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_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
321 let spelling = SqlRenderer::sid_spelling(dialect);
322 if spelling.platform == UNKNOWN_PLATFORM {
323 findings.push(Finding {
324 severity: Severity::Blocking,
325 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
326 an `xxhash64` its engine very likely does not have"
327 .to_owned(),
328 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
329 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
330 hash. If it has no portable hash, name an artifact your prerequisites install \
331 — never the nearest-looking built-in, which keys a warehouse to a number \
332 nothing else can reproduce."
333 .to_owned(),
334 });
335 }
336
337 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
342 && supplied.body.trim().is_empty()
343 {
344 findings.push(Finding {
345 severity: Severity::Blocking,
346 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
347 and then supplies an empty one"
348 .to_owned(),
349 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
350 one argument, or answer `None` and let Razeri compose the derivation from \
351 your `sid_spelling`. An empty body deploys without error and keys every row \
352 to nothing."
353 .to_owned(),
354 });
355 }
356
357 let probe = "'/razeri/probe.csv'";
360 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
361 && !relation.contains(probe)
362 {
363 findings.push(Finding {
364 severity: Severity::Blocking,
365 what: "`delimited_file_relation` returns a relation that does not carry the path it \
366 was given"
367 .to_owned(),
368 fix: "put the literal you were handed where your engine's file relation takes a \
369 path. It arrives already quoted and escaped. A relation that drops it reads \
370 one file for every entity the design binds, and the generated fill loads the \
371 wrong rows without failing."
372 .to_owned(),
373 });
374 }
375
376 check_routine_forms(dialect, findings);
377 check_slice_replacement(dialect, findings);
378
379 if let ConstraintEnforcement::DeclaredOnly(declared) =
382 DdlRenderer::constraint_enforcement(dialect)
383 && declared.note.split_whitespace().count() < 5
384 {
385 findings.push(Finding {
386 severity: Severity::Blocking,
387 what: "this dialect says its engine accepts constraints it does not enforce, and \
388 the note explaining that says almost nothing"
389 .to_owned(),
390 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
391 guarantee. Say what this engine really does with the declaration, and where \
392 the guarantee has to come from instead."
393 .to_owned(),
394 });
395 }
396
397 match DialectCapabilities::merge_shape(dialect) {
403 MergeShape::None => findings.push(Finding {
404 severity: Severity::Unanswered,
405 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
406 load for it"
407 .to_owned(),
408 fix: "if this engine has a MERGE or an upsert statement, say which in \
409 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
410 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
411 operator can act on, rather than a silence."
412 .to_owned(),
413 }),
414 MergeShape::Unsupported(orientation) => {
415 check_refusal_copy("the upsert refusal", orientation, findings);
416 }
417 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
418 _ => {}
419 }
420}
421
422fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
437 const VALUE: &str = "$razeri_slice_probe";
438 let plan = StoragePlan {
439 partition: PartitionStrategy::RangeByTimeDimension {
440 field: "razeri_probe_day".to_owned(),
441 granularity: PartitionGranularity::Day,
442 retention: None,
443 look_ahead: None,
444 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
445 },
446 compression: CompressionStrategy::None,
447 index: IndexStrategy::None,
448 };
449 let day = DataType::date();
450 let columns = ["razeri_probe_day"];
451 let entity = EntityRef {
452 schema: Some("razeri_probe"),
453 name: "razeri_probe_table",
454 is_identity_table: false,
455 columns: &columns,
456 partition_column_type: Some(&day),
457 };
458 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
459 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
460 else {
461 return;
462 };
463
464 let carries = match &answer {
465 SliceReplacement::Emptied(statements) => {
466 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
467 }
468 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
469 };
470 if carries {
476 return;
477 }
478 findings.push(Finding {
479 severity: Severity::Blocking,
480 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
481 was handed"
482 .to_owned(),
483 fix: "put the value you were given where your statement names the slice, and build any \
484 partition NAME from it the same way your extend procedure built it. A replacement \
485 that drops the value clears a slice this call was not given — or every slice — \
486 and the load reports success over what it emptied."
487 .to_owned(),
488 });
489}
490
491fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
508 let answers = DialectCapabilities::evolution_answers(dialect);
509 let boolean = DataType::boolean();
510 let long = DataType::varchar(40);
511
512 if DdlRenderer::render_column_evolution(
513 dialect,
514 &ColumnEvolution::AddColumn {
515 schema: Some("s"),
516 table: "t",
517 column: "c",
518 data_type: &boolean,
519 },
520 )
521 .is_none()
522 {
523 findings.push(Finding {
524 severity: Severity::Blocking,
525 what: "this dialect writes no statement for a column that is new, which is the commonest change a deployed warehouse ever sees"
526 .to_owned(),
527 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."
528 .to_owned(),
529 });
530 }
531
532 if answers.parameterized_string_widening_is_lossless
533 && DdlRenderer::render_column_evolution(
534 dialect,
535 &ColumnEvolution::WidenColumn {
536 schema: Some("s"),
537 table: "t",
538 column: "c",
539 data_type: &long,
540 },
541 )
542 .is_none()
543 {
544 findings.push(Finding {
545 severity: Severity::Note,
546 what: "this dialect says its engine widens a string's declared length in place, and writes no statement for the change"
547 .to_owned(),
548 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."
549 .to_owned(),
550 });
551 }
552
553 if answers.nested_field_addition_is_lossless
554 && DdlRenderer::render_column_evolution(
555 dialect,
556 &ColumnEvolution::AddNestedField {
557 schema: Some("s"),
558 table: "t",
559 column: "c",
560 field: "f",
561 data_type: &long,
562 },
563 )
564 .is_none()
565 {
566 findings.push(Finding {
567 severity: Severity::Note,
568 what: "this dialect says its engine keeps every value when a record gains a field, and writes no statement for the change"
569 .to_owned(),
570 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."
571 .to_owned(),
572 });
573 }
574}
575
576fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
587 if restrictions.slice_pruning_needs_literal {
588 findings.push(Finding {
589 severity: Severity::Note,
590 what: "this dialect says its planner reads one slice of a partitioned table only \
591 from a literal, so a load scoped to one day cannot simply bind the day"
592 .to_owned(),
593 fix: "Razeri will put the day into the statement text through this engine's \
594 dynamic-execution form rather than binding it to the load procedure's \
595 parameter. Confirm that is what your engine wants: the statement is composed \
596 once per call and its text differs per day, which some planners cache and \
597 some do not. If a bound parameter does prune here, drop the restriction and \
598 Razeri binds it like everywhere else."
599 .to_owned(),
600 });
601 }
602}
603
604fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
607 let formula = SqlRenderer::to_formula_renderer(dialect);
608 let dialect_name = SqlRenderer::dialect_name(dialect);
609 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
610 findings.push(Finding {
611 severity: Severity::Blocking,
612 what: "`to_formula_renderer` was not overridden, so every formula in every design \
613 renders through Razeri's ANSI defaults instead of this dialect"
614 .to_owned(),
615 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
616 formula this engine sees is silently somebody else's."
617 .to_owned(),
618 });
619 }
620}
621
622fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
628 match SqlRenderer::procedure_form(dialect) {
629 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
633 ProcedureForm::Agnostic => findings.push(Finding {
634 severity: Severity::Unanswered,
635 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
636 annotated form — which no engine parses"
637 .to_owned(),
638 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
639 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
640 offers instead."
641 .to_owned(),
642 }),
643 ProcedureForm::Unsupported(orientation) => {
644 check_refusal_copy("the stored-procedure refusal", orientation, findings);
645 }
646 ProcedureForm::Custom(grammar) => check_custom_grammar(
647 CustomGrammar {
648 kind: RoutineKind::Procedure,
649 grammar,
650 form_method: "procedure_form",
651 render_method: "render_procedure",
652 noun: "procedures",
653 },
654 |spec| SqlRenderer::render_procedure(dialect, spec),
655 findings,
656 ),
657 _ => {}
658 }
659
660 match SqlRenderer::scalar_function_form(dialect) {
661 ScalarFunctionForm::Unsupported(orientation) => {
662 check_refusal_copy("the scalar-function refusal", orientation, findings);
663 }
664 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
665 CustomGrammar {
666 kind: RoutineKind::ScalarFunction,
667 grammar,
668 form_method: "scalar_function_form",
669 render_method: "render_scalar_function",
670 noun: "scalar functions",
671 },
672 |spec| SqlRenderer::render_scalar_function(dialect, spec),
673 findings,
674 ),
675 _ => {}
676 }
677
678 match SqlRenderer::table_function_form(dialect) {
679 TableFunctionForm::Unsupported(orientation) => {
680 check_refusal_copy("the table-function refusal", orientation, findings);
681 }
682 TableFunctionForm::Custom(grammar) => check_custom_grammar(
683 CustomGrammar {
684 kind: RoutineKind::TableFunction,
685 grammar,
686 form_method: "table_function_form",
687 render_method: "render_table_function",
688 noun: "table functions",
689 },
690 |spec| SqlRenderer::render_table_function(dialect, spec),
691 findings,
692 ),
693 _ => {}
694 }
695
696 check_parameter_type_spelling(dialect, findings);
697}
698
699fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
714 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
715 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
716 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
717 if !spells_its_own {
718 return;
719 }
720 let qualified = DataType::varchar(100);
723 let column = SqlRenderer::render_data_type(dialect, &qualified);
724 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
725 if column == parameter && column.contains('(') {
726 findings.push(Finding {
727 severity: Severity::Note,
728 what: format!(
729 "a parameter of this routine's would be declared `{parameter}`, the same as a column of that type"
730 ),
731 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."
732 .to_owned(),
733 });
734 }
735}
736
737#[derive(Clone, Copy)]
739struct CustomGrammar {
740 kind: RoutineKind,
742 grammar: &'static str,
744 form_method: &'static str,
746 render_method: &'static str,
748 noun: &'static str,
750}
751
752fn check_custom_grammar(
769 claim: CustomGrammar,
770 render: impl Fn(&RoutineSpec) -> Option<String>,
771 findings: &mut Vec<Finding>,
772) {
773 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
774 if grammar.trim().is_empty() {
777 findings.push(Finding {
778 severity: Severity::Blocking,
779 what: format!("`{form_method}` says Custom, and names no grammar"),
780 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
781 of a report, and of the comment Razeri writes where a routine could not be \
782 rendered, is told this engine speaks."
783 .to_owned(),
784 });
785 }
786
787 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
788 probe.or_replace = true;
789 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
790 findings.push(Finding {
791 severity: Severity::Blocking,
792 what: format!(
793 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
794 itself — and `{render_method}` returns nothing"
795 ),
796 fix: format!(
797 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
798 composed — name, parameters with their types already in your spelling, body, \
799 comment — and returns the whole CREATE statement your engine parses. Razeri \
800 emits what you return and writes nothing of its own: a near-neighbour grammar \
801 fails on deploy, and a refusal would say this engine has no {noun} when it \
802 has them in a grammar of its own."
803 ),
804 });
805 }
806}
807
808fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
810 if copy.trim().is_empty() {
811 findings.push(Finding {
812 severity: Severity::Blocking,
813 what: format!("{subject} has no words in it"),
814 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
815 next thing to try, and it has to be about their engine."
816 .to_owned(),
817 });
818 return;
819 }
820 if copy.split_whitespace().count() < 5 {
821 findings.push(Finding {
822 severity: Severity::Note,
823 what: format!("{subject} is very short"),
824 fix: "a refusal earns its place by naming the alternative — what this engine has \
825 instead of the thing it cannot do."
826 .to_owned(),
827 });
828 }
829}
830
831fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
833 let caps: &dyn DialectCapabilities = dialect;
834 let ren: &dyn SqlRenderer = dialect;
835 let ddl: &dyn DdlRenderer = dialect;
836 let mut disagreements: Vec<&'static str> = Vec::new();
837
838 macro_rules! pair {
839 ($m:ident, $renderer_value:expr) => {
840 if caps.$m() != $renderer_value {
841 disagreements.push(stringify!($m));
842 }
843 };
844 }
845 pair!(supports_on_conflict, ren.supports_on_conflict());
846 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
847 pair!(supports_update_from, ren.supports_update_from());
848 pair!(supports_named_windows, ren.supports_named_windows());
849 pair!(supports_compound_assignment, ren.supports_compound_assignment());
850 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
851 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
852 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
853 pair!(uses_alter_table_update, ren.uses_alter_table_update());
854 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
855 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
856 pair!(supports_sync_drop, ddl.supports_sync_drop());
857 pair!(supports_force_drop, ddl.supports_force_drop());
858
859 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
868 disagreements.push("supports_table_functions");
869 }
870
871 if !disagreements.is_empty() {
872 findings.push(Finding {
873 severity: Severity::Blocking,
874 what: format!(
875 "this dialect declares {} fact(s) two ways and the two disagree: {}",
876 disagreements.len(),
877 disagreements.join(", ")
878 ),
879 fix: "each of these is declared once on the capability trait and once in the \
880 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
881 and move the other one to match."
882 .to_owned(),
883 });
884 }
885}
886
887fn declarable_types() -> Vec<DataType> {
895 vec![
896 DataType::tinyint(),
897 DataType::smallint(),
898 DataType::int(),
899 DataType::bigint(),
900 DataType::double(),
901 DataType::boolean(),
902 DataType::text(),
903 DataType::varchar(200),
904 DataType::uuid(),
905 DataType::json(),
906 DataType::date(),
907 DataType::timestamp(),
908 DataType::timestamp_tz(),
909 DataType::decimal(18, 2),
910 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
911 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
912 DataType::Struct(StructDataType::new(vec![
913 StructField::new("street", DataType::varchar(200)),
914 StructField::new("zip", DataType::varchar(10)),
915 ])),
916 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
917 StructField::new("key", DataType::varchar(100)),
918 StructField::new("value", DataType::varchar(1000)),
919 ])))),
920 ]
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::dialect::DialectInfo;
927 use razeri_plugin_contract::{
928 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
929 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
930 TaskRenderer, TextCast,
931 };
932 use std::marker::PhantomData;
933
934 struct BrokenPlugin;
943
944 impl SqlRenderer for BrokenPlugin {
945 fn render_data_type(&self, _dt: &DataType) -> String {
946 String::new()
947 }
948 fn quote_identifier(&self, name: &str) -> String {
949 name.to_owned()
950 }
951 fn render_cast_type(&self, _dt: &DataType) -> String {
952 String::new()
953 }
954 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
955 name.to_owned()
956 }
957 fn render_current_timestamp(&self) -> String {
958 String::new()
959 }
960 fn dialect_name(&self) -> &'static str {
961 "brokendb"
962 }
963 fn supports_on_conflict(&self) -> bool {
966 true
967 }
968 }
969 impl ExpressionRenderer for BrokenPlugin {}
970 impl DdlRenderer for BrokenPlugin {}
971 impl DmlRenderer for BrokenPlugin {}
972 impl ProcedureRenderer for BrokenPlugin {}
973 impl FormulaRenderer for BrokenPlugin {}
974 impl TaskRenderer for BrokenPlugin {}
975 impl ReplicationRenderer for BrokenPlugin {}
976 impl StorageRenderer for BrokenPlugin {}
977 impl DialectCapabilities for BrokenPlugin {
978 fn supports_on_conflict(&self) -> bool {
979 false
980 }
981 }
982 impl PlatformDialect for BrokenPlugin {
983 fn info(&self) -> DialectInfo {
984 DialectInfo {
985 name: "brokendb",
986 display_name: "Broken DB",
987 aliases: &[],
988 dialect_family: DialectFamily::Custom(""),
989 family: "Broken",
990 is_cloud_native: false,
991 is_olap: false,
992 is_postgres_compatible: false,
993 }
994 }
995 }
996
997 trait FixtureName: Send + Sync + 'static {
1006 const NAME: &'static str;
1008 const CONCAT: Concat = Concat::Function("CONCAT");
1012 }
1013
1014 struct Unlinked;
1017 impl FixtureName for Unlinked {
1018 const NAME: &'static str = "healthydb";
1019 }
1020
1021 struct Outside;
1024 impl FixtureName for Outside {
1025 const NAME: &'static str = "outsidedb";
1026 }
1027
1028 struct BinaryConcat;
1033 impl FixtureName for BinaryConcat {
1034 const NAME: &'static str = "binaryconcatdb";
1035 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1036 }
1037
1038 struct Answered<N: FixtureName>(PhantomData<N>);
1039
1040 impl<N: FixtureName> Answered<N> {
1041 const fn new() -> Self {
1042 Self(PhantomData)
1043 }
1044 }
1045
1046 type HealthyPlugin = Answered<Unlinked>;
1047 type OutsidePlugin = Answered<Outside>;
1048
1049 inventory::submit! {
1050 crate::builder::DialectRegistration::new(
1051 Outside::NAME,
1052 || std::sync::Arc::new(OutsidePlugin::new()),
1053 )
1054 }
1055
1056 impl<N: FixtureName> SqlRenderer for Answered<N> {
1057 fn render_data_type(&self, dt: &DataType) -> String {
1058 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1059 }
1060 fn quote_identifier(&self, name: &str) -> String {
1061 format!("\"{name}\"")
1062 }
1063 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1064 schema.map_or_else(
1065 || self.quote_identifier(name),
1066 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1067 )
1068 }
1069 fn render_current_timestamp(&self) -> String {
1070 "NOW()".to_owned()
1071 }
1072 fn dialect_name(&self) -> &'static str {
1073 N::NAME
1074 }
1075 fn sid_spelling(&self) -> SidSpelling {
1076 SidSpelling {
1077 platform: N::NAME,
1078 hash: ("answered_xxhash64(", ")"),
1079 cast_type: TextCast::Direct("STRING"),
1080 concat: N::CONCAT,
1081 through_helper: false,
1082 ..razeri_plugin_contract::ANSI_SID_SPELLING
1083 }
1084 }
1085 fn procedure_form(&self) -> ProcedureForm {
1086 ProcedureForm::Unsupported(
1087 "This engine has no stored procedures; write the load as a scheduled query \
1088 instead.",
1089 )
1090 }
1091 fn scalar_function_form(&self) -> ScalarFunctionForm {
1092 ScalarFunctionForm::PostgreSql
1093 }
1094 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1095 Box::new(Self::new())
1096 }
1097 }
1098 impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1099 impl<N: FixtureName> DdlRenderer for Answered<N> {}
1100 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1101 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1102 impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1103 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1104 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1105 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1106 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1107 fn merge_shape(&self) -> MergeShape {
1108 MergeShape::Merge(MergeRestrictions::none())
1109 }
1110 }
1111 impl<N: FixtureName> PlatformDialect for Answered<N> {
1112 fn info(&self) -> DialectInfo {
1113 DialectInfo {
1114 name: N::NAME,
1115 display_name: "An Answered Engine",
1116 aliases: &[],
1119 dialect_family: DialectFamily::Custom(N::NAME),
1120 family: "Answered",
1121 is_cloud_native: false,
1122 is_olap: false,
1123 is_postgres_compatible: false,
1124 }
1125 }
1126 }
1127
1128 struct Twin;
1134
1135 impl SqlRenderer for Twin {
1136 fn render_data_type(&self, dt: &DataType) -> String {
1137 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1138 }
1139 fn quote_identifier(&self, name: &str) -> String {
1140 format!("\"{name}\"")
1141 }
1142 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1143 self.quote_identifier(name)
1144 }
1145 fn render_current_timestamp(&self) -> String {
1146 "NOW()".to_owned()
1147 }
1148 }
1149 impl ExpressionRenderer for Twin {}
1150 impl DdlRenderer for Twin {}
1151 impl DmlRenderer for Twin {}
1152 impl ProcedureRenderer for Twin {}
1153 impl FormulaRenderer for Twin {}
1154 impl TaskRenderer for Twin {}
1155 impl ReplicationRenderer for Twin {}
1156 impl StorageRenderer for Twin {}
1157 impl DialectCapabilities for Twin {}
1158 impl PlatformDialect for Twin {
1159 fn info(&self) -> DialectInfo {
1160 DialectInfo {
1161 name: "twindb",
1162 display_name: "Twin DB",
1163 aliases: &["healthydb"],
1165 dialect_family: DialectFamily::Custom("twindb"),
1166 family: "Twin",
1167 is_cloud_native: false,
1168 is_olap: false,
1169 is_postgres_compatible: false,
1170 }
1171 }
1172 }
1173
1174 fn mentions(report: &Report, needle: &str) -> bool {
1176 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1177 }
1178
1179 #[test]
1183 fn the_kit_catches_a_deliberately_broken_plugin() {
1184 let report = check(&BrokenPlugin);
1185 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1186
1187 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1188 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1189 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1190 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1191 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1192 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1193 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1194 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1195 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1196 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1197 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1198
1199 assert!(
1200 report.count(Severity::Blocking) >= 8,
1201 "the broken plugin owes more than {} blocking fixes:\n{report}",
1202 report.count(Severity::Blocking)
1203 );
1204 }
1205
1206 #[test]
1210 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1211 let report = check(&HealthyPlugin::new());
1212 let unregistered: Vec<&Finding> =
1213 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1214 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1215 assert_eq!(
1216 report.findings.len(),
1217 1,
1218 "a plugin that answered every question owes nothing else:\n{report}"
1219 );
1220 }
1221
1222 #[test]
1227 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1228 let binary = Answered::<BinaryConcat>::new();
1229 let report = check(&binary);
1230 assert_eq!(
1231 report.findings.len(),
1232 check(&HealthyPlugin::new()).findings.len(),
1233 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1234 );
1235 assert!(
1236 !report.to_string().contains("joins"),
1237 "nothing about the join is outstanding:\n{report}"
1238 );
1239
1240 let key = razeri_plugin_contract::sid::sid_expression(
1241 SqlRenderer::sid_spelling(&binary),
1242 None,
1243 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1244 );
1245 assert!(
1246 key.contains("CONCAT(CONCAT("),
1247 "a two-argument engine gets one call per pair:\n{key}"
1248 );
1249 }
1250
1251 #[test]
1260 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1261 let mut quiet = Vec::new();
1262 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1263 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1264
1265 let mut stated = Vec::new();
1266 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1267 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1268 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1269 assert!(
1270 stated[0].what.contains("literal"),
1271 "the finding names the restriction:\n{}",
1272 stated[0].what
1273 );
1274 assert!(
1275 stated[0].fix.contains("dynamic-execution"),
1276 "and says what Razeri does with the answer:\n{}",
1277 stated[0].fix
1278 );
1279
1280 let mut two_clauses = Vec::new();
1283 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1284 assert!(two_clauses.is_empty(), "one restriction is not the other");
1285 }
1286
1287 #[test]
1293 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1294 assert!(
1295 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1296 "the fixture registers through the registry, which is the whole path being graded"
1297 );
1298 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1302 assert!(
1303 registry
1304 .list()
1305 .iter()
1306 .filter(|info| info.name != Outside::NAME)
1307 .all(|info| !info.answers_to(Outside::NAME)),
1308 "the fixture's name must be one no other platform claims"
1309 );
1310
1311 let report = check(&OutsidePlugin::new());
1312 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1313 assert!(report.is_clean(), "and the report says so:\n{report}");
1314 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1315 assert!(
1316 report.to_string().contains("nothing to fix"),
1317 "a clean dialect is told it is clean:\n{report}"
1318 );
1319 }
1320
1321 #[test]
1328 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1329 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1330 for info in registry.list() {
1331 assert_eq!(
1332 info.spellings().first().copied(),
1333 Some(info.name),
1334 "a platform's own name leads its spellings"
1335 );
1336 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1337 }
1338 }
1339
1340 #[test]
1346 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1347 let mut registry = crate::dialect::PlatformRegistry::new();
1348 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1349 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1350
1351 let report = check_with_registry(&Twin, ®istry);
1352 let collision = report
1353 .findings
1354 .iter()
1355 .find(|f| f.what.contains("claimed by more than one loaded platform"))
1356 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1357 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1358 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1359 assert!(collision.what.contains("twindb"), "{collision:?}");
1360 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1361 assert!(
1362 !mentions(&report, "does not resolve"),
1363 "a collision is not an unregistered platform:\n{report}"
1364 );
1365 }
1366
1367 #[test]
1371 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1372 let mut registry = crate::dialect::PlatformRegistry::new();
1373 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1374
1375 let mut findings = Vec::new();
1376 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
1377 assert!(findings.is_empty(), "{findings:?}");
1378
1379 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
1380 assert!(
1381 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
1382 "'{spelling}' is the same platform"
1383 );
1384 }
1385 assert!(
1386 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
1387 );
1388 }
1389
1390 #[test]
1395 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1396 let mut registry = crate::dialect::PlatformRegistry::new();
1397 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1398
1399 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
1400 assert!(
1401 report.is_clean(),
1402 "a loaded dialect that answered everything owes nothing:\n{report}"
1403 );
1404 }
1405
1406 #[test]
1410 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1411 let mut collided = crate::dialect::PlatformRegistry::new();
1412 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1413 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1414
1415 let mut fixes: Vec<String> = Vec::new();
1416 for report in [
1417 check(&BrokenPlugin),
1418 check(&HealthyPlugin::new()),
1419 check(&OutsidePlugin::new()),
1420 check_with_registry(&Twin, &collided),
1423 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1424 ] {
1425 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1426 }
1427 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1428 for fix in &fixes {
1429 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1430 assert!(
1431 !fix.contains(path),
1432 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
1433 );
1434 }
1435 }
1436 }
1437
1438 #[test]
1448 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1449 for (kind, form_method, render_method) in [
1450 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1451 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1452 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1453 ] {
1454 let mut promised = Vec::new();
1455 check_custom_grammar(
1456 CustomGrammar {
1457 kind,
1458 grammar: "PL/SQL",
1459 form_method,
1460 render_method,
1461 noun: "routines",
1462 },
1463 |_| None,
1464 &mut promised,
1465 );
1466 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1467 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1468 assert!(promised[0].what.contains(form_method), "{promised:?}");
1469 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1470 assert!(promised[0].fix.contains(render_method), "{promised:?}");
1471
1472 let mut kept = Vec::new();
1474 check_custom_grammar(
1475 CustomGrammar {
1476 kind,
1477 grammar: "PL/SQL",
1478 form_method,
1479 render_method,
1480 noun: "routines",
1481 },
1482 |spec| Some(format!("CREATE {} …", spec.name)),
1483 &mut kept,
1484 );
1485 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1486 }
1487 }
1488
1489 #[test]
1495 fn a_custom_render_method_that_returns_nothing_is_blocking() {
1496 let mut findings = Vec::new();
1497 check_custom_grammar(
1498 CustomGrammar {
1499 kind: RoutineKind::Procedure,
1500 grammar: "PL/SQL",
1501 form_method: "procedure_form",
1502 render_method: "render_procedure",
1503 noun: "procedures",
1504 },
1505 |_| Some(" \n".to_owned()),
1506 &mut findings,
1507 );
1508 assert_eq!(findings.len(), 1, "{findings:?}");
1509 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1510 }
1511
1512 #[test]
1514 fn a_custom_grammar_with_no_name_is_blocking() {
1515 let mut findings = Vec::new();
1516 check_custom_grammar(
1517 CustomGrammar {
1518 kind: RoutineKind::ScalarFunction,
1519 grammar: " ",
1520 form_method: "scalar_function_form",
1521 render_method: "render_scalar_function",
1522 noun: "scalar functions",
1523 },
1524 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1525 &mut findings,
1526 );
1527 assert_eq!(findings.len(), 1, "{findings:?}");
1528 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1529 }
1530
1531 #[test]
1539 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1540 struct Overstated;
1541 impl SqlRenderer for Overstated {
1542 fn render_data_type(&self, dt: &DataType) -> String {
1543 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1544 }
1545 fn quote_identifier(&self, name: &str) -> String {
1546 format!("\"{name}\"")
1547 }
1548 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1549 name.to_owned()
1550 }
1551 fn render_current_timestamp(&self) -> String {
1552 "CURRENT_TIMESTAMP".to_owned()
1553 }
1554 fn dialect_name(&self) -> &'static str {
1555 "overstateddb"
1556 }
1557 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1558 Some(razeri_plugin_contract::SidHelperBody {
1559 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1560 body: " \n ",
1561 })
1562 }
1563 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1564 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1565 }
1566 }
1567 impl ExpressionRenderer for Overstated {}
1568 impl DdlRenderer for Overstated {}
1569 impl DmlRenderer for Overstated {}
1570 impl ProcedureRenderer for Overstated {}
1571 impl FormulaRenderer for Overstated {}
1572 impl TaskRenderer for Overstated {}
1573 impl ReplicationRenderer for Overstated {}
1574 impl StorageRenderer for Overstated {}
1575 impl DialectCapabilities for Overstated {}
1576 impl PlatformDialect for Overstated {
1577 fn info(&self) -> DialectInfo {
1578 DialectInfo {
1579 name: "overstateddb",
1580 display_name: "Overstated DB",
1581 aliases: &[],
1582 dialect_family: DialectFamily::Custom(""),
1583 family: "Overstated",
1584 is_cloud_native: false,
1585 is_olap: false,
1586 is_postgres_compatible: false,
1587 }
1588 }
1589 }
1590
1591 let report = check(&Overstated);
1592 for (fragment, why) in [
1593 ("supplies an empty one", "a blank helper body"),
1594 ("does not carry the path", "a file relation that drops its path"),
1595 ] {
1596 let found = report
1597 .findings
1598 .iter()
1599 .find(|f| f.what.contains(fragment))
1600 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1601 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1602 }
1603
1604 let quiet = check(&HealthyPlugin::new());
1607 for fragment in ["supplies an empty one", "does not carry the path"] {
1608 assert!(
1609 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1610 "silence is the default and owes nothing:\n{quiet}"
1611 );
1612 }
1613 }
1614
1615 #[test]
1619 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1620 let mut mute = Vec::new();
1621 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1622 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1623 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1624
1625 let silence = check(&BrokenPlugin);
1626 assert!(
1627 silence
1628 .findings
1629 .iter()
1630 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1631 "a dialect that never answered is unanswered, not wrong:\n{silence}"
1632 );
1633 assert!(
1634 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1635 "the fix line names the way to state a measured refusal:\n{silence}"
1636 );
1637 }
1638
1639 #[test]
1643 fn the_report_reads_as_a_summary_then_a_fix_list() {
1644 let text = check(&BrokenPlugin).to_string();
1645 let first = text.lines().next().expect("a summary line");
1646 assert!(first.starts_with("brokendb:"), "{first}");
1647 assert!(first.contains("to fix"), "{first}");
1648 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
1649 }
1650}