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 }
1042 }
1043 }
1044
1045 trait FixtureName: Send + Sync + 'static {
1054 const NAME: &'static str;
1056 const CONCAT: Concat = Concat::Function("CONCAT");
1060 }
1061
1062 struct Unlinked;
1065 impl FixtureName for Unlinked {
1066 const NAME: &'static str = "healthydb";
1067 }
1068
1069 struct Outside;
1072 impl FixtureName for Outside {
1073 const NAME: &'static str = "outsidedb";
1074 }
1075
1076 struct BinaryConcat;
1081 impl FixtureName for BinaryConcat {
1082 const NAME: &'static str = "binaryconcatdb";
1083 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1084 }
1085
1086 struct Answered<N: FixtureName>(PhantomData<N>);
1087
1088 impl<N: FixtureName> Answered<N> {
1089 const fn new() -> Self {
1090 Self(PhantomData)
1091 }
1092 }
1093
1094 type HealthyPlugin = Answered<Unlinked>;
1095 type OutsidePlugin = Answered<Outside>;
1096
1097 inventory::submit! {
1098 crate::builder::DialectRegistration::new(
1099 Outside::NAME,
1100 || std::sync::Arc::new(OutsidePlugin::new()),
1101 )
1102 }
1103
1104 impl<N: FixtureName> SqlRenderer for Answered<N> {
1105 fn render_data_type(&self, dt: &DataType) -> String {
1106 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1107 }
1108 fn quote_identifier(&self, name: &str) -> String {
1109 format!("\"{name}\"")
1110 }
1111 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1112 schema.map_or_else(
1113 || self.quote_identifier(name),
1114 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1115 )
1116 }
1117 fn render_current_timestamp(&self) -> String {
1118 "NOW()".to_owned()
1119 }
1120 fn dialect_name(&self) -> &'static str {
1121 N::NAME
1122 }
1123 fn sid_spelling(&self) -> SidSpelling {
1124 SidSpelling {
1125 platform: N::NAME,
1126 hash: ("answered_xxhash64(", ")"),
1127 cast_type: TextCast::Direct("STRING"),
1128 concat: N::CONCAT,
1129 through_helper: false,
1130 ..razeri_plugin_contract::ANSI_SID_SPELLING
1131 }
1132 }
1133 fn procedure_form(&self) -> ProcedureForm {
1134 ProcedureForm::Unsupported(
1135 "This engine has no stored procedures; write the load as a scheduled query \
1136 instead.",
1137 )
1138 }
1139 fn scalar_function_form(&self) -> ScalarFunctionForm {
1140 ScalarFunctionForm::PostgreSql
1141 }
1142 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1143 Box::new(Self::new())
1144 }
1145 }
1146 impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1147 impl<N: FixtureName> DdlRenderer for Answered<N> {}
1148 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1149 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1150 impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1151 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1152 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1153 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1154 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1155 fn merge_shape(&self) -> MergeShape {
1156 MergeShape::Merge(MergeRestrictions::none())
1157 }
1158 }
1159 impl<N: FixtureName> PlatformDialect for Answered<N> {
1160 fn info(&self) -> DialectInfo {
1161 DialectInfo {
1162 name: N::NAME,
1163 display_name: "An Answered Engine",
1164 aliases: &[],
1167 dialect_family: DialectFamily::Custom(N::NAME),
1168 family: "Answered",
1169 is_cloud_native: false,
1170 is_olap: false,
1171 is_postgres_compatible: false,
1172 }
1173 }
1174 }
1175
1176 struct Twin;
1182
1183 impl SqlRenderer for Twin {
1184 fn render_data_type(&self, dt: &DataType) -> String {
1185 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1186 }
1187 fn quote_identifier(&self, name: &str) -> String {
1188 format!("\"{name}\"")
1189 }
1190 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1191 self.quote_identifier(name)
1192 }
1193 fn render_current_timestamp(&self) -> String {
1194 "NOW()".to_owned()
1195 }
1196 }
1197 impl ExpressionRenderer for Twin {}
1198 impl DdlRenderer for Twin {}
1199 impl DmlRenderer for Twin {}
1200 impl ProcedureRenderer for Twin {}
1201 impl FormulaRenderer for Twin {}
1202 impl TaskRenderer for Twin {}
1203 impl ReplicationRenderer for Twin {}
1204 impl StorageRenderer for Twin {}
1205 impl DialectCapabilities for Twin {}
1206 impl PlatformDialect for Twin {
1207 fn info(&self) -> DialectInfo {
1208 DialectInfo {
1209 name: "twindb",
1210 display_name: "Twin DB",
1211 aliases: &["healthydb"],
1213 dialect_family: DialectFamily::Custom("twindb"),
1214 family: "Twin",
1215 is_cloud_native: false,
1216 is_olap: false,
1217 is_postgres_compatible: false,
1218 }
1219 }
1220 }
1221
1222 fn mentions(report: &Report, needle: &str) -> bool {
1224 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1225 }
1226
1227 #[test]
1231 fn the_kit_catches_a_deliberately_broken_plugin() {
1232 let report = check(&BrokenPlugin);
1233 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1234
1235 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1236 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1237 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1238 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1239 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1240 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1241 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1242 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1243 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1244 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1245 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1246
1247 assert!(
1248 report.count(Severity::Blocking) >= 8,
1249 "the broken plugin owes more than {} blocking fixes:\n{report}",
1250 report.count(Severity::Blocking)
1251 );
1252 }
1253
1254 #[test]
1258 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1259 let report = check(&HealthyPlugin::new());
1260 let unregistered: Vec<&Finding> =
1261 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1262 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1263 assert_eq!(
1264 report.findings.len(),
1265 1,
1266 "a plugin that answered every question owes nothing else:\n{report}"
1267 );
1268 }
1269
1270 #[test]
1275 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1276 let binary = Answered::<BinaryConcat>::new();
1277 let report = check(&binary);
1278 assert_eq!(
1279 report.findings.len(),
1280 check(&HealthyPlugin::new()).findings.len(),
1281 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1282 );
1283 assert!(
1284 !report.to_string().contains("joins"),
1285 "nothing about the join is outstanding:\n{report}"
1286 );
1287
1288 let key = razeri_plugin_contract::sid::sid_expression(
1289 SqlRenderer::sid_spelling(&binary),
1290 None,
1291 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1292 );
1293 assert!(
1294 key.contains("CONCAT(CONCAT("),
1295 "a two-argument engine gets one call per pair:\n{key}"
1296 );
1297 }
1298
1299 #[test]
1308 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1309 let mut quiet = Vec::new();
1310 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1311 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1312
1313 let mut stated = Vec::new();
1314 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1315 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1316 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1317 assert!(
1318 stated[0].what.contains("literal"),
1319 "the finding names the restriction:\n{}",
1320 stated[0].what
1321 );
1322 assert!(
1323 stated[0].fix.contains("dynamic-execution"),
1324 "and says what Razeri does with the answer:\n{}",
1325 stated[0].fix
1326 );
1327
1328 let mut two_clauses = Vec::new();
1331 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1332 assert!(two_clauses.is_empty(), "one restriction is not the other");
1333 }
1334
1335 #[test]
1341 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1342 assert!(
1343 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1344 "the fixture registers through the registry, which is the whole path being graded"
1345 );
1346 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1350 assert!(
1351 registry
1352 .list()
1353 .iter()
1354 .filter(|info| info.name != Outside::NAME)
1355 .all(|info| !info.answers_to(Outside::NAME)),
1356 "the fixture's name must be one no other platform claims"
1357 );
1358
1359 let report = check(&OutsidePlugin::new());
1360 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1361 assert!(report.is_clean(), "and the report says so:\n{report}");
1362 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1363 assert!(
1364 report.to_string().contains("nothing to fix"),
1365 "a clean dialect is told it is clean:\n{report}"
1366 );
1367 }
1368
1369 #[test]
1376 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1377 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1378 for info in registry.list() {
1379 assert_eq!(
1380 info.spellings().first().copied(),
1381 Some(info.name),
1382 "a platform's own name leads its spellings"
1383 );
1384 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1385 }
1386 }
1387
1388 #[test]
1394 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1395 let mut registry = crate::dialect::PlatformRegistry::new();
1396 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1397 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1398
1399 let report = check_with_registry(&Twin, ®istry);
1400 let collision = report
1401 .findings
1402 .iter()
1403 .find(|f| f.what.contains("claimed by more than one loaded platform"))
1404 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1405 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1406 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1407 assert!(collision.what.contains("twindb"), "{collision:?}");
1408 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1409 assert!(
1410 !mentions(&report, "does not resolve"),
1411 "a collision is not an unregistered platform:\n{report}"
1412 );
1413 }
1414
1415 #[test]
1419 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1420 let mut registry = crate::dialect::PlatformRegistry::new();
1421 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1422
1423 let mut findings = Vec::new();
1424 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
1425 assert!(findings.is_empty(), "{findings:?}");
1426
1427 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
1428 assert!(
1429 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
1430 "'{spelling}' is the same platform"
1431 );
1432 }
1433 assert!(
1434 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
1435 );
1436 }
1437
1438 #[test]
1443 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1444 let mut registry = crate::dialect::PlatformRegistry::new();
1445 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1446
1447 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
1448 assert!(
1449 report.is_clean(),
1450 "a loaded dialect that answered everything owes nothing:\n{report}"
1451 );
1452 }
1453
1454 #[test]
1458 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1459 let mut collided = crate::dialect::PlatformRegistry::new();
1460 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1461 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1462
1463 let mut fixes: Vec<String> = Vec::new();
1464 for report in [
1465 check(&BrokenPlugin),
1466 check(&HealthyPlugin::new()),
1467 check(&OutsidePlugin::new()),
1468 check_with_registry(&Twin, &collided),
1471 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1472 ] {
1473 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1474 }
1475 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1476 for fix in &fixes {
1477 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1478 assert!(
1479 !fix.contains(path),
1480 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
1481 );
1482 }
1483 }
1484 }
1485
1486 #[test]
1496 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1497 for (kind, form_method, render_method) in [
1498 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1499 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1500 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1501 ] {
1502 let mut promised = Vec::new();
1503 check_custom_grammar(
1504 CustomGrammar {
1505 kind,
1506 grammar: "PL/SQL",
1507 form_method,
1508 render_method,
1509 noun: "routines",
1510 },
1511 |_| None,
1512 &mut promised,
1513 );
1514 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1515 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1516 assert!(promised[0].what.contains(form_method), "{promised:?}");
1517 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1518 assert!(promised[0].fix.contains(render_method), "{promised:?}");
1519
1520 let mut kept = Vec::new();
1522 check_custom_grammar(
1523 CustomGrammar {
1524 kind,
1525 grammar: "PL/SQL",
1526 form_method,
1527 render_method,
1528 noun: "routines",
1529 },
1530 |spec| Some(format!("CREATE {} …", spec.name)),
1531 &mut kept,
1532 );
1533 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1534 }
1535 }
1536
1537 #[test]
1543 fn a_custom_render_method_that_returns_nothing_is_blocking() {
1544 let mut findings = Vec::new();
1545 check_custom_grammar(
1546 CustomGrammar {
1547 kind: RoutineKind::Procedure,
1548 grammar: "PL/SQL",
1549 form_method: "procedure_form",
1550 render_method: "render_procedure",
1551 noun: "procedures",
1552 },
1553 |_| Some(" \n".to_owned()),
1554 &mut findings,
1555 );
1556 assert_eq!(findings.len(), 1, "{findings:?}");
1557 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1558 }
1559
1560 #[test]
1562 fn a_custom_grammar_with_no_name_is_blocking() {
1563 let mut findings = Vec::new();
1564 check_custom_grammar(
1565 CustomGrammar {
1566 kind: RoutineKind::ScalarFunction,
1567 grammar: " ",
1568 form_method: "scalar_function_form",
1569 render_method: "render_scalar_function",
1570 noun: "scalar functions",
1571 },
1572 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1573 &mut findings,
1574 );
1575 assert_eq!(findings.len(), 1, "{findings:?}");
1576 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1577 }
1578
1579 #[test]
1587 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1588 struct Overstated;
1589 impl SqlRenderer for Overstated {
1590 fn render_data_type(&self, dt: &DataType) -> String {
1591 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1592 }
1593 fn quote_identifier(&self, name: &str) -> String {
1594 format!("\"{name}\"")
1595 }
1596 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1597 name.to_owned()
1598 }
1599 fn render_current_timestamp(&self) -> String {
1600 "CURRENT_TIMESTAMP".to_owned()
1601 }
1602 fn dialect_name(&self) -> &'static str {
1603 "overstateddb"
1604 }
1605 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1606 Some(razeri_plugin_contract::SidHelperBody {
1607 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1608 body: " \n ",
1609 })
1610 }
1611 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1612 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1613 }
1614 }
1615 impl ExpressionRenderer for Overstated {}
1616 impl DdlRenderer for Overstated {}
1617 impl DmlRenderer for Overstated {}
1618 impl ProcedureRenderer for Overstated {}
1619 impl FormulaRenderer for Overstated {}
1620 impl TaskRenderer for Overstated {}
1621 impl ReplicationRenderer for Overstated {}
1622 impl StorageRenderer for Overstated {}
1623 impl DialectCapabilities for Overstated {}
1624 impl PlatformDialect for Overstated {
1625 fn info(&self) -> DialectInfo {
1626 DialectInfo {
1627 name: "overstateddb",
1628 display_name: "Overstated DB",
1629 aliases: &[],
1630 dialect_family: DialectFamily::Custom(""),
1631 family: "Overstated",
1632 is_cloud_native: false,
1633 is_olap: false,
1634 is_postgres_compatible: false,
1635 }
1636 }
1637 }
1638
1639 let report = check(&Overstated);
1640 for (fragment, why) in [
1641 ("supplies an empty one", "a blank helper body"),
1642 ("does not carry the path", "a file relation that drops its path"),
1643 ] {
1644 let found = report
1645 .findings
1646 .iter()
1647 .find(|f| f.what.contains(fragment))
1648 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1649 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1650 }
1651
1652 let quiet = check(&HealthyPlugin::new());
1655 for fragment in ["supplies an empty one", "does not carry the path"] {
1656 assert!(
1657 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1658 "silence is the default and owes nothing:\n{quiet}"
1659 );
1660 }
1661 }
1662
1663 #[test]
1667 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1668 let mut mute = Vec::new();
1669 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1670 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1671 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1672
1673 let silence = check(&BrokenPlugin);
1674 assert!(
1675 silence
1676 .findings
1677 .iter()
1678 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1679 "a dialect that never answered is unanswered, not wrong:\n{silence}"
1680 );
1681 assert!(
1682 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1683 "the fix line names the way to state a measured refusal:\n{silence}"
1684 );
1685 }
1686
1687 #[test]
1691 fn the_report_reads_as_a_summary_then_a_fix_list() {
1692 let text = check(&BrokenPlugin).to_string();
1693 let first = text.lines().next().expect("a summary line");
1694 assert!(first.starts_with("brokendb:"), "{first}");
1695 assert!(first.contains("to fix"), "{first}");
1696 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
1697 }
1698}