use crate::PlatformDialect;
use razeri_plugin_contract::{
BitwiseOperator, CatalogueReach, ColumnEvolution, Concat, ConstraintEnforcement, DataType,
DdlRenderer, DialectCapabilities, DialectFamily, EntityRef, ExpressionRenderer,
FormulaRenderer, HeldAs, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
TableFunctionForm, UNKNOWN_PLATFORM, WrittenObject,
data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
storage_strategy::{
CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
},
};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Blocking,
Unanswered,
Note,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Blocking => "must fix",
Self::Unanswered => "unanswered",
Self::Note => "worth a look",
})
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub severity: Severity,
pub what: String,
pub fix: String,
}
#[derive(Debug, Clone)]
pub struct Report {
pub dialect: String,
pub findings: Vec<Finding>,
}
impl Report {
#[must_use]
pub fn is_clean(&self) -> bool {
!self.findings.iter().any(|f| f.severity != Severity::Note)
}
#[must_use]
pub fn count(&self, severity: Severity) -> usize {
self.findings.iter().filter(|f| f.severity == severity).count()
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = &self.dialect;
if self.findings.is_empty() {
return writeln!(
f,
"{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
);
}
let blocking = self.count(Severity::Blocking);
let unanswered = self.count(Severity::Unanswered);
let notes = self.count(Severity::Note);
writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
for finding in &self.findings {
writeln!(f)?;
writeln!(f, " [{}] {}", finding.severity, finding.what)?;
writeln!(f, " → {}", finding.fix)?;
}
Ok(())
}
}
#[must_use]
pub fn check(dialect: &dyn PlatformDialect) -> Report {
check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
}
#[must_use]
pub fn check_with_registry(
dialect: &dyn PlatformDialect,
registry: &crate::dialect::PlatformRegistry,
) -> Report {
let info = dialect.info();
let mut findings = Vec::new();
check_registration(&info, registry, &mut findings);
check_family(info.dialect_family, &mut findings);
check_required_rendering(dialect, &mut findings);
check_decisions(dialect, &mut findings);
check_formula_renderer(dialect, &mut findings);
check_evolution(dialect, &mut findings);
check_run_again(dialect, &mut findings);
check_transactions(dialect, &mut findings);
check_paired_facts(dialect, &mut findings);
check_schema_fold(dialect, &mut findings);
check_deploy_client(&info, &mut findings);
findings.sort_by_key(|f| f.severity);
Report { dialect: info.name.to_owned(), findings }
}
fn check_registration(
info: &crate::dialect::DialectInfo,
registry: &crate::dialect::PlatformRegistry,
findings: &mut Vec<Finding>,
) {
let name = info.name;
if matches!(
crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
Err(crate::ResolveError::UnknownDialect { .. })
) {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"the name `{name}` does not resolve — nothing in this program registered it"
),
fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
crate's `lib.rs`, where `register` calls \
`builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
dialect one the registry finds, whether razeri loads your library from a \
plugin directory or links your crate in."
.to_owned(),
});
return;
}
for spelling in info.spellings() {
match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
Ok(found) if found.info().name == name => {}
Ok(found) => findings.push(Finding {
severity: Severity::Blocking,
what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
fix: format!(
"the registry holds `{name}` under another key. Register it under its own \
name, or drop `{spelling}` from its aliases."
),
}),
Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`{spelling}` is claimed by more than one loaded platform ({}), so it names \
none of them — Razeri refuses that spelling rather than guessing which one \
a design meant",
claimed_by.join(" and ")
),
fix: format!(
"give this dialect a name no other platform answers to. A platform name is \
read off the loaded dialects' own declared spellings, so a spelling another \
platform already claims is never `{name}`'s."
),
}),
Err(_) => findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
),
fix: format!(
"the `{name}` in the registry is a different build of this dialect and does \
not declare `{spelling}`. Register the one you are grading."
),
}),
}
}
}
fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect's family is a custom one with no name".to_owned(),
fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
prints and what a reader looks up."
.to_owned(),
});
}
}
fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let declarable = declarable_types();
let empty_column: Vec<String> = declarable
.iter()
.filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
.map(|dt| format!("{dt:?}"))
.collect();
if !empty_column.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`render_data_type` answers nothing for {} of the types a design can declare",
empty_column.len()
),
fix: "every declarable type needs a column spelling on this engine, even if the \
spelling is a wider type that stores it. A blank answer becomes a column \
declaration with no type in it."
.to_owned(),
});
}
let empty_cast: Vec<String> = declarable
.iter()
.filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
.map(|dt| format!("{dt:?}"))
.collect();
if !empty_cast.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`render_cast_type` answers nothing for {} declarable types",
empty_cast.len()
),
fix: "the generated load opens by casting every source column to its declared type, \
so a blank answer fails the load on its first statement."
.to_owned(),
});
}
let quoted = SqlRenderer::quote_identifier(dialect, "order");
if quoted == "order" {
findings.push(Finding {
severity: Severity::Blocking,
what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
reaches this engine bare"
.to_owned(),
fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
backticks, or brackets."
.to_owned(),
});
}
if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: "`render_current_timestamp` answers nothing, and every generated load stamps \
a row with it"
.to_owned(),
fix: "return this engine's own expression for the current moment.".to_owned(),
});
}
}
fn check_encoded_file_relation(
dialect: &dyn PlatformDialect,
probe: &str,
findings: &mut Vec<Finding>,
) {
let Some(relation) =
SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
else {
return;
};
if !relation.contains(probe) {
findings.push(Finding {
severity: Severity::Blocking,
what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
the path it was given"
.to_owned(),
fix: "put the literal you were handed where your engine's file relation takes a \
path. It arrives already quoted and escaped."
.to_owned(),
});
}
if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect states how to read a file in a named encoding and states no way \
to read a file at all"
.to_owned(),
fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
first question before the second — a source whose bytes are UTF-8 never \
reaches the encoding arm — so an engine that answers only the second has \
written a relation nothing will ask it for."
.to_owned(),
});
}
}
fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let spelling = SqlRenderer::sid_spelling(dialect);
if spelling.platform == UNKNOWN_PLATFORM {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect states no surrogate-key spelling, so every key it renders calls \
an `xxhash64` its engine very likely does not have"
.to_owned(),
fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
hash. If it has no portable hash, name an artifact your prerequisites install \
— never the nearest-looking built-in, which keys a warehouse to a number \
nothing else can reproduce."
.to_owned(),
});
}
let join = match spelling.concat {
Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
Concat::Plus => "+",
};
if join.trim().is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect's surrogate-key spelling joins the parts of a composite key \
with a function or operator that has no name"
.to_owned(),
fix: "name what your engine joins strings with in `SidSpelling::concat`: \
`Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
operator and no function."
.to_owned(),
});
}
if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
&& supplied.body.trim().is_empty()
{
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
and then supplies an empty one"
.to_owned(),
fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
one argument, or answer `None` and let Razeri compose the derivation from \
your `sid_spelling`. An empty body deploys without error and keys every row \
to nothing."
.to_owned(),
});
}
let probe = "'/razeri/probe.csv'";
if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
&& !relation.contains(probe)
{
findings.push(Finding {
severity: Severity::Blocking,
what: "`delimited_file_relation` returns a relation that does not carry the path it \
was given"
.to_owned(),
fix: "put the literal you were handed where your engine's file relation takes a \
path. It arrives already quoted and escaped. A relation that drops it reads \
one file for every entity the design binds, and the generated fill loads the \
wrong rows without failing."
.to_owned(),
});
}
check_encoded_file_relation(dialect, probe, findings);
check_routine_forms(dialect, findings);
check_slice_replacement(dialect, findings);
if let ConstraintEnforcement::DeclaredOnly(declared) =
DdlRenderer::constraint_enforcement(dialect)
&& declared.note.split_whitespace().count() < 5
{
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect says its engine accepts constraints it does not enforce, and \
the note explaining that says almost nothing"
.to_owned(),
fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
guarantee. Say what this engine really does with the declaration, and where \
the guarantee has to come from instead."
.to_owned(),
});
}
match DialectCapabilities::merge_shape(dialect) {
MergeShape::None => findings.push(Finding {
severity: Severity::Unanswered,
what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
load for it"
.to_owned(),
fix: "if this engine has a MERGE or an upsert statement, say which in \
`DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
`MergeShape::Unsupported` with your engine's own sentence — a refusal an \
operator can act on, rather than a silence."
.to_owned(),
}),
MergeShape::Unsupported(orientation) => {
check_refusal_copy("the upsert refusal", orientation, findings);
}
MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
_ => {}
}
}
fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
const VALUE: &str = "$razeri_slice_probe";
let plan = StoragePlan {
partition: PartitionStrategy::RangeByTimeDimension {
field: "razeri_probe_day".to_owned(),
granularity: PartitionGranularity::Day,
retention: None,
look_ahead: None,
window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
},
compression: CompressionStrategy::None,
index: IndexStrategy::None,
};
let day = DataType::date();
let columns = ["razeri_probe_day"];
let entity = EntityRef {
schema: Some("razeri_probe"),
name: "razeri_probe_table",
is_identity_table: false,
columns: &columns,
partition_column_type: Some(&day),
};
let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
else {
return;
};
let carries = match &answer {
SliceReplacement::Emptied(statements) => {
!statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
}
SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
};
if carries {
return;
}
findings.push(Finding {
severity: Severity::Blocking,
what: "`render_slice_replacement` returns a statement that does not carry the slice it \
was handed"
.to_owned(),
fix: "put the value you were given where your statement names the slice, and build any \
partition NAME from it the same way your extend procedure built it. A replacement \
that drops the value clears a slice this call was not given — or every slice — \
and the load reports success over what it emptied."
.to_owned(),
});
}
fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let answers = DialectCapabilities::evolution_answers(dialect);
let boolean = DataType::boolean();
let long = DataType::varchar(40);
if DdlRenderer::render_column_evolution(
dialect,
&ColumnEvolution::AddColumn {
schema: Some("s"),
table: "t",
column: "c",
data_type: &boolean,
},
)
.is_none()
{
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect writes no statement for a column that is new, which is the \
commonest change a deployed warehouse ever sees"
.to_owned(),
fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
engine that genuinely has no way to add a column is worth saying out loud in \
the crate's own documentation."
.to_owned(),
});
}
if answers.parameterized_string_widening_is_lossless
&& DdlRenderer::render_column_evolution(
dialect,
&ColumnEvolution::WidenColumn {
schema: Some("s"),
table: "t",
column: "c",
data_type: &long,
},
)
.is_none()
{
findings.push(Finding {
severity: Severity::Note,
what: "this dialect says its engine widens a string's declared length in place, and \
writes no statement for the change"
.to_owned(),
fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
engine's spelling of it. Without it Razeri tells an operator the widening \
costs nothing and then hands them nothing to run, which reads as Razeri not \
having finished rather than as a statement they own."
.to_owned(),
});
}
if answers.nested_field_addition_is_lossless
&& DdlRenderer::render_column_evolution(
dialect,
&ColumnEvolution::AddNestedField {
schema: Some("s"),
table: "t",
column: "c",
field: "f",
data_type: &long,
},
)
.is_none()
{
findings.push(Finding {
severity: Severity::Note,
what: "this dialect says its engine keeps every value when a record gains a field, \
and writes no statement for the change"
.to_owned(),
fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
engines name the new field; some restate the whole column's type, which is \
why the change carries both the field's path and the column's type with the \
field in it."
.to_owned(),
});
}
}
fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
let relation = format!("{schema}.{name}");
let table = WrittenObject::new(Some(&schema), &name, &relation);
let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
let mut lost: Vec<&str> = Vec::new();
match DdlRenderer::render_drop_column(dialect, table, &column, false) {
Some(drop) if drop.text().contains(&column) => {}
_ => lost.push("render_drop_column"),
}
if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
&& !guarded.text().contains(&column)
{
lost.push("render_drop_column (only where it stands)");
}
for only_where_it_stands in [false, true] {
if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
&& !drop.text().contains(&name)
{
lost.push("render_drop_view");
}
}
let create = format!("CREATE TABLE {relation} (a INTEGER)");
if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
&& !guarded.text().contains(&create)
{
lost.push("render_create_table_if_absent");
}
lost.dedup();
if !lost.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"{} returns a statement that does not carry the object it was handed",
lost.join(", ")
),
fix: "put the names you were given where your statement names the object, and keep \
a create you guard whole inside the guard. A drop that loses its column drops \
another one or none, and a guard that loses its statement makes nothing — \
both under a run that reports success."
.to_owned(),
});
}
let value = "razeri_probe_value";
let loses_its_value = declarable_types().iter().any(|data_type| {
[HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
ExpressionRenderer::render_comparable(dialect, value, data_type, held)
.is_some_and(|compared| !compared.contains(value))
})
});
if loses_its_value {
findings.push(Finding {
severity: Severity::Blocking,
what: "`render_comparable` answers an expression that does not carry the value it \
stands for"
.to_owned(),
fix: "answer the value itself where your engine compares the type directly, an \
expression over it where it compares one only that way, and `None` where it \
cannot compare one at all — then Razeri refuses such a column by name."
.to_owned(),
});
}
if let Some(limits) = DialectCapabilities::text_limits(dialect)
&& (limits.longest_varchar == Some(0)
|| limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
{
findings.push(Finding {
severity: Severity::Blocking,
what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
unbounded text cannot be"
.to_owned(),
fix: "state the longest varchar(n) your engine creates — one more is refused at \
CREATE TABLE — and name what it refuses a text column as, or leave it None."
.to_owned(),
});
}
match DialectCapabilities::catalogue_reach(dialect) {
CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
check_refusal_copy("the reason the schema check is not asked", reason, findings);
}
_ => {}
}
}
fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
if SqlRenderer::render_procedure_log(
dialect,
razeri_plugin_contract::ProcedureLogLevel::Info,
"'razeri'",
)
.is_some_and(|line| line.trim().is_empty())
{
findings.push(Finding {
severity: Severity::Blocking,
what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
fix: "answer `None` where your engine has no statement that writes a message: the \
line is then left out of the routine."
.to_owned(),
});
}
if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
&& !DialectCapabilities::supports_multi_statement_transactions(dialect)
{
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect says its begin statement leaves the commit to the client, and \
that its engine has no transaction spanning statements"
.to_owned(),
fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
run in one transaction. Where there is none, the bundle already tells the \
operator the boundary is theirs."
.to_owned(),
});
}
}
fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
if restrictions.slice_pruning_needs_literal {
findings.push(Finding {
severity: Severity::Note,
what: "this dialect says its planner reads one slice of a partitioned table only \
from a literal, so a load scoped to one day cannot simply bind the day"
.to_owned(),
fix: "Razeri will put the day into the statement text through this engine's \
dynamic-execution form rather than binding it to the load procedure's \
parameter. Confirm that is what your engine wants: the statement is composed \
once per call and its text differs per day, which some planners cache and \
some do not. If a bound parameter does prune here, drop the restriction and \
Razeri binds it like everywhere else."
.to_owned(),
});
}
}
fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let bitwise = BitwiseOperator::ALL.map(|operator| {
(
format!("render_bitwise_operator({operator:?})"),
FormulaRenderer::render_bitwise_operator(dialect, operator),
)
});
let blank: Vec<String> = [
("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
(
"render_not_ilike_operator".to_owned(),
FormulaRenderer::render_not_ilike_operator(dialect),
),
("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
]
.into_iter()
.chain(bitwise)
.filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
.map(|(method, _)| method)
.collect();
if !blank.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!("{} answers an operator with no text in it", blank.join(", ")),
fix: "answer `None` where your engine has no such operator: a formula that needs it \
is then refused by name, and the reader is told to write the function your \
engine has. An empty answer writes the two operands side by side."
.to_owned(),
});
}
let formula = SqlRenderer::to_formula_renderer(dialect);
let dialect_name = SqlRenderer::dialect_name(dialect);
if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
findings.push(Finding {
severity: Severity::Blocking,
what: "`to_formula_renderer` was not overridden, so every formula in every design \
renders through Razeri's ANSI defaults instead of this dialect"
.to_owned(),
fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
formula this engine sees is silently somebody else's."
.to_owned(),
});
}
}
fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
match SqlRenderer::procedure_form(dialect) {
ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
ProcedureForm::Agnostic => findings.push(Finding {
severity: Severity::Unanswered,
what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
annotated form — which no engine parses"
.to_owned(),
fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
offers instead."
.to_owned(),
}),
ProcedureForm::Unsupported(orientation) => {
check_refusal_copy("the stored-procedure refusal", orientation, findings);
if DialectCapabilities::supports_stored_procedures(dialect) {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect says its engine has stored procedures, and its \
procedure form refuses to write one"
.to_owned(),
fix: "decide which is true. If the engine has none, leave \
`DialectCapabilities::supports_stored_procedures` at `false`; if it \
has them, answer `procedure_form` with the grammar it speaks."
.to_owned(),
});
}
}
ProcedureForm::Custom(grammar) => check_custom_grammar(
CustomGrammar {
kind: RoutineKind::Procedure,
grammar,
form_method: "procedure_form",
render_method: "render_procedure",
noun: "procedures",
},
|spec| SqlRenderer::render_procedure(dialect, spec),
findings,
),
_ => {}
}
check_procedures_are_used(dialect, findings);
match SqlRenderer::scalar_function_form(dialect) {
ScalarFunctionForm::Unsupported(orientation) => {
check_refusal_copy("the scalar-function refusal", orientation, findings);
}
ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
CustomGrammar {
kind: RoutineKind::ScalarFunction,
grammar,
form_method: "scalar_function_form",
render_method: "render_scalar_function",
noun: "scalar functions",
},
|spec| SqlRenderer::render_scalar_function(dialect, spec),
findings,
),
_ => {}
}
match SqlRenderer::table_function_form(dialect) {
TableFunctionForm::Unsupported(orientation) => {
check_refusal_copy("the table-function refusal", orientation, findings);
}
TableFunctionForm::Custom(grammar) => check_custom_grammar(
CustomGrammar {
kind: RoutineKind::TableFunction,
grammar,
form_method: "table_function_form",
render_method: "render_table_function",
noun: "table functions",
},
|spec| SqlRenderer::render_table_function(dialect, spec),
findings,
),
_ => {}
}
check_parameter_type_spelling(dialect, findings);
}
fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
return;
};
if DialectCapabilities::supports_stored_procedures(dialect) {
return;
}
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"this dialect writes procedures in {grammar:?} and says its engine has none, so no \
load is ever written as one"
),
fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
it before it writes a load as a procedure, and its default is `false`."
.to_owned(),
});
}
fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
|| SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
|| SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
if !spells_its_own {
return;
}
let qualified = DataType::varchar(100);
let column = SqlRenderer::render_data_type(dialect, &qualified);
let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
if column == parameter && column.contains('(') {
findings.push(Finding {
severity: Severity::Note,
what: format!(
"a parameter of this routine's would be declared `{parameter}`, the same as a \
column of that type"
),
fix: "that is right for most engines, so this is only worth a look. If yours refuses \
a length or a precision in a parameter list — Oracle does, and answers \
PLS-00103 at the opening parenthesis — override \
`SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
declarations keep the length either way."
.to_owned(),
});
}
}
#[derive(Clone, Copy)]
struct CustomGrammar {
kind: RoutineKind,
grammar: &'static str,
form_method: &'static str,
render_method: &'static str,
noun: &'static str,
}
fn check_custom_grammar(
claim: CustomGrammar,
render: impl Fn(&RoutineSpec) -> Option<String>,
findings: &mut Vec<Finding>,
) {
let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
if grammar.trim().is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!("`{form_method}` says Custom, and names no grammar"),
fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
of a report, and of the comment Razeri writes where a routine could not be \
rendered, is told this engine speaks."
.to_owned(),
});
}
let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
probe.or_replace = true;
if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
itself — and `{render_method}` returns nothing"
),
fix: format!(
"override `SqlRenderer::{render_method}`. It receives the routine Razeri \
composed — name, parameters with their types already in your spelling, body, \
comment — and returns the whole CREATE statement your engine parses. Razeri \
emits what you return and writes nothing of its own: a near-neighbour grammar \
fails on deploy, and a refusal would say this engine has no {noun} when it \
has them in a grammar of its own."
),
});
}
}
fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
if copy.trim().is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!("{subject} has no words in it"),
fix: "say what this engine offers instead. A reader who hits a refusal needs the \
next thing to try, and it has to be about their engine."
.to_owned(),
});
return;
}
if copy.split_whitespace().count() < 5 {
findings.push(Finding {
severity: Severity::Note,
what: format!("{subject} is very short"),
fix: "a refusal earns its place by naming the alternative — what this engine has \
instead of the thing it cannot do."
.to_owned(),
});
}
}
fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let caps: &dyn DialectCapabilities = dialect;
let ren: &dyn SqlRenderer = dialect;
let ddl: &dyn DdlRenderer = dialect;
let mut disagreements: Vec<&'static str> = Vec::new();
macro_rules! pair {
($m:ident, $renderer_value:expr) => {
if caps.$m() != $renderer_value {
disagreements.push(stringify!($m));
}
};
}
pair!(supports_on_conflict, ren.supports_on_conflict());
pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
pair!(supports_update_from, ren.supports_update_from());
pair!(supports_named_windows, ren.supports_named_windows());
pair!(supports_compound_assignment, ren.supports_compound_assignment());
pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
pair!(uses_alter_table_update, ren.uses_alter_table_update());
pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
pair!(supports_cascade_drop, ddl.supports_cascade_drop());
pair!(supports_sync_drop, ddl.supports_sync_drop());
pair!(supports_force_drop, ddl.supports_force_drop());
if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
disagreements.push("supports_table_functions");
}
if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
disagreements.push("supports_stored_functions");
}
if !disagreements.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"this dialect declares {} {} two ways and the two disagree: {}",
disagreements.len(),
if disagreements.len() == 1 { "fact" } else { "facts" },
disagreements.join(", ")
),
fix: "each of these is declared once on the capability trait and once in the \
renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
and move the other one to match."
.to_owned(),
});
}
}
fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
return;
};
if DialectCapabilities::supports_schemas(dialect) {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect folds a schema into an object's name on an engine it says has \
schemas"
.to_owned(),
fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
schemas, and only there is it asked; or answer \
`DialectCapabilities::supports_schemas` with `false` if your engine has none."
.to_owned(),
});
return;
}
let expected = SqlRenderer::quote_identifier(dialect, &folded);
let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
if qualified != expected {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
the same object `{qualified}`"
),
fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
schema. Razeri names some objects through it and the rest through the fold, \
so a difference creates a table under one name and reads it under another."
.to_owned(),
});
}
}
fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
return;
};
if client.command.trim().is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: "this dialect describes a deploy client with no command".to_owned(),
fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
});
}
let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
.into_iter()
.filter_map(|(field, args)| {
args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
})
.collect();
if !unnamed.is_empty() {
findings.push(Finding {
severity: Severity::Blocking,
what: format!(
"the deploy client's {} never names the file to run",
unnamed.join(" and ")
),
fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
inside an argument such as `--file={file}`. The script writes the file there \
and nowhere else."
.to_owned(),
});
}
if let Some(terminator) = client.block_terminator
&& [terminator.open, terminator.end, terminator.close]
.iter()
.any(|part| part.trim().is_empty())
{
findings.push(Finding {
severity: Severity::Blocking,
what: "the deploy client's block terminator leaves a part blank".to_owned(),
fix: "name the line before a routine, what ends it, and the line after it — \
`SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
the routine open, and the next statement is read into it."
.to_owned(),
});
}
}
fn declarable_types() -> Vec<DataType> {
vec![
DataType::tinyint(),
DataType::smallint(),
DataType::int(),
DataType::bigint(),
DataType::double(),
DataType::boolean(),
DataType::text(),
DataType::varchar(200),
DataType::uuid(),
DataType::json(),
DataType::date(),
DataType::timestamp(),
DataType::timestamp_tz(),
DataType::decimal(18, 2),
DataType::Array(ArrayDataType::new(DataType::varchar(100))),
DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
DataType::Struct(StructDataType::new(vec![
StructField::new("street", DataType::varchar(200)),
StructField::new("zip", DataType::varchar(10)),
])),
DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
StructField::new("key", DataType::varchar(100)),
StructField::new("value", DataType::varchar(1000)),
])))),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dialect::DialectInfo;
use razeri_plugin_contract::{
Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
TaskRenderer, TextCast,
};
use std::marker::PhantomData;
struct BrokenPlugin;
impl SqlRenderer for BrokenPlugin {
fn render_data_type(&self, _dt: &DataType) -> String {
String::new()
}
fn quote_identifier(&self, name: &str) -> String {
name.to_owned()
}
fn render_cast_type(&self, _dt: &DataType) -> String {
String::new()
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
name.to_owned()
}
fn render_current_timestamp(&self) -> String {
String::new()
}
fn dialect_name(&self) -> &'static str {
"brokendb"
}
fn supports_on_conflict(&self) -> bool {
true
}
}
impl ExpressionRenderer for BrokenPlugin {}
impl DdlRenderer for BrokenPlugin {}
impl DmlRenderer for BrokenPlugin {}
impl ProcedureRenderer for BrokenPlugin {}
impl FormulaRenderer for BrokenPlugin {}
impl TaskRenderer for BrokenPlugin {}
impl ReplicationRenderer for BrokenPlugin {}
impl StorageRenderer for BrokenPlugin {}
impl DialectCapabilities for BrokenPlugin {
fn supports_on_conflict(&self) -> bool {
false
}
}
impl PlatformDialect for BrokenPlugin {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "brokendb",
display_name: "Broken DB",
aliases: &[],
dialect_family: DialectFamily::Custom(""),
family: "Broken",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
trait FixtureName: Send + Sync + 'static {
const NAME: &'static str;
const CONCAT: Concat = Concat::Function("CONCAT");
const POWER: Option<&'static str> = Some("^");
const SHIFT_LEFT: Option<&'static str> = Some("<<");
const DROP_LOSES_ITS_COLUMN: bool = false;
const COMPARES_SOMETHING_ELSE: bool = false;
const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
const DEPLOY: Option<crate::dialect::DeployScript> = None;
}
struct Unlinked;
impl FixtureName for Unlinked {
const NAME: &'static str = "healthydb";
}
struct Outside;
impl FixtureName for Outside {
const NAME: &'static str = "outsidedb";
}
struct BinaryConcat;
impl FixtureName for BinaryConcat {
const NAME: &'static str = "binaryconcatdb";
const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
}
struct OperatorConcat;
impl FixtureName for OperatorConcat {
const NAME: &'static str = "operatorconcatdb";
const CONCAT: Concat = Concat::Operator("||");
}
struct NamelessConcat;
impl FixtureName for NamelessConcat {
const NAME: &'static str = "namelessconcatdb";
const CONCAT: Concat = Concat::Operator(" ");
}
struct NoPowerOperator;
impl FixtureName for NoPowerOperator {
const NAME: &'static str = "nopowerdb";
const POWER: Option<&'static str> = None;
}
struct BlankPowerOperator;
impl FixtureName for BlankPowerOperator {
const NAME: &'static str = "blankpowerdb";
const POWER: Option<&'static str> = Some(" ");
}
struct ComparesSomethingElse;
impl FixtureName for ComparesSomethingElse {
const NAME: &'static str = "comparesotherdb";
const COMPARES_SOMETHING_ELSE: bool = true;
}
struct DropLosesItsColumn;
impl FixtureName for DropLosesItsColumn {
const NAME: &'static str = "droplosesdb";
const DROP_LOSES_ITS_COLUMN: bool = true;
}
struct BeginWithoutTransactions;
impl FixtureName for BeginWithoutTransactions {
const NAME: &'static str = "beginwithouttxdb";
const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
}
struct NoShiftOperator;
impl FixtureName for NoShiftOperator {
const NAME: &'static str = "noshiftdb";
const SHIFT_LEFT: Option<&'static str> = None;
}
struct BlankShiftOperator;
impl FixtureName for BlankShiftOperator {
const NAME: &'static str = "blankshiftdb";
const SHIFT_LEFT: Option<&'static str> = Some("");
}
struct DescribedClient;
impl FixtureName for DescribedClient {
const NAME: &'static str = "describedclientdb";
const DEPLOY: Option<crate::dialect::DeployScript> =
Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
command: "sqlite3",
run_file: &["-bail", "{target}", ".read {file}"],
read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
block_terminator: None,
}));
}
struct FilelessClient;
impl FixtureName for FilelessClient {
const NAME: &'static str = "filelessclientdb";
const DEPLOY: Option<crate::dialect::DeployScript> =
Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
command: "sqlite3",
run_file: &["-bail", "{target}"],
read_rows: None,
block_terminator: None,
}));
}
struct BlankBlockEnd;
impl FixtureName for BlankBlockEnd {
const NAME: &'static str = "blankblockenddb";
const DEPLOY: Option<crate::dialect::DeployScript> =
Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
command: "isql",
run_file: &["-bail", "-input", "{file}", "{target}"],
read_rows: None,
block_terminator: Some(crate::dialect::BlockTerminator {
open: "SET TERM ^ ;",
end: " ",
close: "SET TERM ; ^",
}),
}));
}
struct Answered<N: FixtureName>(PhantomData<N>);
impl<N: FixtureName> Answered<N> {
const fn new() -> Self {
Self(PhantomData)
}
}
type HealthyPlugin = Answered<Unlinked>;
type OutsidePlugin = Answered<Outside>;
inventory::submit! {
crate::builder::DialectRegistration::new(
Outside::NAME,
|| std::sync::Arc::new(OutsidePlugin::new()),
)
}
impl<N: FixtureName> SqlRenderer for Answered<N> {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
schema.map_or_else(
|| self.quote_identifier(name),
|s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
)
}
fn render_current_timestamp(&self) -> String {
"NOW()".to_owned()
}
fn dialect_name(&self) -> &'static str {
N::NAME
}
fn sid_spelling(&self) -> SidSpelling {
SidSpelling {
platform: N::NAME,
hash: ("answered_xxhash64(", ")"),
cast_type: TextCast::Direct("STRING"),
concat: N::CONCAT,
through_helper: false,
..razeri_plugin_contract::ANSI_SID_SPELLING
}
}
fn procedure_form(&self) -> ProcedureForm {
ProcedureForm::Unsupported(
"This engine has no stored procedures; write the load as a scheduled query \
instead.",
)
}
fn scalar_function_form(&self) -> ScalarFunctionForm {
ScalarFunctionForm::PostgreSql
}
fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
Box::new(Self::new())
}
}
impl<N: FixtureName> ExpressionRenderer for Answered<N> {
fn render_comparable(
&self,
value: &str,
_data_type: &DataType,
_held: HeldAs,
) -> Option<String> {
Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
}
}
impl<N: FixtureName> DdlRenderer for Answered<N> {
fn render_drop_column(
&self,
table: WrittenObject<'_>,
column: &str,
only_where_it_stands: bool,
) -> Option<razeri_plugin_contract::Written> {
if N::DROP_LOSES_ITS_COLUMN {
return Some(razeri_plugin_contract::Written::Statement(format!(
"ALTER TABLE {} DROP COLUMN c",
table.relation
)));
}
let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
Some(razeri_plugin_contract::Written::Statement(format!(
"ALTER TABLE {} DROP COLUMN{guard} {column}",
table.relation
)))
}
}
impl<N: FixtureName> DmlRenderer for Answered<N> {}
impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
impl<N: FixtureName> FormulaRenderer for Answered<N> {
fn render_power_operator(&self) -> Option<String> {
N::POWER.map(str::to_owned)
}
fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
match operator {
BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
other => Some(other.standard_spelling().to_owned()),
}
}
}
impl<N: FixtureName> TaskRenderer for Answered<N> {}
impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
impl<N: FixtureName> StorageRenderer for Answered<N> {}
impl<N: FixtureName> DialectCapabilities for Answered<N> {
fn begin_leaves_the_commit_to_the_client(&self) -> bool {
N::BEGIN_WITHOUT_TRANSACTIONS
}
fn supports_multi_statement_transactions(&self) -> bool {
!N::BEGIN_WITHOUT_TRANSACTIONS
}
fn merge_shape(&self) -> MergeShape {
MergeShape::Merge(MergeRestrictions::none())
}
}
impl<N: FixtureName> PlatformDialect for Answered<N> {
fn info(&self) -> DialectInfo {
DialectInfo {
name: N::NAME,
display_name: "An Answered Engine",
aliases: &[],
dialect_family: DialectFamily::Custom(N::NAME),
family: "Answered",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: N::DEPLOY,
}
}
}
struct Twin;
impl SqlRenderer for Twin {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
self.quote_identifier(name)
}
fn render_current_timestamp(&self) -> String {
"NOW()".to_owned()
}
}
impl ExpressionRenderer for Twin {}
impl DdlRenderer for Twin {}
impl DmlRenderer for Twin {}
impl ProcedureRenderer for Twin {}
impl FormulaRenderer for Twin {}
impl TaskRenderer for Twin {}
impl ReplicationRenderer for Twin {}
impl StorageRenderer for Twin {}
impl DialectCapabilities for Twin {}
impl PlatformDialect for Twin {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "twindb",
display_name: "Twin DB",
aliases: &["healthydb"],
dialect_family: DialectFamily::Custom("twindb"),
family: "Twin",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
fn mentions(report: &Report, needle: &str) -> bool {
report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
}
#[test]
fn the_kit_catches_a_deliberately_broken_plugin() {
let report = check(&BrokenPlugin);
assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
assert!(
report.count(Severity::Blocking) >= 8,
"the broken plugin owes more than {} blocking fixes:\n{report}",
report.count(Severity::Blocking)
);
}
#[test]
fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
let report = check(&HealthyPlugin::new());
let unregistered: Vec<&Finding> =
report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
assert_eq!(
report.findings.len(),
1,
"a plugin that answered every question owes nothing else:\n{report}"
);
}
#[test]
fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
let binary = Answered::<BinaryConcat>::new();
let report = check(&binary);
assert_eq!(
report.findings.len(),
check(&HealthyPlugin::new()).findings.len(),
"the join form is a decision, so stating this one owes nothing extra:\n{report}"
);
assert!(
!report.to_string().contains("joins"),
"nothing about the join is outstanding:\n{report}"
);
let key = razeri_plugin_contract::sid::sid_expression(
SqlRenderer::sid_spelling(&binary),
razeri_plugin_contract::sid::Backslash::of(&binary),
None,
&["a".to_owned(), "b".to_owned(), "c".to_owned()],
);
assert!(
key.contains("CONCAT(CONCAT("),
"a two-argument engine gets one call per pair:\n{key}"
);
}
#[test]
fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
let operator = Answered::<OperatorConcat>::new();
let report = check(&operator);
assert_eq!(
report.findings.len(),
check(&HealthyPlugin::new()).findings.len(),
"the join form is a decision, so stating this one owes nothing extra:\n{report}"
);
let key = razeri_plugin_contract::sid::sid_expression(
SqlRenderer::sid_spelling(&operator),
razeri_plugin_contract::sid::Backslash::of(&operator),
None,
&["a".to_owned(), "b".to_owned()],
);
assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
}
#[test]
fn a_join_that_names_nothing_must_be_fixed() {
let report = check(&Answered::<NamelessConcat>::new());
assert!(
report.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("joins the parts of a composite key")),
"{report}"
);
assert!(
!check(&Answered::<OperatorConcat>::new())
.findings
.iter()
.any(|finding| finding.what.contains("joins the parts of a composite key")),
"a named operator is not the finding"
);
}
#[test]
fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
let none = check(&Answered::<NoPowerOperator>::new());
assert_eq!(
none.findings.len(),
check(&HealthyPlugin::new()).findings.len(),
"saying the engine has no such operator owes nothing:\n{none}"
);
let blank = check(&Answered::<BlankPowerOperator>::new());
assert!(
blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("render_power_operator")),
"{blank}"
);
}
#[test]
fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
let none = check(&Answered::<NoShiftOperator>::new());
assert_eq!(
none.findings.len(),
check(&HealthyPlugin::new()).findings.len(),
"saying the engine has no left shift owes nothing:\n{none}"
);
let blank = check(&Answered::<BlankShiftOperator>::new());
assert!(
blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("render_bitwise_operator(ShiftLeft)")),
"{blank}"
);
}
#[test]
fn a_drop_that_loses_its_column_must_be_fixed() {
let healthy = check(&HealthyPlugin::new());
assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
let lost = check(&Answered::<DropLosesItsColumn>::new());
assert!(
lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("render_drop_column")),
"{lost}"
);
}
#[test]
fn a_comparison_that_loses_its_value_must_be_fixed() {
assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
let report = check(&Answered::<ComparesSomethingElse>::new());
assert!(
report.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("render_comparable")),
"{report}"
);
}
#[test]
fn a_begin_that_leaves_the_commit_needs_a_transaction() {
let report = check(&Answered::<BeginWithoutTransactions>::new());
assert!(
report.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("leaves the commit to the client")),
"{report}"
);
}
#[test]
fn a_block_terminator_with_a_blank_part_must_be_fixed() {
let report = check(&Answered::<BlankBlockEnd>::new());
assert!(
report.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("block terminator")),
"{report}"
);
}
#[test]
fn a_deploy_client_has_to_be_told_which_file_to_run() {
let described = check(&Answered::<DescribedClient>::new());
assert_eq!(
described.findings.len(),
check(&HealthyPlugin::new()).findings.len(),
"a client described in full owes nothing:\n{described}"
);
let fileless = check(&Answered::<FilelessClient>::new());
assert!(
fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
&& finding.what.contains("run_file never names the file")),
"{fileless}"
);
}
#[test]
fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
struct Folder<const SCHEMAS: bool, const AGREES: bool>;
impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
match schema {
Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
None => self.quote_identifier(name),
}
}
fn render_current_timestamp(&self) -> String {
"CURRENT_TIMESTAMP".to_owned()
}
fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
Some(format!("{schema}_{name}"))
}
}
impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
fn supports_schemas(&self) -> bool {
S
}
}
impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "folderdb",
display_name: "Folder DB",
aliases: &[],
dialect_family: DialectFamily::Custom("folderdb"),
family: "Folder",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
let fold_findings = |dialect: &dyn PlatformDialect| {
let mut findings = Vec::new();
check_schema_fold(dialect, &mut findings);
findings
};
assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
let disagrees = fold_findings(&Folder::<false, false>);
assert_eq!(disagrees.len(), 1, "{disagrees:?}");
assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
let has_schemas = fold_findings(&Folder::<true, true>);
assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
}
#[test]
fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
let mut quiet = Vec::new();
check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
let mut stated = Vec::new();
check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
assert_eq!(stated.len(), 1, "the stated restriction is reported once");
assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
assert!(
stated[0].what.contains("literal"),
"the finding names the restriction:\n{}",
stated[0].what
);
assert!(
stated[0].fix.contains("dynamic-execution"),
"and says what Razeri does with the answer:\n{}",
stated[0].fix
);
let mut two_clauses = Vec::new();
check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
assert!(two_clauses.is_empty(), "one restriction is not the other");
}
#[test]
fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
assert!(
crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
"the fixture registers through the registry, which is the whole path being graded"
);
let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
assert!(
registry
.list()
.iter()
.filter(|info| info.name != Outside::NAME)
.all(|info| !info.answers_to(Outside::NAME)),
"the fixture's name must be one no other platform claims"
);
let report = check(&OutsidePlugin::new());
assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
assert!(report.is_clean(), "and the report says so:\n{report}");
assert_eq!(report.count(Severity::Blocking), 0, "{report}");
assert!(
report.to_string().contains("nothing to fix"),
"a clean dialect is told it is clean:\n{report}"
);
}
#[test]
fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
for info in registry.list() {
assert_eq!(
info.spellings().first().copied(),
Some(info.name),
"a platform's own name leads its spellings"
);
assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
}
}
#[test]
fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
let mut registry = crate::dialect::PlatformRegistry::new();
registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
let report = check_with_registry(&Twin, ®istry);
let collision = report
.findings
.iter()
.find(|f| f.what.contains("claimed by more than one loaded platform"))
.unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
assert!(collision.what.contains("twindb"), "{collision:?}");
assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
assert!(
!mentions(&report, "does not resolve"),
"a collision is not an unregistered platform:\n{report}"
);
}
#[test]
fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
let mut registry = crate::dialect::PlatformRegistry::new();
registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
let mut findings = Vec::new();
check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
assert!(findings.is_empty(), "{findings:?}");
for spelling in ["healthydb", "HealthyDB", " healthydb "] {
assert!(
crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
"'{spelling}' is the same platform"
);
}
assert!(
crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
);
}
#[test]
fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
let mut registry = crate::dialect::PlatformRegistry::new();
registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
let report = check_with_registry(&HealthyPlugin::new(), ®istry);
assert!(
report.is_clean(),
"a loaded dialect that answered everything owes nothing:\n{report}"
);
}
#[test]
fn no_fix_line_sends_an_author_into_razeris_own_tree() {
let mut collided = crate::dialect::PlatformRegistry::new();
collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
let mut fixes: Vec<String> = Vec::new();
for report in [
check(&BrokenPlugin),
check(&HealthyPlugin::new()),
check(&OutsidePlugin::new()),
check_with_registry(&Twin, &collided),
check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
] {
fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
}
assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
for fix in &fixes {
for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
assert!(
!fix.contains(path),
"a fix line names `{path}`, which is not in the author's crate:\n {fix}"
);
}
}
}
#[test]
fn no_finding_holds_a_run_of_spaces() {
struct Declining;
impl SqlRenderer for Declining {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
name.to_owned()
}
fn render_current_timestamp(&self) -> String {
"CURRENT_TIMESTAMP".to_owned()
}
fn procedure_form(&self) -> ProcedureForm {
ProcedureForm::Custom("Declining PSQL")
}
}
impl ExpressionRenderer for Declining {}
impl DdlRenderer for Declining {
fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
None
}
}
impl DialectCapabilities for Declining {
fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
.and_a_nested_field()
}
}
impl DmlRenderer for Declining {}
impl ProcedureRenderer for Declining {}
impl FormulaRenderer for Declining {}
impl TaskRenderer for Declining {}
impl ReplicationRenderer for Declining {}
impl StorageRenderer for Declining {}
impl PlatformDialect for Declining {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "decliningdb",
display_name: "Declining DB",
aliases: &[],
dialect_family: DialectFamily::Custom("decliningdb"),
family: "Declining",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
let mut findings = Vec::new();
check_evolution(&Declining, &mut findings);
check_parameter_type_spelling(&Declining, &mut findings);
assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
for report in [
check(&BrokenPlugin),
check(&HealthyPlugin::new()),
check(&OutsidePlugin::new()),
check(&Declining),
check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
] {
findings.extend(report.findings);
}
for finding in &findings {
for text in [&finding.what, &finding.fix] {
assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
}
}
}
#[test]
fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
for (kind, form_method, render_method) in [
(RoutineKind::Procedure, "procedure_form", "render_procedure"),
(RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
(RoutineKind::TableFunction, "table_function_form", "render_table_function"),
] {
let mut promised = Vec::new();
check_custom_grammar(
CustomGrammar {
kind,
grammar: "PL/SQL",
form_method,
render_method,
noun: "routines",
},
|_| None,
&mut promised,
);
assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
assert!(promised[0].what.contains(form_method), "{promised:?}");
assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
assert!(promised[0].fix.contains(render_method), "{promised:?}");
let mut kept = Vec::new();
check_custom_grammar(
CustomGrammar {
kind,
grammar: "PL/SQL",
form_method,
render_method,
noun: "routines",
},
|spec| Some(format!("CREATE {} …", spec.name)),
&mut kept,
);
assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
}
}
#[test]
fn a_procedure_form_and_the_procedure_capability_must_agree() {
fn check_forms(dialect: &dyn PlatformDialect) -> Report {
let mut findings = Vec::new();
check_routine_forms(dialect, &mut findings);
Report { dialect: "claimsdb".to_owned(), findings }
}
struct Claims<const CUSTOM: bool, const HAS: bool>;
impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
self.quote_identifier(name)
}
fn render_current_timestamp(&self) -> String {
"CURRENT_TIMESTAMP".to_owned()
}
fn procedure_form(&self) -> ProcedureForm {
if CUSTOM {
ProcedureForm::Custom("Firebird PSQL")
} else {
ProcedureForm::Unsupported(
"This engine has no stored procedures; schedule the load as a query.",
)
}
}
fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
}
}
impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
fn supports_stored_procedures(&self) -> bool {
HAS
}
}
impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "claimsdb",
display_name: "Claims DB",
aliases: &[],
dialect_family: DialectFamily::Custom("claimsdb"),
family: "Claims",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
let disagreement = |report: &Report| {
report
.findings
.iter()
.filter(|finding| {
finding.severity == Severity::Blocking
&& (finding.what.contains("says its engine has none")
|| finding.what.contains("refuses to write one"))
})
.count()
};
let mut findings = Vec::new();
check_routine_forms(&Claims::<true, false>, &mut findings);
let report = Report { dialect: "claimsdb".to_owned(), findings };
assert_eq!(disagreement(&report), 1, "{report}");
assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
for (agrees, label) in [
(check_forms(&Claims::<true, true>), "spelled and used"),
(check_forms(&Claims::<false, false>), "refused and absent"),
] {
assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
}
assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
}
#[test]
fn a_custom_render_method_that_returns_nothing_is_blocking() {
let mut findings = Vec::new();
check_custom_grammar(
CustomGrammar {
kind: RoutineKind::Procedure,
grammar: "PL/SQL",
form_method: "procedure_form",
render_method: "render_procedure",
noun: "procedures",
},
|_| Some(" \n".to_owned()),
&mut findings,
);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
}
#[test]
fn a_custom_grammar_with_no_name_is_blocking() {
let mut findings = Vec::new();
check_custom_grammar(
CustomGrammar {
kind: RoutineKind::ScalarFunction,
grammar: " ",
form_method: "scalar_function_form",
render_method: "render_scalar_function",
noun: "scalar functions",
},
|spec| Some(format!("CREATE FUNCTION {}", spec.name)),
&mut findings,
);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
}
#[test]
fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
struct Overstated;
impl SqlRenderer for Overstated {
fn render_data_type(&self, dt: &DataType) -> String {
razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
}
fn quote_identifier(&self, name: &str) -> String {
format!("\"{name}\"")
}
fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
name.to_owned()
}
fn render_current_timestamp(&self) -> String {
"CURRENT_TIMESTAMP".to_owned()
}
fn dialect_name(&self) -> &'static str {
"overstateddb"
}
fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
Some(razeri_plugin_contract::SidHelperBody {
language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
body: " \n ",
})
}
fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
}
}
impl ExpressionRenderer for Overstated {}
impl DdlRenderer for Overstated {}
impl DmlRenderer for Overstated {}
impl ProcedureRenderer for Overstated {}
impl FormulaRenderer for Overstated {}
impl TaskRenderer for Overstated {}
impl ReplicationRenderer for Overstated {}
impl StorageRenderer for Overstated {}
impl DialectCapabilities for Overstated {}
impl PlatformDialect for Overstated {
fn info(&self) -> DialectInfo {
DialectInfo {
name: "overstateddb",
display_name: "Overstated DB",
aliases: &[],
dialect_family: DialectFamily::Custom(""),
family: "Overstated",
is_cloud_native: false,
is_olap: false,
is_postgres_compatible: false,
versions: &[],
deploy_script: None,
}
}
}
let report = check(&Overstated);
for (fragment, why) in [
("supplies an empty one", "a blank helper body"),
("does not carry the path", "a file relation that drops its path"),
] {
let found = report
.findings
.iter()
.find(|f| f.what.contains(fragment))
.unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
}
let quiet = check(&HealthyPlugin::new());
for fragment in ["supplies an empty one", "does not carry the path"] {
assert!(
!quiet.findings.iter().any(|f| f.what.contains(fragment)),
"silence is the default and owes nothing:\n{quiet}"
);
}
}
#[test]
fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
let mut mute = Vec::new();
check_refusal_copy("the upsert refusal", "no merge", &mut mute);
assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
let silence = check(&BrokenPlugin);
assert!(
silence
.findings
.iter()
.any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
"a dialect that never answered is unanswered, not wrong:\n{silence}"
);
assert!(
silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
"the fix line names the way to state a measured refusal:\n{silence}"
);
}
#[test]
fn the_report_reads_as_a_summary_then_a_fix_list() {
let text = check(&BrokenPlugin).to_string();
let first = text.lines().next().expect("a summary line");
assert!(first.starts_with("brokendb:"), "{first}");
assert!(first.contains("to fix"), "{first}");
assert!(text.contains(" → "), "every finding names the fix:\n{text}");
}
}