1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
47 ProcedureForm, RoutineKind, RoutineSpec, ScalarFunctionForm, SqlRenderer, TableFunctionForm,
48 UNKNOWN_PLATFORM,
49};
50use std::fmt;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
54pub enum Severity {
55 Blocking,
58 Unanswered,
61 Note,
63}
64
65impl fmt::Display for Severity {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 f.write_str(match self {
68 Self::Blocking => "must fix",
69 Self::Unanswered => "unanswered",
70 Self::Note => "worth a look",
71 })
72 }
73}
74
75#[derive(Debug, Clone)]
78pub struct Finding {
79 pub severity: Severity,
81 pub what: String,
83 pub fix: String,
85}
86
87#[derive(Debug, Clone)]
89pub struct Report {
90 pub dialect: String,
92 pub findings: Vec<Finding>,
94}
95
96impl Report {
97 #[must_use]
101 pub fn is_clean(&self) -> bool {
102 !self.findings.iter().any(|f| f.severity != Severity::Note)
103 }
104
105 #[must_use]
107 pub fn count(&self, severity: Severity) -> usize {
108 self.findings.iter().filter(|f| f.severity == severity).count()
109 }
110}
111
112impl fmt::Display for Report {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 let name = &self.dialect;
115 if self.findings.is_empty() {
116 return writeln!(
117 f,
118 "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
119 );
120 }
121 let blocking = self.count(Severity::Blocking);
122 let unanswered = self.count(Severity::Unanswered);
123 let notes = self.count(Severity::Note);
124 writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
125 for finding in &self.findings {
126 writeln!(f)?;
127 writeln!(f, " [{}] {}", finding.severity, finding.what)?;
128 writeln!(f, " → {}", finding.fix)?;
129 }
130 Ok(())
131 }
132}
133
134#[must_use]
141pub fn check(dialect: &dyn PlatformDialect) -> Report {
142 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
143}
144
145#[must_use]
148pub fn check_with_registry(
149 dialect: &dyn PlatformDialect,
150 registry: &crate::dialect::PlatformRegistry,
151) -> Report {
152 let info = dialect.info();
153 let mut findings = Vec::new();
154
155 check_registration(&info, registry, &mut findings);
156 check_family(info.dialect_family, &mut findings);
157 check_required_rendering(dialect, &mut findings);
158 check_decisions(dialect, &mut findings);
159 check_formula_renderer(dialect, &mut findings);
160 check_paired_facts(dialect, &mut findings);
161
162 findings.sort_by_key(|f| f.severity);
163 Report { dialect: info.name.to_owned(), findings }
164}
165
166fn check_registration(
177 info: &crate::dialect::DialectInfo,
178 registry: &crate::dialect::PlatformRegistry,
179 findings: &mut Vec<Finding>,
180) {
181 let name = info.name;
182 if matches!(
183 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
184 Err(crate::ResolveError::UnknownDialect { .. })
185 ) {
186 findings.push(Finding {
187 severity: Severity::Blocking,
188 what: format!(
189 "the name `{name}` does not resolve — nothing in this program registered it"
190 ),
191 fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
192 Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
193 it that the program using your dialect calls once. A crate nothing links is a \
194 crate the registry cannot see."
195 .to_owned(),
196 });
197 return;
198 }
199 for spelling in info.spellings() {
200 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
201 Ok(found) if found.info().name == name => {}
202 Ok(found) => findings.push(Finding {
203 severity: Severity::Blocking,
204 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
205 fix: format!(
206 "the registry holds `{name}` under another key. Register it under its own \
207 name, or drop `{spelling}` from its aliases."
208 ),
209 }),
210 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
211 severity: Severity::Blocking,
212 what: format!(
213 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
214 none of them — Razeri refuses that spelling rather than guessing which one \
215 a design meant",
216 claimed_by.join(" and ")
217 ),
218 fix: format!(
219 "give this dialect a name no other platform answers to. A platform name is \
220 read off the loaded dialects' own declared spellings, so a spelling another \
221 platform already claims is never `{name}`'s."
222 ),
223 }),
224 Err(_) => findings.push(Finding {
225 severity: Severity::Blocking,
226 what: format!(
227 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
228 ),
229 fix: format!(
230 "the `{name}` in the registry is a different build of this dialect and does \
231 not declare `{spelling}`. Register the one you are grading."
232 ),
233 }),
234 }
235 }
236}
237
238fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
241 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
242 findings.push(Finding {
243 severity: Severity::Blocking,
244 what: "this dialect's family is a custom one with no name".to_owned(),
245 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
246 prints and what a reader looks up."
247 .to_owned(),
248 });
249 }
250}
251
252fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
254 let declarable = declarable_types();
255 let empty_column: Vec<String> = declarable
256 .iter()
257 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
258 .map(|dt| format!("{dt:?}"))
259 .collect();
260 if !empty_column.is_empty() {
261 findings.push(Finding {
262 severity: Severity::Blocking,
263 what: format!(
264 "`render_data_type` answers nothing for {} of the types a design can declare",
265 empty_column.len()
266 ),
267 fix: "every declarable type needs a column spelling on this engine, even if the \
268 spelling is a wider type that stores it. A blank answer becomes a column \
269 declaration with no type in it."
270 .to_owned(),
271 });
272 }
273 let empty_cast: Vec<String> = declarable
274 .iter()
275 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
276 .map(|dt| format!("{dt:?}"))
277 .collect();
278 if !empty_cast.is_empty() {
279 findings.push(Finding {
280 severity: Severity::Blocking,
281 what: format!(
282 "`render_cast_type` answers nothing for {} declarable types",
283 empty_cast.len()
284 ),
285 fix: "the generated load opens by casting every source column to its declared type, \
286 so a blank answer fails the load on its first statement."
287 .to_owned(),
288 });
289 }
290 let quoted = SqlRenderer::quote_identifier(dialect, "order");
291 if quoted == "order" {
292 findings.push(Finding {
293 severity: Severity::Blocking,
294 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
295 reaches this engine bare"
296 .to_owned(),
297 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
298 backticks, or brackets."
299 .to_owned(),
300 });
301 }
302 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
303 findings.push(Finding {
304 severity: Severity::Blocking,
305 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
306 a row with it"
307 .to_owned(),
308 fix: "return this engine's own expression for the current moment.".to_owned(),
309 });
310 }
311}
312
313fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
315 let spelling = SqlRenderer::sid_spelling(dialect);
316 if spelling.platform == UNKNOWN_PLATFORM {
317 findings.push(Finding {
318 severity: Severity::Blocking,
319 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
320 an `xxhash64` its engine very likely does not have"
321 .to_owned(),
322 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
323 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
324 hash. If it has no portable hash, name an artifact your prerequisites install \
325 — never the nearest-looking built-in, which keys a warehouse to a number \
326 nothing else can reproduce."
327 .to_owned(),
328 });
329 }
330
331 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
336 && supplied.body.trim().is_empty()
337 {
338 findings.push(Finding {
339 severity: Severity::Blocking,
340 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
341 and then supplies an empty one"
342 .to_owned(),
343 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
344 one argument, or answer `None` and let Razeri compose the derivation from \
345 your `sid_spelling`. An empty body deploys without error and keys every row \
346 to nothing."
347 .to_owned(),
348 });
349 }
350
351 let probe = "'/razeri/probe.csv'";
354 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
355 && !relation.contains(probe)
356 {
357 findings.push(Finding {
358 severity: Severity::Blocking,
359 what: "`delimited_file_relation` returns a relation that does not carry the path it \
360 was given"
361 .to_owned(),
362 fix: "put the literal you were handed where your engine's file relation takes a \
363 path. It arrives already quoted and escaped. A relation that drops it reads \
364 one file for every entity the design binds, and the generated fill loads the \
365 wrong rows without failing."
366 .to_owned(),
367 });
368 }
369
370 check_routine_forms(dialect, findings);
371
372 if let ConstraintEnforcement::DeclaredOnly(declared) =
375 DdlRenderer::constraint_enforcement(dialect)
376 && declared.note.split_whitespace().count() < 5
377 {
378 findings.push(Finding {
379 severity: Severity::Blocking,
380 what: "this dialect says its engine accepts constraints it does not enforce, and \
381 the note explaining that says almost nothing"
382 .to_owned(),
383 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
384 guarantee. Say what this engine really does with the declaration, and where \
385 the guarantee has to come from instead."
386 .to_owned(),
387 });
388 }
389
390 match DialectCapabilities::merge_shape(dialect) {
396 MergeShape::None => findings.push(Finding {
397 severity: Severity::Unanswered,
398 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
399 load for it"
400 .to_owned(),
401 fix: "if this engine has a MERGE or an upsert statement, say which in \
402 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
403 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
404 operator can act on, rather than a silence."
405 .to_owned(),
406 }),
407 MergeShape::Unsupported(orientation) => {
408 check_refusal_copy("the upsert refusal", orientation, findings);
409 }
410 _ => {}
411 }
412}
413
414fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
417 let formula = SqlRenderer::to_formula_renderer(dialect);
418 let dialect_name = SqlRenderer::dialect_name(dialect);
419 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
420 findings.push(Finding {
421 severity: Severity::Blocking,
422 what: "`to_formula_renderer` was not overridden, so every formula in every design \
423 renders through Razeri's ANSI defaults instead of this dialect"
424 .to_owned(),
425 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
426 formula this engine sees is silently somebody else's."
427 .to_owned(),
428 });
429 }
430}
431
432fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
438 match SqlRenderer::procedure_form(dialect) {
439 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
443 ProcedureForm::Agnostic => findings.push(Finding {
444 severity: Severity::Unanswered,
445 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
446 annotated form — which no engine parses"
447 .to_owned(),
448 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
449 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
450 offers instead."
451 .to_owned(),
452 }),
453 ProcedureForm::Unsupported(orientation) => {
454 check_refusal_copy("the stored-procedure refusal", orientation, findings);
455 }
456 ProcedureForm::Custom(grammar) => check_custom_grammar(
457 CustomGrammar {
458 kind: RoutineKind::Procedure,
459 grammar,
460 form_method: "procedure_form",
461 render_method: "render_procedure",
462 noun: "procedures",
463 },
464 |spec| SqlRenderer::render_procedure(dialect, spec),
465 findings,
466 ),
467 _ => {}
468 }
469
470 match SqlRenderer::scalar_function_form(dialect) {
471 ScalarFunctionForm::Unsupported(orientation) => {
472 check_refusal_copy("the scalar-function refusal", orientation, findings);
473 }
474 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
475 CustomGrammar {
476 kind: RoutineKind::ScalarFunction,
477 grammar,
478 form_method: "scalar_function_form",
479 render_method: "render_scalar_function",
480 noun: "scalar functions",
481 },
482 |spec| SqlRenderer::render_scalar_function(dialect, spec),
483 findings,
484 ),
485 _ => {}
486 }
487
488 match SqlRenderer::table_function_form(dialect) {
489 TableFunctionForm::Unsupported(orientation) => {
490 check_refusal_copy("the table-function refusal", orientation, findings);
491 }
492 TableFunctionForm::Custom(grammar) => check_custom_grammar(
493 CustomGrammar {
494 kind: RoutineKind::TableFunction,
495 grammar,
496 form_method: "table_function_form",
497 render_method: "render_table_function",
498 noun: "table functions",
499 },
500 |spec| SqlRenderer::render_table_function(dialect, spec),
501 findings,
502 ),
503 _ => {}
504 }
505}
506
507#[derive(Clone, Copy)]
509struct CustomGrammar {
510 kind: RoutineKind,
512 grammar: &'static str,
514 form_method: &'static str,
516 render_method: &'static str,
518 noun: &'static str,
520}
521
522fn check_custom_grammar(
539 claim: CustomGrammar,
540 render: impl Fn(&RoutineSpec) -> Option<String>,
541 findings: &mut Vec<Finding>,
542) {
543 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
544 if grammar.trim().is_empty() {
547 findings.push(Finding {
548 severity: Severity::Blocking,
549 what: format!("`{form_method}` says Custom, and names no grammar"),
550 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
551 of a report, and of the comment Razeri writes where a routine could not be \
552 rendered, is told this engine speaks."
553 .to_owned(),
554 });
555 }
556
557 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
558 probe.or_replace = true;
559 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
560 findings.push(Finding {
561 severity: Severity::Blocking,
562 what: format!(
563 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
564 itself — and `{render_method}` returns nothing"
565 ),
566 fix: format!(
567 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
568 composed — name, parameters with their types already in your spelling, body, \
569 comment — and returns the whole CREATE statement your engine parses. Razeri \
570 emits what you return and writes nothing of its own: a near-neighbour grammar \
571 fails on deploy, and a refusal would say this engine has no {noun} when it \
572 has them in a grammar of its own."
573 ),
574 });
575 }
576}
577
578fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
580 if copy.trim().is_empty() {
581 findings.push(Finding {
582 severity: Severity::Blocking,
583 what: format!("{subject} has no words in it"),
584 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
585 next thing to try, and it has to be about their engine."
586 .to_owned(),
587 });
588 return;
589 }
590 if copy.split_whitespace().count() < 5 {
591 findings.push(Finding {
592 severity: Severity::Note,
593 what: format!("{subject} is very short"),
594 fix: "a refusal earns its place by naming the alternative — what this engine has \
595 instead of the thing it cannot do."
596 .to_owned(),
597 });
598 }
599}
600
601fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
603 let caps: &dyn DialectCapabilities = dialect;
604 let ren: &dyn SqlRenderer = dialect;
605 let ddl: &dyn DdlRenderer = dialect;
606 let mut disagreements: Vec<&'static str> = Vec::new();
607
608 macro_rules! pair {
609 ($m:ident, $renderer_value:expr) => {
610 if caps.$m() != $renderer_value {
611 disagreements.push(stringify!($m));
612 }
613 };
614 }
615 pair!(supports_on_conflict, ren.supports_on_conflict());
616 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
617 pair!(supports_update_from, ren.supports_update_from());
618 pair!(supports_named_windows, ren.supports_named_windows());
619 pair!(supports_compound_assignment, ren.supports_compound_assignment());
620 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
621 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
622 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
623 pair!(uses_alter_table_update, ren.uses_alter_table_update());
624 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
625 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
626 pair!(supports_sync_drop, ddl.supports_sync_drop());
627 pair!(supports_force_drop, ddl.supports_force_drop());
628
629 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
638 disagreements.push("supports_table_functions");
639 }
640
641 if !disagreements.is_empty() {
642 findings.push(Finding {
643 severity: Severity::Blocking,
644 what: format!(
645 "this dialect declares {} fact(s) two ways and the two disagree: {}",
646 disagreements.len(),
647 disagreements.join(", ")
648 ),
649 fix: "each of these is declared once on the capability trait and once in the \
650 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
651 and move the other one to match."
652 .to_owned(),
653 });
654 }
655}
656
657fn declarable_types() -> Vec<DataType> {
660 vec![
661 DataType::tinyint(),
662 DataType::smallint(),
663 DataType::int(),
664 DataType::bigint(),
665 DataType::double(),
666 DataType::boolean(),
667 DataType::text(),
668 DataType::varchar(200),
669 DataType::uuid(),
670 DataType::json(),
671 DataType::date(),
672 DataType::timestamp(),
673 DataType::timestamp_tz(),
674 DataType::decimal(18, 2),
675 ]
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::dialect::DialectInfo;
682 use razeri_plugin_contract::{
683 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
684 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
685 TaskRenderer, TextCast,
686 };
687 use std::marker::PhantomData;
688
689 struct BrokenPlugin;
698
699 impl SqlRenderer for BrokenPlugin {
700 fn render_data_type(&self, _dt: &DataType) -> String {
701 String::new()
702 }
703 fn quote_identifier(&self, name: &str) -> String {
704 name.to_owned()
705 }
706 fn render_cast_type(&self, _dt: &DataType) -> String {
707 String::new()
708 }
709 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
710 name.to_owned()
711 }
712 fn render_current_timestamp(&self) -> String {
713 String::new()
714 }
715 fn dialect_name(&self) -> &'static str {
716 "brokendb"
717 }
718 fn supports_on_conflict(&self) -> bool {
721 true
722 }
723 }
724 impl ExpressionRenderer for BrokenPlugin {}
725 impl DdlRenderer for BrokenPlugin {}
726 impl DmlRenderer for BrokenPlugin {}
727 impl ProcedureRenderer for BrokenPlugin {}
728 impl FormulaRenderer for BrokenPlugin {}
729 impl TaskRenderer for BrokenPlugin {}
730 impl ReplicationRenderer for BrokenPlugin {}
731 impl StorageRenderer for BrokenPlugin {}
732 impl DialectCapabilities for BrokenPlugin {
733 fn supports_on_conflict(&self) -> bool {
734 false
735 }
736 }
737 impl PlatformDialect for BrokenPlugin {
738 fn info(&self) -> DialectInfo {
739 DialectInfo {
740 name: "brokendb",
741 display_name: "Broken DB",
742 aliases: &[],
743 dialect_family: DialectFamily::Custom(""),
744 family: "Broken",
745 is_cloud_native: false,
746 is_olap: false,
747 is_postgres_compatible: false,
748 }
749 }
750 }
751
752 trait FixtureName: Send + Sync + 'static {
761 const NAME: &'static str;
763 const CONCAT: Concat = Concat::Function("CONCAT");
767 }
768
769 struct Unlinked;
772 impl FixtureName for Unlinked {
773 const NAME: &'static str = "healthydb";
774 }
775
776 struct Outside;
779 impl FixtureName for Outside {
780 const NAME: &'static str = "outsidedb";
781 }
782
783 struct BinaryConcat;
788 impl FixtureName for BinaryConcat {
789 const NAME: &'static str = "binaryconcatdb";
790 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
791 }
792
793 struct Answered<N: FixtureName>(PhantomData<N>);
794
795 impl<N: FixtureName> Answered<N> {
796 const fn new() -> Self {
797 Self(PhantomData)
798 }
799 }
800
801 type HealthyPlugin = Answered<Unlinked>;
802 type OutsidePlugin = Answered<Outside>;
803
804 inventory::submit! {
805 crate::builder::DialectRegistration::new(
806 Outside::NAME,
807 || std::sync::Arc::new(OutsidePlugin::new()),
808 )
809 }
810
811 impl<N: FixtureName> SqlRenderer for Answered<N> {
812 fn render_data_type(&self, dt: &DataType) -> String {
813 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
814 }
815 fn quote_identifier(&self, name: &str) -> String {
816 format!("\"{name}\"")
817 }
818 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
819 schema.map_or_else(
820 || self.quote_identifier(name),
821 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
822 )
823 }
824 fn render_current_timestamp(&self) -> String {
825 "NOW()".to_owned()
826 }
827 fn dialect_name(&self) -> &'static str {
828 N::NAME
829 }
830 fn sid_spelling(&self) -> SidSpelling {
831 SidSpelling {
832 platform: N::NAME,
833 hash: ("answered_xxhash64(", ")"),
834 cast_type: TextCast::Direct("STRING"),
835 concat: N::CONCAT,
836 through_helper: false,
837 ..razeri_plugin_contract::ANSI_SID_SPELLING
838 }
839 }
840 fn procedure_form(&self) -> ProcedureForm {
841 ProcedureForm::Unsupported(
842 "This engine has no stored procedures; write the load as a scheduled query \
843 instead.",
844 )
845 }
846 fn scalar_function_form(&self) -> ScalarFunctionForm {
847 ScalarFunctionForm::PostgreSql
848 }
849 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
850 Box::new(Self::new())
851 }
852 }
853 impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
854 impl<N: FixtureName> DdlRenderer for Answered<N> {}
855 impl<N: FixtureName> DmlRenderer for Answered<N> {}
856 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
857 impl<N: FixtureName> FormulaRenderer for Answered<N> {}
858 impl<N: FixtureName> TaskRenderer for Answered<N> {}
859 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
860 impl<N: FixtureName> StorageRenderer for Answered<N> {}
861 impl<N: FixtureName> DialectCapabilities for Answered<N> {
862 fn merge_shape(&self) -> MergeShape {
863 MergeShape::Merge(MergeRestrictions::none())
864 }
865 }
866 impl<N: FixtureName> PlatformDialect for Answered<N> {
867 fn info(&self) -> DialectInfo {
868 DialectInfo {
869 name: N::NAME,
870 display_name: "An Answered Engine",
871 aliases: &[],
874 dialect_family: DialectFamily::Custom(N::NAME),
875 family: "Answered",
876 is_cloud_native: false,
877 is_olap: false,
878 is_postgres_compatible: false,
879 }
880 }
881 }
882
883 struct Twin;
889
890 impl SqlRenderer for Twin {
891 fn render_data_type(&self, dt: &DataType) -> String {
892 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
893 }
894 fn quote_identifier(&self, name: &str) -> String {
895 format!("\"{name}\"")
896 }
897 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
898 self.quote_identifier(name)
899 }
900 fn render_current_timestamp(&self) -> String {
901 "NOW()".to_owned()
902 }
903 }
904 impl ExpressionRenderer for Twin {}
905 impl DdlRenderer for Twin {}
906 impl DmlRenderer for Twin {}
907 impl ProcedureRenderer for Twin {}
908 impl FormulaRenderer for Twin {}
909 impl TaskRenderer for Twin {}
910 impl ReplicationRenderer for Twin {}
911 impl StorageRenderer for Twin {}
912 impl DialectCapabilities for Twin {}
913 impl PlatformDialect for Twin {
914 fn info(&self) -> DialectInfo {
915 DialectInfo {
916 name: "twindb",
917 display_name: "Twin DB",
918 aliases: &["healthydb"],
920 dialect_family: DialectFamily::Custom("twindb"),
921 family: "Twin",
922 is_cloud_native: false,
923 is_olap: false,
924 is_postgres_compatible: false,
925 }
926 }
927 }
928
929 fn mentions(report: &Report, needle: &str) -> bool {
931 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
932 }
933
934 #[test]
938 fn the_kit_catches_a_deliberately_broken_plugin() {
939 let report = check(&BrokenPlugin);
940 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
941
942 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
943 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
944 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
945 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
946 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
947 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
948 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
949 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
950 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
951 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
952 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
953
954 assert!(
955 report.count(Severity::Blocking) >= 8,
956 "the broken plugin owes more than {} blocking fixes:\n{report}",
957 report.count(Severity::Blocking)
958 );
959 }
960
961 #[test]
965 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
966 let report = check(&HealthyPlugin::new());
967 let unregistered: Vec<&Finding> =
968 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
969 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
970 assert_eq!(
971 report.findings.len(),
972 1,
973 "a plugin that answered every question owes nothing else:\n{report}"
974 );
975 }
976
977 #[test]
982 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
983 let binary = Answered::<BinaryConcat>::new();
984 let report = check(&binary);
985 assert_eq!(
986 report.findings.len(),
987 check(&HealthyPlugin::new()).findings.len(),
988 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
989 );
990 assert!(
991 !report.to_string().contains("joins"),
992 "nothing about the join is outstanding:\n{report}"
993 );
994
995 let key = razeri_plugin_contract::sid::sid_expression(
996 SqlRenderer::sid_spelling(&binary),
997 None,
998 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
999 );
1000 assert!(
1001 key.contains("CONCAT(CONCAT("),
1002 "a two-argument engine gets one call per pair:\n{key}"
1003 );
1004 }
1005
1006 #[test]
1012 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1013 assert!(
1014 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1015 "the fixture registers through the registry, which is the whole path being graded"
1016 );
1017 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1021 assert!(
1022 registry
1023 .list()
1024 .iter()
1025 .filter(|info| info.name != Outside::NAME)
1026 .all(|info| !info.answers_to(Outside::NAME)),
1027 "the fixture's name must be one no other platform claims"
1028 );
1029
1030 let report = check(&OutsidePlugin::new());
1031 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1032 assert!(report.is_clean(), "and the report says so:\n{report}");
1033 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1034 assert!(
1035 report.to_string().contains("nothing to fix"),
1036 "a clean dialect is told it is clean:\n{report}"
1037 );
1038 }
1039
1040 #[test]
1047 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1048 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1049 for info in registry.list() {
1050 assert_eq!(
1051 info.spellings().first().copied(),
1052 Some(info.name),
1053 "a platform's own name leads its spellings"
1054 );
1055 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1056 }
1057 }
1058
1059 #[test]
1065 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1066 let mut registry = crate::dialect::PlatformRegistry::new();
1067 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1068 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1069
1070 let report = check_with_registry(&Twin, ®istry);
1071 let collision = report
1072 .findings
1073 .iter()
1074 .find(|f| f.what.contains("claimed by more than one loaded platform"))
1075 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1076 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1077 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1078 assert!(collision.what.contains("twindb"), "{collision:?}");
1079 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1080 assert!(
1081 !mentions(&report, "does not resolve"),
1082 "a collision is not an unregistered platform:\n{report}"
1083 );
1084 }
1085
1086 #[test]
1090 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1091 let mut registry = crate::dialect::PlatformRegistry::new();
1092 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1093
1094 let mut findings = Vec::new();
1095 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
1096 assert!(findings.is_empty(), "{findings:?}");
1097
1098 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
1099 assert!(
1100 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
1101 "'{spelling}' is the same platform"
1102 );
1103 }
1104 assert!(
1105 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
1106 );
1107 }
1108
1109 #[test]
1114 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1115 let mut registry = crate::dialect::PlatformRegistry::new();
1116 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1117
1118 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
1119 assert!(
1120 report.is_clean(),
1121 "a loaded dialect that answered everything owes nothing:\n{report}"
1122 );
1123 }
1124
1125 #[test]
1129 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1130 let mut collided = crate::dialect::PlatformRegistry::new();
1131 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1132 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1133
1134 let mut fixes: Vec<String> = Vec::new();
1135 for report in [
1136 check(&BrokenPlugin),
1137 check(&HealthyPlugin::new()),
1138 check(&OutsidePlugin::new()),
1139 check_with_registry(&Twin, &collided),
1142 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1143 ] {
1144 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1145 }
1146 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1147 for fix in &fixes {
1148 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1149 assert!(
1150 !fix.contains(path),
1151 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
1152 );
1153 }
1154 }
1155 }
1156
1157 #[test]
1167 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1168 for (kind, form_method, render_method) in [
1169 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1170 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1171 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1172 ] {
1173 let mut promised = Vec::new();
1174 check_custom_grammar(
1175 CustomGrammar {
1176 kind,
1177 grammar: "PL/SQL",
1178 form_method,
1179 render_method,
1180 noun: "routines",
1181 },
1182 |_| None,
1183 &mut promised,
1184 );
1185 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1186 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1187 assert!(promised[0].what.contains(form_method), "{promised:?}");
1188 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1189 assert!(promised[0].fix.contains(render_method), "{promised:?}");
1190
1191 let mut kept = Vec::new();
1193 check_custom_grammar(
1194 CustomGrammar {
1195 kind,
1196 grammar: "PL/SQL",
1197 form_method,
1198 render_method,
1199 noun: "routines",
1200 },
1201 |spec| Some(format!("CREATE {} …", spec.name)),
1202 &mut kept,
1203 );
1204 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1205 }
1206 }
1207
1208 #[test]
1214 fn a_custom_render_method_that_returns_nothing_is_blocking() {
1215 let mut findings = Vec::new();
1216 check_custom_grammar(
1217 CustomGrammar {
1218 kind: RoutineKind::Procedure,
1219 grammar: "PL/SQL",
1220 form_method: "procedure_form",
1221 render_method: "render_procedure",
1222 noun: "procedures",
1223 },
1224 |_| Some(" \n".to_owned()),
1225 &mut findings,
1226 );
1227 assert_eq!(findings.len(), 1, "{findings:?}");
1228 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1229 }
1230
1231 #[test]
1233 fn a_custom_grammar_with_no_name_is_blocking() {
1234 let mut findings = Vec::new();
1235 check_custom_grammar(
1236 CustomGrammar {
1237 kind: RoutineKind::ScalarFunction,
1238 grammar: " ",
1239 form_method: "scalar_function_form",
1240 render_method: "render_scalar_function",
1241 noun: "scalar functions",
1242 },
1243 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1244 &mut findings,
1245 );
1246 assert_eq!(findings.len(), 1, "{findings:?}");
1247 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1248 }
1249
1250 #[test]
1258 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1259 struct Overstated;
1260 impl SqlRenderer for Overstated {
1261 fn render_data_type(&self, dt: &DataType) -> String {
1262 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1263 }
1264 fn quote_identifier(&self, name: &str) -> String {
1265 format!("\"{name}\"")
1266 }
1267 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1268 name.to_owned()
1269 }
1270 fn render_current_timestamp(&self) -> String {
1271 "CURRENT_TIMESTAMP".to_owned()
1272 }
1273 fn dialect_name(&self) -> &'static str {
1274 "overstateddb"
1275 }
1276 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1277 Some(razeri_plugin_contract::SidHelperBody {
1278 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1279 body: " \n ",
1280 })
1281 }
1282 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1283 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1284 }
1285 }
1286 impl ExpressionRenderer for Overstated {}
1287 impl DdlRenderer for Overstated {}
1288 impl DmlRenderer for Overstated {}
1289 impl ProcedureRenderer for Overstated {}
1290 impl FormulaRenderer for Overstated {}
1291 impl TaskRenderer for Overstated {}
1292 impl ReplicationRenderer for Overstated {}
1293 impl StorageRenderer for Overstated {}
1294 impl DialectCapabilities for Overstated {}
1295 impl PlatformDialect for Overstated {
1296 fn info(&self) -> DialectInfo {
1297 DialectInfo {
1298 name: "overstateddb",
1299 display_name: "Overstated DB",
1300 aliases: &[],
1301 dialect_family: DialectFamily::Custom(""),
1302 family: "Overstated",
1303 is_cloud_native: false,
1304 is_olap: false,
1305 is_postgres_compatible: false,
1306 }
1307 }
1308 }
1309
1310 let report = check(&Overstated);
1311 for (fragment, why) in [
1312 ("supplies an empty one", "a blank helper body"),
1313 ("does not carry the path", "a file relation that drops its path"),
1314 ] {
1315 let found = report
1316 .findings
1317 .iter()
1318 .find(|f| f.what.contains(fragment))
1319 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1320 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1321 }
1322
1323 let quiet = check(&HealthyPlugin::new());
1326 for fragment in ["supplies an empty one", "does not carry the path"] {
1327 assert!(
1328 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1329 "silence is the default and owes nothing:\n{quiet}"
1330 );
1331 }
1332 }
1333
1334 #[test]
1338 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1339 let mut mute = Vec::new();
1340 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1341 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1342 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1343
1344 let silence = check(&BrokenPlugin);
1345 assert!(
1346 silence
1347 .findings
1348 .iter()
1349 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1350 "a dialect that never answered is unanswered, not wrong:\n{silence}"
1351 );
1352 assert!(
1353 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1354 "the fix line names the way to state a measured refusal:\n{silence}"
1355 );
1356 }
1357
1358 #[test]
1362 fn the_report_reads_as_a_summary_then_a_fix_list() {
1363 let text = check(&BrokenPlugin).to_string();
1364 let first = text.lines().next().expect("a summary line");
1365 assert!(first.starts_with("brokendb:"), "{first}");
1366 assert!(first.contains("to fix"), "{first}");
1367 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
1368 }
1369}