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