1use crate::PlatformDialect;
36use razeri_plugin_contract::{
37 ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
38 ProcedureForm, SqlRenderer, UNKNOWN_PLATFORM,
39};
40use std::fmt;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum Severity {
45 Blocking,
48 Unanswered,
51 Note,
53}
54
55impl fmt::Display for Severity {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(match self {
58 Self::Blocking => "must fix",
59 Self::Unanswered => "unanswered",
60 Self::Note => "worth a look",
61 })
62 }
63}
64
65#[derive(Debug, Clone)]
68pub struct Finding {
69 pub severity: Severity,
71 pub what: String,
73 pub fix: String,
75}
76
77#[derive(Debug, Clone, Copy)]
79pub struct Obligation {
80 pub what: &'static str,
82 pub command: &'static str,
84}
85
86#[derive(Debug, Clone)]
88pub struct Report {
89 pub dialect: String,
91 pub findings: Vec<Finding>,
93}
94
95impl Report {
96 #[must_use]
100 pub fn is_clean(&self) -> bool {
101 !self.findings.iter().any(|f| f.severity != Severity::Note)
102 }
103
104 #[must_use]
106 pub fn count(&self, severity: Severity) -> usize {
107 self.findings.iter().filter(|f| f.severity == severity).count()
108 }
109}
110
111impl fmt::Display for Report {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 let name = &self.dialect;
114 if self.findings.is_empty() {
115 return writeln!(
116 f,
117 "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
118 );
119 }
120 let blocking = self.count(Severity::Blocking);
121 let unanswered = self.count(Severity::Unanswered);
122 let notes = self.count(Severity::Note);
123 writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
124 for finding in &self.findings {
125 writeln!(f)?;
126 writeln!(f, " [{}] {}", finding.severity, finding.what)?;
127 writeln!(f, " → {}", finding.fix)?;
128 }
129 Ok(())
130 }
131}
132
133#[must_use]
136pub fn check(dialect: &dyn PlatformDialect) -> Report {
137 let info = dialect.info();
138 let mut findings = Vec::new();
139
140 check_registration(info.name, &mut findings);
141 check_family(info.dialect_family, &mut findings);
142 check_required_rendering(dialect, &mut findings);
143 check_decisions(dialect, &mut findings);
144 check_formula_renderer(dialect, &mut findings);
145 check_paired_facts(dialect, &mut findings);
146
147 findings.sort_by_key(|f| f.severity);
148 Report { dialect: info.name.to_owned(), findings }
149}
150
151fn check_registration(name: &str, findings: &mut Vec<Finding>) {
153 if crate::resolve_dialect_by_name(name).is_err() {
154 findings.push(Finding {
155 severity: Severity::Blocking,
156 what: format!(
157 "the name `{name}` does not resolve — nothing in this program registered it"
158 ),
159 fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
160 Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
161 it that the program using your dialect calls once. A crate nothing links is a \
162 crate the registry cannot see."
163 .to_owned(),
164 });
165 return;
166 }
167 if !crate::resolve::alias_spellings()
168 .iter()
169 .any(|(spelling, canonical)| *spelling == name && *canonical == name)
170 {
171 findings.push(Finding {
172 severity: Severity::Blocking,
173 what: format!(
174 "`{name}` is registered but the resolver's spelling table does not carry it, so \
175 a design naming this platform is refused at the door"
176 ),
177 fix: "add your platform's accepted spellings to the resolver's alias table, including its \
178 own \
179 name, and add the same spellings to `schemas/razeri-design.schema.json`'s \
180 platform list — a test holds the two set-equal."
181 .to_owned(),
182 });
183 }
184}
185
186fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
189 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
190 findings.push(Finding {
191 severity: Severity::Blocking,
192 what: "this dialect's family is a custom one with no name".to_owned(),
193 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
194 prints and what a reader looks up."
195 .to_owned(),
196 });
197 }
198}
199
200fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
202 let declarable = declarable_types();
203 let empty_column: Vec<String> = declarable
204 .iter()
205 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
206 .map(|dt| format!("{dt:?}"))
207 .collect();
208 if !empty_column.is_empty() {
209 findings.push(Finding {
210 severity: Severity::Blocking,
211 what: format!(
212 "`render_data_type` answers nothing for {} of the types a design can declare",
213 empty_column.len()
214 ),
215 fix: "every declarable type needs a column spelling on this engine, even if the \
216 spelling is a wider type that stores it. A blank answer becomes a column \
217 declaration with no type in it."
218 .to_owned(),
219 });
220 }
221 let empty_cast: Vec<String> = declarable
222 .iter()
223 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
224 .map(|dt| format!("{dt:?}"))
225 .collect();
226 if !empty_cast.is_empty() {
227 findings.push(Finding {
228 severity: Severity::Blocking,
229 what: format!(
230 "`render_cast_type` answers nothing for {} declarable types",
231 empty_cast.len()
232 ),
233 fix: "the generated load opens by casting every source column to its declared type, \
234 so a blank answer fails the load on its first statement."
235 .to_owned(),
236 });
237 }
238 let quoted = SqlRenderer::quote_identifier(dialect, "order");
239 if quoted == "order" {
240 findings.push(Finding {
241 severity: Severity::Blocking,
242 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
243 reaches this engine bare"
244 .to_owned(),
245 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
246 backticks, or brackets."
247 .to_owned(),
248 });
249 }
250 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
251 findings.push(Finding {
252 severity: Severity::Blocking,
253 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
254 a row with it"
255 .to_owned(),
256 fix: "return this engine's own expression for the current moment.".to_owned(),
257 });
258 }
259}
260
261fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
263 let spelling = SqlRenderer::sid_spelling(dialect);
264 if spelling.platform == UNKNOWN_PLATFORM {
265 findings.push(Finding {
266 severity: Severity::Blocking,
267 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
268 an `xxhash64` its engine very likely does not have"
269 .to_owned(),
270 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
271 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
272 hash. If it has no portable hash, name an artifact your prerequisites install \
273 — never the nearest-looking built-in, which keys a warehouse to a number \
274 nothing else can reproduce."
275 .to_owned(),
276 });
277 }
278
279 match SqlRenderer::procedure_form(dialect) {
280 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
284 ProcedureForm::Agnostic => findings.push(Finding {
285 severity: Severity::Unanswered,
286 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
287 annotated form — which no engine parses"
288 .to_owned(),
289 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
290 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
291 offers instead."
292 .to_owned(),
293 }),
294 ProcedureForm::Unsupported(orientation) => {
295 check_refusal_copy("the stored-procedure refusal", orientation, findings);
296 }
297 _ => {}
298 }
299
300 if let Some(orientation) = SqlRenderer::table_function_form(dialect).refusal() {
301 check_refusal_copy("the table-function refusal", orientation, findings);
302 }
303
304 if let ConstraintEnforcement::DeclaredOnly(declared) =
307 DdlRenderer::constraint_enforcement(dialect)
308 && declared.note.split_whitespace().count() < 5
309 {
310 findings.push(Finding {
311 severity: Severity::Blocking,
312 what: "this dialect says its engine accepts constraints it does not enforce, and \
313 the note explaining that says almost nothing"
314 .to_owned(),
315 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
316 guarantee. Say what this engine really does with the declaration, and where \
317 the guarantee has to come from instead."
318 .to_owned(),
319 });
320 }
321
322 match DialectCapabilities::merge_shape(dialect) {
328 MergeShape::None => findings.push(Finding {
329 severity: Severity::Unanswered,
330 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
331 load for it"
332 .to_owned(),
333 fix: "if this engine has a MERGE or an upsert statement, say which in \
334 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
335 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
336 operator can act on, rather than a silence."
337 .to_owned(),
338 }),
339 MergeShape::Unsupported(orientation) => {
340 check_refusal_copy("the upsert refusal", orientation, findings);
341 }
342 _ => {}
343 }
344}
345
346fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
349 let formula = SqlRenderer::to_formula_renderer(dialect);
350 let dialect_name = SqlRenderer::dialect_name(dialect);
351 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
352 findings.push(Finding {
353 severity: Severity::Blocking,
354 what: "`to_formula_renderer` was not overridden, so every formula in every design \
355 renders through Razeri's ANSI defaults instead of this dialect"
356 .to_owned(),
357 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
358 formula this engine sees is silently somebody else's."
359 .to_owned(),
360 });
361 }
362}
363
364fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
366 if copy.trim().is_empty() {
367 findings.push(Finding {
368 severity: Severity::Blocking,
369 what: format!("{subject} has no words in it"),
370 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
371 next thing to try, and it has to be about their engine."
372 .to_owned(),
373 });
374 return;
375 }
376 if copy.split_whitespace().count() < 5 {
377 findings.push(Finding {
378 severity: Severity::Note,
379 what: format!("{subject} is very short"),
380 fix: "a refusal earns its place by naming the alternative — what this engine has \
381 instead of the thing it cannot do."
382 .to_owned(),
383 });
384 }
385}
386
387fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
389 let caps: &dyn DialectCapabilities = dialect;
390 let ren: &dyn SqlRenderer = dialect;
391 let ddl: &dyn DdlRenderer = dialect;
392 let mut disagreements: Vec<&'static str> = Vec::new();
393
394 macro_rules! pair {
395 ($m:ident, $renderer_value:expr) => {
396 if caps.$m() != $renderer_value {
397 disagreements.push(stringify!($m));
398 }
399 };
400 }
401 pair!(supports_on_conflict, ren.supports_on_conflict());
402 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
403 pair!(supports_update_from, ren.supports_update_from());
404 pair!(supports_named_windows, ren.supports_named_windows());
405 pair!(supports_compound_assignment, ren.supports_compound_assignment());
406 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
407 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
408 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
409 pair!(uses_alter_table_update, ren.uses_alter_table_update());
410 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
411 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
412 pair!(supports_sync_drop, ddl.supports_sync_drop());
413 pair!(supports_force_drop, ddl.supports_force_drop());
414
415 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
424 disagreements.push("supports_table_functions");
425 }
426
427 if !disagreements.is_empty() {
428 findings.push(Finding {
429 severity: Severity::Blocking,
430 what: format!(
431 "this dialect declares {} fact(s) two ways and the two disagree: {}",
432 disagreements.len(),
433 disagreements.join(", ")
434 ),
435 fix: "each of these is declared once on the capability trait and once in the \
436 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
437 and move the other one to match."
438 .to_owned(),
439 });
440 }
441}
442
443#[must_use]
451pub const fn repository_obligations() -> &'static [Obligation] {
452 &[
453 Obligation {
454 what: "classify the new crate — every workspace member is named exactly once, and \
455 dialect crates are tier 1",
456 command: "scripts/check-tier-boundary.sh",
457 },
458 Obligation {
459 what: "freeze the DDL your platform renders for all eleven corpus designs",
460 command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test golden_ddl",
461 },
462 Obligation {
463 what: "freeze the derived-column projections your platform renders",
464 command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test expression_matrix",
465 },
466 Obligation {
467 what: "freeze the transaction envelope your platform renders",
468 command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize \
469 --test load_transaction_matrix",
470 },
471 Obligation {
472 what: "regenerate the typed platform constants for Python, TypeScript, Go, C# and \
473 Java",
474 command: "cargo run -p razeri-dialects --bin gen-platform-constants",
475 },
476 Obligation {
477 what: "give every registry-walking roster a row for your platform — they fail one \
478 at a time otherwise",
479 command: "cargo test -p razeri-materialize -p razeri-sql",
480 },
481 Obligation {
482 what: "prove the artifacts deploy and run: an execution leg per routine kind and \
483 per generated load is what finished means here",
484 command: "cargo test -p integration-tests",
485 },
486 ]
487}
488
489fn declarable_types() -> Vec<DataType> {
492 vec![
493 DataType::tinyint(),
494 DataType::smallint(),
495 DataType::int(),
496 DataType::bigint(),
497 DataType::double(),
498 DataType::boolean(),
499 DataType::text(),
500 DataType::varchar(200),
501 DataType::uuid(),
502 DataType::json(),
503 DataType::date(),
504 DataType::timestamp(),
505 DataType::timestamp_tz(),
506 DataType::decimal(18, 2),
507 ]
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::dialect::DialectInfo;
514 use razeri_plugin_contract::{
515 DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions, ProcedureRenderer,
516 ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer, TaskRenderer,
517 TextCast,
518 };
519
520 struct BrokenPlugin;
529
530 impl SqlRenderer for BrokenPlugin {
531 fn render_data_type(&self, _dt: &DataType) -> String {
532 String::new()
533 }
534 fn quote_identifier(&self, name: &str) -> String {
535 name.to_owned()
536 }
537 fn render_cast_type(&self, _dt: &DataType) -> String {
538 String::new()
539 }
540 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
541 name.to_owned()
542 }
543 fn render_current_timestamp(&self) -> String {
544 String::new()
545 }
546 fn dialect_name(&self) -> &'static str {
547 "brokendb"
548 }
549 fn supports_on_conflict(&self) -> bool {
552 true
553 }
554 }
555 impl ExpressionRenderer for BrokenPlugin {}
556 impl DdlRenderer for BrokenPlugin {}
557 impl DmlRenderer for BrokenPlugin {}
558 impl ProcedureRenderer for BrokenPlugin {}
559 impl FormulaRenderer for BrokenPlugin {}
560 impl TaskRenderer for BrokenPlugin {}
561 impl ReplicationRenderer for BrokenPlugin {}
562 impl StorageRenderer for BrokenPlugin {}
563 impl DialectCapabilities for BrokenPlugin {
564 fn supports_on_conflict(&self) -> bool {
565 false
566 }
567 }
568 impl PlatformDialect for BrokenPlugin {
569 fn info(&self) -> DialectInfo {
570 DialectInfo {
571 name: "brokendb",
572 display_name: "Broken DB",
573 dialect_family: DialectFamily::Custom(""),
574 family: "Broken",
575 is_cloud_native: false,
576 is_olap: false,
577 is_postgres_compatible: false,
578 }
579 }
580 }
581
582 struct HealthyPlugin;
591
592 impl SqlRenderer for HealthyPlugin {
593 fn render_data_type(&self, dt: &DataType) -> String {
594 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
595 }
596 fn quote_identifier(&self, name: &str) -> String {
597 format!("\"{name}\"")
598 }
599 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
600 schema.map_or_else(
601 || self.quote_identifier(name),
602 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
603 )
604 }
605 fn render_current_timestamp(&self) -> String {
606 "NOW()".to_owned()
607 }
608 fn dialect_name(&self) -> &'static str {
609 "healthydb"
610 }
611 fn sid_spelling(&self) -> SidSpelling {
612 SidSpelling {
613 platform: "healthydb",
614 hash: ("healthy_xxhash64(", ")"),
615 cast_type: TextCast::Direct("STRING"),
616 through_helper: false,
617 ..razeri_plugin_contract::ANSI_SID_SPELLING
618 }
619 }
620 fn procedure_form(&self) -> ProcedureForm {
621 ProcedureForm::Unsupported(
622 "Healthy DB has no stored procedures; write the load as a scheduled query \
623 instead.",
624 )
625 }
626 fn scalar_function_form(&self) -> ScalarFunctionForm {
627 ScalarFunctionForm::PostgreSql
628 }
629 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
630 Box::new(Self)
631 }
632 }
633 impl ExpressionRenderer for HealthyPlugin {}
634 impl DdlRenderer for HealthyPlugin {}
635 impl DmlRenderer for HealthyPlugin {}
636 impl ProcedureRenderer for HealthyPlugin {}
637 impl FormulaRenderer for HealthyPlugin {}
638 impl TaskRenderer for HealthyPlugin {}
639 impl ReplicationRenderer for HealthyPlugin {}
640 impl StorageRenderer for HealthyPlugin {}
641 impl DialectCapabilities for HealthyPlugin {
642 fn merge_shape(&self) -> MergeShape {
643 MergeShape::Merge(MergeRestrictions::none())
644 }
645 }
646 impl PlatformDialect for HealthyPlugin {
647 fn info(&self) -> DialectInfo {
648 DialectInfo {
649 name: "healthydb",
650 display_name: "Healthy DB",
651 dialect_family: DialectFamily::Custom("healthydb"),
652 family: "Healthy",
653 is_cloud_native: false,
654 is_olap: false,
655 is_postgres_compatible: false,
656 }
657 }
658 }
659
660 fn mentions(report: &Report, needle: &str) -> bool {
662 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
663 }
664
665 #[test]
669 fn the_kit_catches_a_deliberately_broken_plugin() {
670 let report = check(&BrokenPlugin);
671 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
672
673 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
674 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
675 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
676 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
677 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
678 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
679 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
680 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
681 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
682 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
683 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
684
685 assert!(
686 report.count(Severity::Blocking) >= 8,
687 "the broken plugin owes more than {} blocking fixes:\n{report}",
688 report.count(Severity::Blocking)
689 );
690 }
691
692 #[test]
696 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
697 let report = check(&HealthyPlugin);
698 let unregistered: Vec<&Finding> =
699 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
700 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
701 assert_eq!(
702 report.findings.len(),
703 1,
704 "a plugin that answered every question owes nothing else:\n{report}"
705 );
706 }
707
708 #[test]
712 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
713 let mut mute = Vec::new();
714 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
715 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
716 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
717
718 let silence = check(&BrokenPlugin);
719 assert!(
720 silence
721 .findings
722 .iter()
723 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
724 "a dialect that never answered is unanswered, not wrong:\n{silence}"
725 );
726 assert!(
727 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
728 "the fix line names the way to state a measured refusal:\n{silence}"
729 );
730 }
731
732 #[test]
736 fn the_report_reads_as_a_summary_then_a_fix_list() {
737 let text = check(&BrokenPlugin).to_string();
738 let first = text.lines().next().expect("a summary line");
739 assert!(first.starts_with("brokendb:"), "{first}");
740 assert!(first.contains("to fix"), "{first}");
741 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
742 }
743
744 #[test]
747 fn every_repository_obligation_names_a_command_and_a_reason() {
748 let obligations = repository_obligations();
749 assert!(obligations.len() >= 5);
750 for o in obligations {
751 assert!(!o.command.trim().is_empty(), "{o:?}");
752 assert!(o.what.split_whitespace().count() >= 5, "{o:?}");
753 }
754 }
755}