1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 ColumnEvolution, ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities,
47 DialectFamily, EntityRef, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
48 RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
49 TableFunctionForm, UNKNOWN_PLATFORM,
50 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
51 storage_strategy::{
52 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
53 },
54};
55use std::fmt;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Severity {
60 Blocking,
63 Unanswered,
66 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#[derive(Debug, Clone)]
83pub struct Finding {
84 pub severity: Severity,
86 pub what: String,
88 pub fix: String,
90}
91
92#[derive(Debug, Clone)]
94pub struct Report {
95 pub dialect: String,
97 pub findings: Vec<Finding>,
99}
100
101impl Report {
102 #[must_use]
106 pub fn is_clean(&self) -> bool {
107 !self.findings.iter().any(|f| f.severity != Severity::Note)
108 }
109
110 #[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#[must_use]
146pub fn check(dialect: &dyn PlatformDialect) -> Report {
147 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
148}
149
150#[must_use]
153pub fn check_with_registry(
154 dialect: &dyn PlatformDialect,
155 registry: &crate::dialect::PlatformRegistry,
156) -> Report {
157 let info = dialect.info();
158 let mut findings = Vec::new();
159
160 check_registration(&info, registry, &mut findings);
161 check_family(info.dialect_family, &mut findings);
162 check_required_rendering(dialect, &mut findings);
163 check_decisions(dialect, &mut findings);
164 check_formula_renderer(dialect, &mut findings);
165 check_evolution(dialect, &mut findings);
166 check_paired_facts(dialect, &mut findings);
167
168 findings.sort_by_key(|f| f.severity);
169 Report { dialect: info.name.to_owned(), findings }
170}
171
172fn check_registration(
183 info: &crate::dialect::DialectInfo,
184 registry: &crate::dialect::PlatformRegistry,
185 findings: &mut Vec<Finding>,
186) {
187 let name = info.name;
188 if matches!(
189 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
190 Err(crate::ResolveError::UnknownDialect { .. })
191 ) {
192 findings.push(Finding {
193 severity: Severity::Blocking,
194 what: format!(
195 "the name `{name}` does not resolve — nothing in this program registered it"
196 ),
197 fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
198 Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
199 it that the program using your dialect calls once. A crate nothing links is a \
200 crate the registry cannot see."
201 .to_owned(),
202 });
203 return;
204 }
205 for spelling in info.spellings() {
206 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
207 Ok(found) if found.info().name == name => {}
208 Ok(found) => findings.push(Finding {
209 severity: Severity::Blocking,
210 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
211 fix: format!(
212 "the registry holds `{name}` under another key. Register it under its own \
213 name, or drop `{spelling}` from its aliases."
214 ),
215 }),
216 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
217 severity: Severity::Blocking,
218 what: format!(
219 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
220 none of them — Razeri refuses that spelling rather than guessing which one \
221 a design meant",
222 claimed_by.join(" and ")
223 ),
224 fix: format!(
225 "give this dialect a name no other platform answers to. A platform name is \
226 read off the loaded dialects' own declared spellings, so a spelling another \
227 platform already claims is never `{name}`'s."
228 ),
229 }),
230 Err(_) => findings.push(Finding {
231 severity: Severity::Blocking,
232 what: format!(
233 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
234 ),
235 fix: format!(
236 "the `{name}` in the registry is a different build of this dialect and does \
237 not declare `{spelling}`. Register the one you are grading."
238 ),
239 }),
240 }
241 }
242}
243
244fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
247 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
248 findings.push(Finding {
249 severity: Severity::Blocking,
250 what: "this dialect's family is a custom one with no name".to_owned(),
251 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
252 prints and what a reader looks up."
253 .to_owned(),
254 });
255 }
256}
257
258fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
260 let declarable = declarable_types();
261 let empty_column: Vec<String> = declarable
262 .iter()
263 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
264 .map(|dt| format!("{dt:?}"))
265 .collect();
266 if !empty_column.is_empty() {
267 findings.push(Finding {
268 severity: Severity::Blocking,
269 what: format!(
270 "`render_data_type` answers nothing for {} of the types a design can declare",
271 empty_column.len()
272 ),
273 fix: "every declarable type needs a column spelling on this engine, even if the \
274 spelling is a wider type that stores it. A blank answer becomes a column \
275 declaration with no type in it."
276 .to_owned(),
277 });
278 }
279 let empty_cast: Vec<String> = declarable
280 .iter()
281 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
282 .map(|dt| format!("{dt:?}"))
283 .collect();
284 if !empty_cast.is_empty() {
285 findings.push(Finding {
286 severity: Severity::Blocking,
287 what: format!(
288 "`render_cast_type` answers nothing for {} declarable types",
289 empty_cast.len()
290 ),
291 fix: "the generated load opens by casting every source column to its declared type, \
292 so a blank answer fails the load on its first statement."
293 .to_owned(),
294 });
295 }
296 let quoted = SqlRenderer::quote_identifier(dialect, "order");
297 if quoted == "order" {
298 findings.push(Finding {
299 severity: Severity::Blocking,
300 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
301 reaches this engine bare"
302 .to_owned(),
303 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
304 backticks, or brackets."
305 .to_owned(),
306 });
307 }
308 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
309 findings.push(Finding {
310 severity: Severity::Blocking,
311 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
312 a row with it"
313 .to_owned(),
314 fix: "return this engine's own expression for the current moment.".to_owned(),
315 });
316 }
317}
318
319fn check_encoded_file_relation(
331 dialect: &dyn PlatformDialect,
332 probe: &str,
333 findings: &mut Vec<Finding>,
334) {
335 let Some(relation) =
336 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
337 else {
338 return;
339 };
340 if !relation.contains(probe) {
341 findings.push(Finding {
342 severity: Severity::Blocking,
343 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
344 the path it was given"
345 .to_owned(),
346 fix: "put the literal you were handed where your engine's file relation takes a \
347 path. It arrives already quoted and escaped."
348 .to_owned(),
349 });
350 }
351 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
352 findings.push(Finding {
353 severity: Severity::Blocking,
354 what: "this dialect states how to read a file in a named encoding and states no way \
355 to read a file at all"
356 .to_owned(),
357 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
358 first question before the second — a source whose bytes are UTF-8 never \
359 reaches the encoding arm — so an engine that answers only the second has \
360 written a relation nothing will ask it for."
361 .to_owned(),
362 });
363 }
364}
365
366fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
368 let spelling = SqlRenderer::sid_spelling(dialect);
369 if spelling.platform == UNKNOWN_PLATFORM {
370 findings.push(Finding {
371 severity: Severity::Blocking,
372 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
373 an `xxhash64` its engine very likely does not have"
374 .to_owned(),
375 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
376 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
377 hash. If it has no portable hash, name an artifact your prerequisites install \
378 — never the nearest-looking built-in, which keys a warehouse to a number \
379 nothing else can reproduce."
380 .to_owned(),
381 });
382 }
383
384 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
389 && supplied.body.trim().is_empty()
390 {
391 findings.push(Finding {
392 severity: Severity::Blocking,
393 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
394 and then supplies an empty one"
395 .to_owned(),
396 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
397 one argument, or answer `None` and let Razeri compose the derivation from \
398 your `sid_spelling`. An empty body deploys without error and keys every row \
399 to nothing."
400 .to_owned(),
401 });
402 }
403
404 let probe = "'/razeri/probe.csv'";
407 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
408 && !relation.contains(probe)
409 {
410 findings.push(Finding {
411 severity: Severity::Blocking,
412 what: "`delimited_file_relation` returns a relation that does not carry the path it \
413 was given"
414 .to_owned(),
415 fix: "put the literal you were handed where your engine's file relation takes a \
416 path. It arrives already quoted and escaped. A relation that drops it reads \
417 one file for every entity the design binds, and the generated fill loads the \
418 wrong rows without failing."
419 .to_owned(),
420 });
421 }
422
423 check_encoded_file_relation(dialect, probe, findings);
424 check_routine_forms(dialect, findings);
425 check_slice_replacement(dialect, findings);
426
427 if let ConstraintEnforcement::DeclaredOnly(declared) =
430 DdlRenderer::constraint_enforcement(dialect)
431 && declared.note.split_whitespace().count() < 5
432 {
433 findings.push(Finding {
434 severity: Severity::Blocking,
435 what: "this dialect says its engine accepts constraints it does not enforce, and \
436 the note explaining that says almost nothing"
437 .to_owned(),
438 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
439 guarantee. Say what this engine really does with the declaration, and where \
440 the guarantee has to come from instead."
441 .to_owned(),
442 });
443 }
444
445 match DialectCapabilities::merge_shape(dialect) {
451 MergeShape::None => findings.push(Finding {
452 severity: Severity::Unanswered,
453 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
454 load for it"
455 .to_owned(),
456 fix: "if this engine has a MERGE or an upsert statement, say which in \
457 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
458 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
459 operator can act on, rather than a silence."
460 .to_owned(),
461 }),
462 MergeShape::Unsupported(orientation) => {
463 check_refusal_copy("the upsert refusal", orientation, findings);
464 }
465 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
466 _ => {}
467 }
468}
469
470fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
485 const VALUE: &str = "$razeri_slice_probe";
486 let plan = StoragePlan {
487 partition: PartitionStrategy::RangeByTimeDimension {
488 field: "razeri_probe_day".to_owned(),
489 granularity: PartitionGranularity::Day,
490 retention: None,
491 look_ahead: None,
492 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
493 },
494 compression: CompressionStrategy::None,
495 index: IndexStrategy::None,
496 };
497 let day = DataType::date();
498 let columns = ["razeri_probe_day"];
499 let entity = EntityRef {
500 schema: Some("razeri_probe"),
501 name: "razeri_probe_table",
502 is_identity_table: false,
503 columns: &columns,
504 partition_column_type: Some(&day),
505 };
506 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
507 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
508 else {
509 return;
510 };
511
512 let carries = match &answer {
513 SliceReplacement::Emptied(statements) => {
514 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
515 }
516 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
517 };
518 if carries {
524 return;
525 }
526 findings.push(Finding {
527 severity: Severity::Blocking,
528 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
529 was handed"
530 .to_owned(),
531 fix: "put the value you were given where your statement names the slice, and build any \
532 partition NAME from it the same way your extend procedure built it. A replacement \
533 that drops the value clears a slice this call was not given — or every slice — \
534 and the load reports success over what it emptied."
535 .to_owned(),
536 });
537}
538
539fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
556 let answers = DialectCapabilities::evolution_answers(dialect);
557 let boolean = DataType::boolean();
558 let long = DataType::varchar(40);
559
560 if DdlRenderer::render_column_evolution(
561 dialect,
562 &ColumnEvolution::AddColumn {
563 schema: Some("s"),
564 table: "t",
565 column: "c",
566 data_type: &boolean,
567 },
568 )
569 .is_none()
570 {
571 findings.push(Finding {
572 severity: Severity::Blocking,
573 what: "this dialect writes no statement for a column that is new, which is the commonest change a deployed warehouse ever sees"
574 .to_owned(),
575 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the override and take the contract's `ALTER TABLE … ADD COLUMN …`, which is this spelling on every engine Razeri reaches. An engine that genuinely has no way to add a column is worth saying out loud in the crate's own documentation."
576 .to_owned(),
577 });
578 }
579
580 if answers.parameterized_string_widening_is_lossless
581 && DdlRenderer::render_column_evolution(
582 dialect,
583 &ColumnEvolution::WidenColumn {
584 schema: Some("s"),
585 table: "t",
586 column: "c",
587 data_type: &long,
588 },
589 )
590 .is_none()
591 {
592 findings.push(Finding {
593 severity: Severity::Note,
594 what: "this dialect says its engine widens a string's declared length in place, and writes no statement for the change"
595 .to_owned(),
596 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this engine's spelling of it. Without it Razeri tells an operator the widening costs nothing and then hands them nothing to run, which reads as Razeri not having finished rather than as a statement they own."
597 .to_owned(),
598 });
599 }
600
601 if answers.nested_field_addition_is_lossless
602 && DdlRenderer::render_column_evolution(
603 dialect,
604 &ColumnEvolution::AddNestedField {
605 schema: Some("s"),
606 table: "t",
607 column: "c",
608 field: "f",
609 data_type: &long,
610 },
611 )
612 .is_none()
613 {
614 findings.push(Finding {
615 severity: Severity::Note,
616 what: "this dialect says its engine keeps every value when a record gains a field, and writes no statement for the change"
617 .to_owned(),
618 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some engines name the new field; some restate the whole column's type, which is why the change carries both the field's path and the column's type with the field in it."
619 .to_owned(),
620 });
621 }
622}
623
624fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
635 if restrictions.slice_pruning_needs_literal {
636 findings.push(Finding {
637 severity: Severity::Note,
638 what: "this dialect says its planner reads one slice of a partitioned table only \
639 from a literal, so a load scoped to one day cannot simply bind the day"
640 .to_owned(),
641 fix: "Razeri will put the day into the statement text through this engine's \
642 dynamic-execution form rather than binding it to the load procedure's \
643 parameter. Confirm that is what your engine wants: the statement is composed \
644 once per call and its text differs per day, which some planners cache and \
645 some do not. If a bound parameter does prune here, drop the restriction and \
646 Razeri binds it like everywhere else."
647 .to_owned(),
648 });
649 }
650}
651
652fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
655 let formula = SqlRenderer::to_formula_renderer(dialect);
656 let dialect_name = SqlRenderer::dialect_name(dialect);
657 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
658 findings.push(Finding {
659 severity: Severity::Blocking,
660 what: "`to_formula_renderer` was not overridden, so every formula in every design \
661 renders through Razeri's ANSI defaults instead of this dialect"
662 .to_owned(),
663 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
664 formula this engine sees is silently somebody else's."
665 .to_owned(),
666 });
667 }
668}
669
670fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
676 match SqlRenderer::procedure_form(dialect) {
677 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
681 ProcedureForm::Agnostic => findings.push(Finding {
682 severity: Severity::Unanswered,
683 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
684 annotated form — which no engine parses"
685 .to_owned(),
686 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
687 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
688 offers instead."
689 .to_owned(),
690 }),
691 ProcedureForm::Unsupported(orientation) => {
692 check_refusal_copy("the stored-procedure refusal", orientation, findings);
693 }
694 ProcedureForm::Custom(grammar) => check_custom_grammar(
695 CustomGrammar {
696 kind: RoutineKind::Procedure,
697 grammar,
698 form_method: "procedure_form",
699 render_method: "render_procedure",
700 noun: "procedures",
701 },
702 |spec| SqlRenderer::render_procedure(dialect, spec),
703 findings,
704 ),
705 _ => {}
706 }
707
708 match SqlRenderer::scalar_function_form(dialect) {
709 ScalarFunctionForm::Unsupported(orientation) => {
710 check_refusal_copy("the scalar-function refusal", orientation, findings);
711 }
712 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
713 CustomGrammar {
714 kind: RoutineKind::ScalarFunction,
715 grammar,
716 form_method: "scalar_function_form",
717 render_method: "render_scalar_function",
718 noun: "scalar functions",
719 },
720 |spec| SqlRenderer::render_scalar_function(dialect, spec),
721 findings,
722 ),
723 _ => {}
724 }
725
726 match SqlRenderer::table_function_form(dialect) {
727 TableFunctionForm::Unsupported(orientation) => {
728 check_refusal_copy("the table-function refusal", orientation, findings);
729 }
730 TableFunctionForm::Custom(grammar) => check_custom_grammar(
731 CustomGrammar {
732 kind: RoutineKind::TableFunction,
733 grammar,
734 form_method: "table_function_form",
735 render_method: "render_table_function",
736 noun: "table functions",
737 },
738 |spec| SqlRenderer::render_table_function(dialect, spec),
739 findings,
740 ),
741 _ => {}
742 }
743
744 check_parameter_type_spelling(dialect, findings);
745}
746
747fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
762 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
763 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
764 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
765 if !spells_its_own {
766 return;
767 }
768 let qualified = DataType::varchar(100);
771 let column = SqlRenderer::render_data_type(dialect, &qualified);
772 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
773 if column == parameter && column.contains('(') {
774 findings.push(Finding {
775 severity: Severity::Note,
776 what: format!(
777 "a parameter of this routine's would be declared `{parameter}`, the same as a column of that type"
778 ),
779 fix: "that is right for most engines, so this is only worth a look. If yours refuses a length or a precision in a parameter list — Oracle does, and answers PLS-00103 at the opening parenthesis — override `SqlRenderer::render_parameter_data_type` with the bare spelling. Local declarations keep the length either way."
780 .to_owned(),
781 });
782 }
783}
784
785#[derive(Clone, Copy)]
787struct CustomGrammar {
788 kind: RoutineKind,
790 grammar: &'static str,
792 form_method: &'static str,
794 render_method: &'static str,
796 noun: &'static str,
798}
799
800fn check_custom_grammar(
817 claim: CustomGrammar,
818 render: impl Fn(&RoutineSpec) -> Option<String>,
819 findings: &mut Vec<Finding>,
820) {
821 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
822 if grammar.trim().is_empty() {
825 findings.push(Finding {
826 severity: Severity::Blocking,
827 what: format!("`{form_method}` says Custom, and names no grammar"),
828 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
829 of a report, and of the comment Razeri writes where a routine could not be \
830 rendered, is told this engine speaks."
831 .to_owned(),
832 });
833 }
834
835 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
836 probe.or_replace = true;
837 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
838 findings.push(Finding {
839 severity: Severity::Blocking,
840 what: format!(
841 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
842 itself — and `{render_method}` returns nothing"
843 ),
844 fix: format!(
845 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
846 composed — name, parameters with their types already in your spelling, body, \
847 comment — and returns the whole CREATE statement your engine parses. Razeri \
848 emits what you return and writes nothing of its own: a near-neighbour grammar \
849 fails on deploy, and a refusal would say this engine has no {noun} when it \
850 has them in a grammar of its own."
851 ),
852 });
853 }
854}
855
856fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
858 if copy.trim().is_empty() {
859 findings.push(Finding {
860 severity: Severity::Blocking,
861 what: format!("{subject} has no words in it"),
862 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
863 next thing to try, and it has to be about their engine."
864 .to_owned(),
865 });
866 return;
867 }
868 if copy.split_whitespace().count() < 5 {
869 findings.push(Finding {
870 severity: Severity::Note,
871 what: format!("{subject} is very short"),
872 fix: "a refusal earns its place by naming the alternative — what this engine has \
873 instead of the thing it cannot do."
874 .to_owned(),
875 });
876 }
877}
878
879fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
881 let caps: &dyn DialectCapabilities = dialect;
882 let ren: &dyn SqlRenderer = dialect;
883 let ddl: &dyn DdlRenderer = dialect;
884 let mut disagreements: Vec<&'static str> = Vec::new();
885
886 macro_rules! pair {
887 ($m:ident, $renderer_value:expr) => {
888 if caps.$m() != $renderer_value {
889 disagreements.push(stringify!($m));
890 }
891 };
892 }
893 pair!(supports_on_conflict, ren.supports_on_conflict());
894 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
895 pair!(supports_update_from, ren.supports_update_from());
896 pair!(supports_named_windows, ren.supports_named_windows());
897 pair!(supports_compound_assignment, ren.supports_compound_assignment());
898 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
899 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
900 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
901 pair!(uses_alter_table_update, ren.uses_alter_table_update());
902 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
903 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
904 pair!(supports_sync_drop, ddl.supports_sync_drop());
905 pair!(supports_force_drop, ddl.supports_force_drop());
906
907 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
916 disagreements.push("supports_table_functions");
917 }
918
919 if !disagreements.is_empty() {
920 findings.push(Finding {
921 severity: Severity::Blocking,
922 what: format!(
923 "this dialect declares {} fact(s) two ways and the two disagree: {}",
924 disagreements.len(),
925 disagreements.join(", ")
926 ),
927 fix: "each of these is declared once on the capability trait and once in the \
928 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
929 and move the other one to match."
930 .to_owned(),
931 });
932 }
933}
934
935fn declarable_types() -> Vec<DataType> {
943 vec![
944 DataType::tinyint(),
945 DataType::smallint(),
946 DataType::int(),
947 DataType::bigint(),
948 DataType::double(),
949 DataType::boolean(),
950 DataType::text(),
951 DataType::varchar(200),
952 DataType::uuid(),
953 DataType::json(),
954 DataType::date(),
955 DataType::timestamp(),
956 DataType::timestamp_tz(),
957 DataType::decimal(18, 2),
958 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
959 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
960 DataType::Struct(StructDataType::new(vec![
961 StructField::new("street", DataType::varchar(200)),
962 StructField::new("zip", DataType::varchar(10)),
963 ])),
964 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
965 StructField::new("key", DataType::varchar(100)),
966 StructField::new("value", DataType::varchar(1000)),
967 ])))),
968 ]
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974 use crate::dialect::DialectInfo;
975 use razeri_plugin_contract::{
976 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
977 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
978 TaskRenderer, TextCast,
979 };
980 use std::marker::PhantomData;
981
982 struct BrokenPlugin;
991
992 impl SqlRenderer for BrokenPlugin {
993 fn render_data_type(&self, _dt: &DataType) -> String {
994 String::new()
995 }
996 fn quote_identifier(&self, name: &str) -> String {
997 name.to_owned()
998 }
999 fn render_cast_type(&self, _dt: &DataType) -> String {
1000 String::new()
1001 }
1002 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1003 name.to_owned()
1004 }
1005 fn render_current_timestamp(&self) -> String {
1006 String::new()
1007 }
1008 fn dialect_name(&self) -> &'static str {
1009 "brokendb"
1010 }
1011 fn supports_on_conflict(&self) -> bool {
1014 true
1015 }
1016 }
1017 impl ExpressionRenderer for BrokenPlugin {}
1018 impl DdlRenderer for BrokenPlugin {}
1019 impl DmlRenderer for BrokenPlugin {}
1020 impl ProcedureRenderer for BrokenPlugin {}
1021 impl FormulaRenderer for BrokenPlugin {}
1022 impl TaskRenderer for BrokenPlugin {}
1023 impl ReplicationRenderer for BrokenPlugin {}
1024 impl StorageRenderer for BrokenPlugin {}
1025 impl DialectCapabilities for BrokenPlugin {
1026 fn supports_on_conflict(&self) -> bool {
1027 false
1028 }
1029 }
1030 impl PlatformDialect for BrokenPlugin {
1031 fn info(&self) -> DialectInfo {
1032 DialectInfo {
1033 name: "brokendb",
1034 display_name: "Broken DB",
1035 aliases: &[],
1036 dialect_family: DialectFamily::Custom(""),
1037 family: "Broken",
1038 is_cloud_native: false,
1039 is_olap: false,
1040 is_postgres_compatible: false,
1041 versions: &[],
1042 deploy_script: None,
1043 }
1044 }
1045 }
1046
1047 trait FixtureName: Send + Sync + 'static {
1056 const NAME: &'static str;
1058 const CONCAT: Concat = Concat::Function("CONCAT");
1062 }
1063
1064 struct Unlinked;
1067 impl FixtureName for Unlinked {
1068 const NAME: &'static str = "healthydb";
1069 }
1070
1071 struct Outside;
1074 impl FixtureName for Outside {
1075 const NAME: &'static str = "outsidedb";
1076 }
1077
1078 struct BinaryConcat;
1083 impl FixtureName for BinaryConcat {
1084 const NAME: &'static str = "binaryconcatdb";
1085 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1086 }
1087
1088 struct Answered<N: FixtureName>(PhantomData<N>);
1089
1090 impl<N: FixtureName> Answered<N> {
1091 const fn new() -> Self {
1092 Self(PhantomData)
1093 }
1094 }
1095
1096 type HealthyPlugin = Answered<Unlinked>;
1097 type OutsidePlugin = Answered<Outside>;
1098
1099 inventory::submit! {
1100 crate::builder::DialectRegistration::new(
1101 Outside::NAME,
1102 || std::sync::Arc::new(OutsidePlugin::new()),
1103 )
1104 }
1105
1106 impl<N: FixtureName> SqlRenderer for Answered<N> {
1107 fn render_data_type(&self, dt: &DataType) -> String {
1108 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1109 }
1110 fn quote_identifier(&self, name: &str) -> String {
1111 format!("\"{name}\"")
1112 }
1113 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1114 schema.map_or_else(
1115 || self.quote_identifier(name),
1116 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1117 )
1118 }
1119 fn render_current_timestamp(&self) -> String {
1120 "NOW()".to_owned()
1121 }
1122 fn dialect_name(&self) -> &'static str {
1123 N::NAME
1124 }
1125 fn sid_spelling(&self) -> SidSpelling {
1126 SidSpelling {
1127 platform: N::NAME,
1128 hash: ("answered_xxhash64(", ")"),
1129 cast_type: TextCast::Direct("STRING"),
1130 concat: N::CONCAT,
1131 through_helper: false,
1132 ..razeri_plugin_contract::ANSI_SID_SPELLING
1133 }
1134 }
1135 fn procedure_form(&self) -> ProcedureForm {
1136 ProcedureForm::Unsupported(
1137 "This engine has no stored procedures; write the load as a scheduled query \
1138 instead.",
1139 )
1140 }
1141 fn scalar_function_form(&self) -> ScalarFunctionForm {
1142 ScalarFunctionForm::PostgreSql
1143 }
1144 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1145 Box::new(Self::new())
1146 }
1147 }
1148 impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1149 impl<N: FixtureName> DdlRenderer for Answered<N> {}
1150 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1151 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1152 impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1153 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1154 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1155 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1156 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1157 fn merge_shape(&self) -> MergeShape {
1158 MergeShape::Merge(MergeRestrictions::none())
1159 }
1160 }
1161 impl<N: FixtureName> PlatformDialect for Answered<N> {
1162 fn info(&self) -> DialectInfo {
1163 DialectInfo {
1164 name: N::NAME,
1165 display_name: "An Answered Engine",
1166 aliases: &[],
1169 dialect_family: DialectFamily::Custom(N::NAME),
1170 family: "Answered",
1171 is_cloud_native: false,
1172 is_olap: false,
1173 is_postgres_compatible: false,
1174 versions: &[],
1175 deploy_script: None,
1176 }
1177 }
1178 }
1179
1180 struct Twin;
1186
1187 impl SqlRenderer for Twin {
1188 fn render_data_type(&self, dt: &DataType) -> String {
1189 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1190 }
1191 fn quote_identifier(&self, name: &str) -> String {
1192 format!("\"{name}\"")
1193 }
1194 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1195 self.quote_identifier(name)
1196 }
1197 fn render_current_timestamp(&self) -> String {
1198 "NOW()".to_owned()
1199 }
1200 }
1201 impl ExpressionRenderer for Twin {}
1202 impl DdlRenderer for Twin {}
1203 impl DmlRenderer for Twin {}
1204 impl ProcedureRenderer for Twin {}
1205 impl FormulaRenderer for Twin {}
1206 impl TaskRenderer for Twin {}
1207 impl ReplicationRenderer for Twin {}
1208 impl StorageRenderer for Twin {}
1209 impl DialectCapabilities for Twin {}
1210 impl PlatformDialect for Twin {
1211 fn info(&self) -> DialectInfo {
1212 DialectInfo {
1213 name: "twindb",
1214 display_name: "Twin DB",
1215 aliases: &["healthydb"],
1217 dialect_family: DialectFamily::Custom("twindb"),
1218 family: "Twin",
1219 is_cloud_native: false,
1220 is_olap: false,
1221 is_postgres_compatible: false,
1222 versions: &[],
1223 deploy_script: None,
1224 }
1225 }
1226 }
1227
1228 fn mentions(report: &Report, needle: &str) -> bool {
1230 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1231 }
1232
1233 #[test]
1237 fn the_kit_catches_a_deliberately_broken_plugin() {
1238 let report = check(&BrokenPlugin);
1239 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1240
1241 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1242 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1243 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1244 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1245 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1246 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1247 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1248 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1249 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1250 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1251 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1252
1253 assert!(
1254 report.count(Severity::Blocking) >= 8,
1255 "the broken plugin owes more than {} blocking fixes:\n{report}",
1256 report.count(Severity::Blocking)
1257 );
1258 }
1259
1260 #[test]
1264 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1265 let report = check(&HealthyPlugin::new());
1266 let unregistered: Vec<&Finding> =
1267 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1268 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1269 assert_eq!(
1270 report.findings.len(),
1271 1,
1272 "a plugin that answered every question owes nothing else:\n{report}"
1273 );
1274 }
1275
1276 #[test]
1281 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1282 let binary = Answered::<BinaryConcat>::new();
1283 let report = check(&binary);
1284 assert_eq!(
1285 report.findings.len(),
1286 check(&HealthyPlugin::new()).findings.len(),
1287 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1288 );
1289 assert!(
1290 !report.to_string().contains("joins"),
1291 "nothing about the join is outstanding:\n{report}"
1292 );
1293
1294 let key = razeri_plugin_contract::sid::sid_expression(
1295 SqlRenderer::sid_spelling(&binary),
1296 None,
1297 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1298 );
1299 assert!(
1300 key.contains("CONCAT(CONCAT("),
1301 "a two-argument engine gets one call per pair:\n{key}"
1302 );
1303 }
1304
1305 #[test]
1314 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1315 let mut quiet = Vec::new();
1316 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1317 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1318
1319 let mut stated = Vec::new();
1320 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1321 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1322 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1323 assert!(
1324 stated[0].what.contains("literal"),
1325 "the finding names the restriction:\n{}",
1326 stated[0].what
1327 );
1328 assert!(
1329 stated[0].fix.contains("dynamic-execution"),
1330 "and says what Razeri does with the answer:\n{}",
1331 stated[0].fix
1332 );
1333
1334 let mut two_clauses = Vec::new();
1337 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1338 assert!(two_clauses.is_empty(), "one restriction is not the other");
1339 }
1340
1341 #[test]
1347 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1348 assert!(
1349 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1350 "the fixture registers through the registry, which is the whole path being graded"
1351 );
1352 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1356 assert!(
1357 registry
1358 .list()
1359 .iter()
1360 .filter(|info| info.name != Outside::NAME)
1361 .all(|info| !info.answers_to(Outside::NAME)),
1362 "the fixture's name must be one no other platform claims"
1363 );
1364
1365 let report = check(&OutsidePlugin::new());
1366 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1367 assert!(report.is_clean(), "and the report says so:\n{report}");
1368 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1369 assert!(
1370 report.to_string().contains("nothing to fix"),
1371 "a clean dialect is told it is clean:\n{report}"
1372 );
1373 }
1374
1375 #[test]
1382 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1383 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1384 for info in registry.list() {
1385 assert_eq!(
1386 info.spellings().first().copied(),
1387 Some(info.name),
1388 "a platform's own name leads its spellings"
1389 );
1390 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1391 }
1392 }
1393
1394 #[test]
1400 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1401 let mut registry = crate::dialect::PlatformRegistry::new();
1402 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1403 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1404
1405 let report = check_with_registry(&Twin, ®istry);
1406 let collision = report
1407 .findings
1408 .iter()
1409 .find(|f| f.what.contains("claimed by more than one loaded platform"))
1410 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1411 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1412 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1413 assert!(collision.what.contains("twindb"), "{collision:?}");
1414 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1415 assert!(
1416 !mentions(&report, "does not resolve"),
1417 "a collision is not an unregistered platform:\n{report}"
1418 );
1419 }
1420
1421 #[test]
1425 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1426 let mut registry = crate::dialect::PlatformRegistry::new();
1427 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1428
1429 let mut findings = Vec::new();
1430 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
1431 assert!(findings.is_empty(), "{findings:?}");
1432
1433 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
1434 assert!(
1435 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
1436 "'{spelling}' is the same platform"
1437 );
1438 }
1439 assert!(
1440 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
1441 );
1442 }
1443
1444 #[test]
1449 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1450 let mut registry = crate::dialect::PlatformRegistry::new();
1451 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1452
1453 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
1454 assert!(
1455 report.is_clean(),
1456 "a loaded dialect that answered everything owes nothing:\n{report}"
1457 );
1458 }
1459
1460 #[test]
1464 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1465 let mut collided = crate::dialect::PlatformRegistry::new();
1466 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1467 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1468
1469 let mut fixes: Vec<String> = Vec::new();
1470 for report in [
1471 check(&BrokenPlugin),
1472 check(&HealthyPlugin::new()),
1473 check(&OutsidePlugin::new()),
1474 check_with_registry(&Twin, &collided),
1477 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1478 ] {
1479 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1480 }
1481 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1482 for fix in &fixes {
1483 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1484 assert!(
1485 !fix.contains(path),
1486 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
1487 );
1488 }
1489 }
1490 }
1491
1492 #[test]
1502 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1503 for (kind, form_method, render_method) in [
1504 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1505 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1506 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1507 ] {
1508 let mut promised = Vec::new();
1509 check_custom_grammar(
1510 CustomGrammar {
1511 kind,
1512 grammar: "PL/SQL",
1513 form_method,
1514 render_method,
1515 noun: "routines",
1516 },
1517 |_| None,
1518 &mut promised,
1519 );
1520 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1521 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1522 assert!(promised[0].what.contains(form_method), "{promised:?}");
1523 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1524 assert!(promised[0].fix.contains(render_method), "{promised:?}");
1525
1526 let mut kept = Vec::new();
1528 check_custom_grammar(
1529 CustomGrammar {
1530 kind,
1531 grammar: "PL/SQL",
1532 form_method,
1533 render_method,
1534 noun: "routines",
1535 },
1536 |spec| Some(format!("CREATE {} …", spec.name)),
1537 &mut kept,
1538 );
1539 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1540 }
1541 }
1542
1543 #[test]
1549 fn a_custom_render_method_that_returns_nothing_is_blocking() {
1550 let mut findings = Vec::new();
1551 check_custom_grammar(
1552 CustomGrammar {
1553 kind: RoutineKind::Procedure,
1554 grammar: "PL/SQL",
1555 form_method: "procedure_form",
1556 render_method: "render_procedure",
1557 noun: "procedures",
1558 },
1559 |_| Some(" \n".to_owned()),
1560 &mut findings,
1561 );
1562 assert_eq!(findings.len(), 1, "{findings:?}");
1563 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1564 }
1565
1566 #[test]
1568 fn a_custom_grammar_with_no_name_is_blocking() {
1569 let mut findings = Vec::new();
1570 check_custom_grammar(
1571 CustomGrammar {
1572 kind: RoutineKind::ScalarFunction,
1573 grammar: " ",
1574 form_method: "scalar_function_form",
1575 render_method: "render_scalar_function",
1576 noun: "scalar functions",
1577 },
1578 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1579 &mut findings,
1580 );
1581 assert_eq!(findings.len(), 1, "{findings:?}");
1582 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1583 }
1584
1585 #[test]
1593 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1594 struct Overstated;
1595 impl SqlRenderer for Overstated {
1596 fn render_data_type(&self, dt: &DataType) -> String {
1597 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1598 }
1599 fn quote_identifier(&self, name: &str) -> String {
1600 format!("\"{name}\"")
1601 }
1602 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1603 name.to_owned()
1604 }
1605 fn render_current_timestamp(&self) -> String {
1606 "CURRENT_TIMESTAMP".to_owned()
1607 }
1608 fn dialect_name(&self) -> &'static str {
1609 "overstateddb"
1610 }
1611 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1612 Some(razeri_plugin_contract::SidHelperBody {
1613 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1614 body: " \n ",
1615 })
1616 }
1617 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1618 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1619 }
1620 }
1621 impl ExpressionRenderer for Overstated {}
1622 impl DdlRenderer for Overstated {}
1623 impl DmlRenderer for Overstated {}
1624 impl ProcedureRenderer for Overstated {}
1625 impl FormulaRenderer for Overstated {}
1626 impl TaskRenderer for Overstated {}
1627 impl ReplicationRenderer for Overstated {}
1628 impl StorageRenderer for Overstated {}
1629 impl DialectCapabilities for Overstated {}
1630 impl PlatformDialect for Overstated {
1631 fn info(&self) -> DialectInfo {
1632 DialectInfo {
1633 name: "overstateddb",
1634 display_name: "Overstated DB",
1635 aliases: &[],
1636 dialect_family: DialectFamily::Custom(""),
1637 family: "Overstated",
1638 is_cloud_native: false,
1639 is_olap: false,
1640 is_postgres_compatible: false,
1641 versions: &[],
1642 deploy_script: None,
1643 }
1644 }
1645 }
1646
1647 let report = check(&Overstated);
1648 for (fragment, why) in [
1649 ("supplies an empty one", "a blank helper body"),
1650 ("does not carry the path", "a file relation that drops its path"),
1651 ] {
1652 let found = report
1653 .findings
1654 .iter()
1655 .find(|f| f.what.contains(fragment))
1656 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1657 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1658 }
1659
1660 let quiet = check(&HealthyPlugin::new());
1663 for fragment in ["supplies an empty one", "does not carry the path"] {
1664 assert!(
1665 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1666 "silence is the default and owes nothing:\n{quiet}"
1667 );
1668 }
1669 }
1670
1671 #[test]
1675 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1676 let mut mute = Vec::new();
1677 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1678 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1679 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1680
1681 let silence = check(&BrokenPlugin);
1682 assert!(
1683 silence
1684 .findings
1685 .iter()
1686 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1687 "a dialect that never answered is unanswered, not wrong:\n{silence}"
1688 );
1689 assert!(
1690 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1691 "the fix line names the way to state a measured refusal:\n{silence}"
1692 );
1693 }
1694
1695 #[test]
1699 fn the_report_reads_as_a_summary_then_a_fix_list() {
1700 let text = check(&BrokenPlugin).to_string();
1701 let first = text.lines().next().expect("a summary line");
1702 assert!(first.starts_with("brokendb:"), "{first}");
1703 assert!(first.contains("to fix"), "{first}");
1704 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
1705 }
1706}