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