1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 BitwiseOperator, CatalogueReach, ColumnEvolution, Concat, ConstraintEnforcement, DataType,
47 DdlRenderer, DialectCapabilities, DialectFamily, EntityRef, ExpressionRenderer,
48 FormulaRenderer, HeldAs, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
49 RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
50 TableFunctionForm, TableRole, UNKNOWN_PLATFORM, WrittenObject,
51 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
52 storage_strategy::{
53 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
54 },
55};
56use std::fmt;
57use std::fmt::Write as _;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum Severity {
62 Blocking,
65 Unanswered,
68 Note,
70}
71
72impl fmt::Display for Severity {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(match self {
75 Self::Blocking => "must fix",
76 Self::Unanswered => "unanswered",
77 Self::Note => "worth a look",
78 })
79 }
80}
81
82#[derive(Debug, Clone)]
85pub struct Finding {
86 pub severity: Severity,
88 pub what: String,
90 pub fix: String,
92}
93
94#[derive(Debug, Clone)]
96pub struct Report {
97 pub dialect: String,
99 pub findings: Vec<Finding>,
101 pub taken_as_default: Vec<TakenAsDefault>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TakenAsDefault {
111 pub name: &'static str,
113 pub shows_in: &'static str,
115 pub answer: String,
117}
118
119impl Report {
120 #[must_use]
124 pub fn is_clean(&self) -> bool {
125 !self.findings.iter().any(|f| f.severity != Severity::Note)
126 }
127
128 #[must_use]
130 pub fn count(&self, severity: Severity) -> usize {
131 self.findings.iter().filter(|f| f.severity == severity).count()
132 }
133}
134
135impl Report {
136 #[must_use]
143 pub fn head(&self) -> String {
144 let mut out = String::new();
145 let name = &self.dialect;
146 let blocking = self.count(Severity::Blocking);
147 let summary = if self.findings.is_empty() {
148 "nothing to fix".to_owned()
149 } else {
150 format!(
151 "{blocking} to fix, {} unanswered, {} worth a look",
152 self.count(Severity::Unanswered),
153 self.count(Severity::Note)
154 )
155 };
156 let _ = writeln!(out, "{name}: {summary}, in the answers this dialect states.");
157 let zero = if blocking == 0 {
158 let phrase = if self.findings.is_empty() { "nothing to fix" } else { "0 to fix" };
159 format!(" \"{phrase}\" means no question is open, not that the SQL runs.")
160 } else {
161 String::new()
162 };
163 let _ = writeln!(
164 out,
165 "This report read the dialect's answers and ran nothing on an engine.{zero} Only the \
166 SQL Razeri renders for it, run on your engine, shows that it runs."
167 );
168 for finding in &self.findings {
169 let _ = writeln!(out);
170 let _ = writeln!(out, " [{}] {}", finding.severity, finding.what);
171 let _ = writeln!(out, " → {}", finding.fix);
172 }
173 out
174 }
175}
176
177impl fmt::Display for Report {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.write_str(&self.head())?;
180 writeln!(f)?;
181 if self.taken_as_default.is_empty() {
182 return writeln!(
183 f,
184 "This dialect answers every question Razeri asks here about the shape of a \
185 statement differently from the contract's default."
186 );
187 }
188 let count = self.taken_as_default.len();
189 writeln!(
190 f,
191 "Taken as the contract's default: this dialect answers {count} {} as the contract's \
192 default does, so Razeri writes the default's SQL, shown beside each. Confirm each \
193 on your engine.",
194 if count == 1 { "question" } else { "questions" }
195 )?;
196 for taken in &self.taken_as_default {
197 writeln!(f)?;
198 writeln!(f, " {} → {}", taken.name, taken.answer)?;
199 writeln!(f, " in {}", taken.shows_in)?;
200 }
201 Ok(())
202 }
203}
204
205#[derive(Clone, Copy)]
208enum Asked {
209 Sql(fn(&dyn SqlRenderer) -> String),
210 Expression(fn(&dyn ExpressionRenderer) -> String),
211 Ddl(fn(&dyn DdlRenderer) -> String),
212 Capabilities(fn(&dyn DialectCapabilities) -> String),
213}
214
215#[derive(Clone, Copy)]
218struct Seam {
219 name: &'static str,
220 shows_in: &'static str,
221 asked: Asked,
222}
223
224impl Seam {
225 fn answer_of(&self, dialect: &dyn PlatformDialect) -> String {
227 match self.asked {
228 Asked::Sql(answer) => answer(dialect),
229 Asked::Expression(answer) => answer(dialect),
230 Asked::Ddl(answer) => answer(dialect),
231 Asked::Capabilities(answer) => answer(dialect),
232 }
233 }
234
235 fn default_of(&self, defaults: &ContractDefaults<'_>) -> String {
237 match self.asked {
238 Asked::Sql(answer) => answer(defaults),
239 Asked::Expression(answer) => answer(defaults),
240 Asked::Ddl(answer) => answer(defaults),
241 Asked::Capabilities(answer) => answer(defaults),
242 }
243 }
244}
245
246fn statement_seams() -> &'static [Seam] {
254 SEAMS
255}
256
257const SCHEMA: &str = "schema.sql";
258const LOADS: &str = "loads.sql";
259const RECONCILE: &str = "reconcile.sql";
260const EVERY: &str = "every file";
261const fn seam(name: &'static str, shows_in: &'static str, asked: Asked) -> Seam {
262 Seam { name, shows_in, asked }
263}
264const SEAMS: &[Seam] = &[
265 seam(
266 "DdlRenderer::supports_if_not_exists",
267 SCHEMA,
268 Asked::Ddl(|d| said(d.supports_if_not_exists())),
269 ),
270 seam(
271 "DdlRenderer::supports_primary_key_constraint",
272 SCHEMA,
273 Asked::Ddl(|d| said(d.supports_primary_key_constraint())),
274 ),
275 seam(
276 "DdlRenderer::supports_foreign_key_constraint",
277 SCHEMA,
278 Asked::Ddl(|d| said(d.supports_foreign_key_constraint())),
279 ),
280 seam(
281 "DdlRenderer::render_create_view_prefix",
282 SCHEMA,
283 Asked::Ddl(|d| quoted(d.render_create_view_prefix())),
284 ),
285 seam(
286 "DdlRenderer::render_drop_view_prefix",
287 SCHEMA,
288 Asked::Ddl(|d| quoted(&d.render_drop_view_prefix(true))),
289 ),
290 seam(
291 "DdlRenderer::render_drop_table_prefix",
292 SCHEMA,
293 Asked::Ddl(|d| quoted(&d.render_drop_table_prefix(true))),
294 ),
295 seam(
296 "DdlRenderer::render_drop_function",
297 SCHEMA,
298 Asked::Ddl(|d| quoted(&d.render_drop_function("f", &["BIGINT"], true))),
299 ),
300 seam(
301 "DdlRenderer::supports_drop_function_arg_types",
302 SCHEMA,
303 Asked::Ddl(|d| said(d.supports_drop_function_arg_types())),
304 ),
305 seam(
306 "DdlRenderer::render_create_schema_statement",
307 SCHEMA,
308 Asked::Ddl(|d| quoted(&d.render_create_schema_statement("s", true))),
309 ),
310 seam(
311 "DialectCapabilities::supports_schemas",
312 SCHEMA,
313 Asked::Capabilities(|d| said(d.supports_schemas())),
314 ),
315 seam(
316 "SqlRenderer::uses_schema_in_qualified_names",
317 SCHEMA,
318 Asked::Sql(|d| said(d.uses_schema_in_qualified_names())),
319 ),
320 seam(
321 "DdlRenderer::supports_ddl_transactions",
322 SCHEMA,
323 Asked::Ddl(|d| said(d.supports_ddl_transactions())),
324 ),
325 seam(
326 "DdlRenderer::render_begin_transaction",
327 SCHEMA,
328 Asked::Ddl(|d| quoted(d.render_begin_transaction())),
329 ),
330 seam("DdlRenderer::render_commit", SCHEMA, Asked::Ddl(|d| quoted(d.render_commit()))),
331 seam(
332 "SqlRenderer::scalar_function_body_is_expression",
333 SCHEMA,
334 Asked::Sql(|d| said(d.scalar_function_body_is_expression())),
335 ),
336 seam(
337 "DialectCapabilities::max_identifier_length",
338 EVERY,
339 Asked::Capabilities(|d| said(d.max_identifier_length())),
340 ),
341 seam(
342 "DialectCapabilities::begin_transaction_keyword",
343 LOADS,
344 Asked::Capabilities(|d| quoted(d.begin_transaction_keyword())),
345 ),
346 seam(
347 "SqlRenderer::merge_source_requires_inlined_ctes",
348 LOADS,
349 Asked::Sql(|d| said(d.merge_source_requires_inlined_ctes())),
350 ),
351 seam(
352 "SqlRenderer::insert_accepts_leading_ctes",
353 LOADS,
354 Asked::Sql(|d| said(d.insert_accepts_leading_ctes())),
355 ),
356 seam(
357 "SqlRenderer::render_empty_table",
358 LOADS,
359 Asked::Sql(|d| quoted(&d.render_empty_table("t"))),
360 ),
361 seam(
362 "SqlRenderer::render_procedure_variable_reference",
363 LOADS,
364 Asked::Sql(|d| quoted(&d.render_procedure_variable_reference("v"))),
365 ),
366 seam(
367 "ExpressionRenderer::render_concat",
368 LOADS,
369 Asked::Expression(|d| quoted(&d.render_concat(&["a".into(), "b".into()]))),
370 ),
371 seam(
372 "SqlRenderer::statement_separator",
373 LOADS,
374 Asked::Sql(|d| quoted(d.statement_separator())),
375 ),
376 seam(
377 "SqlRenderer::render_one_row_source",
378 RECONCILE,
379 Asked::Sql(|d| match d.render_one_row_source() {
380 Some(source) => quoted(&source),
381 None => "none: a `SELECT` with no `FROM`".to_owned(),
382 }),
383 ),
384];
385
386fn said(answer: impl fmt::Display) -> String {
388 format!("`{answer}`")
389}
390
391fn quoted(sql: &str) -> String {
393 format!("`{}`", sql.replace('\n', "\\n"))
394}
395
396#[must_use]
407pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
408 let defaults = ContractDefaults { graded: dialect };
409 statement_seams()
410 .iter()
411 .filter_map(|seam| {
412 let default = seam.default_of(&defaults);
413 (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
414 name: seam.name,
415 shows_in: seam.shows_in,
416 answer: default,
417 })
418 })
419 .collect()
420}
421
422struct ContractDefaults<'a> {
426 graded: &'a dyn PlatformDialect,
427}
428
429impl SqlRenderer for ContractDefaults<'_> {
430 fn render_data_type(&self, dt: &DataType) -> String {
431 self.graded.render_data_type(dt)
432 }
433
434 fn quote_identifier(&self, name: &str) -> String {
435 self.graded.quote_identifier(name)
436 }
437
438 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
439 self.graded.qualify_name(schema, name)
440 }
441
442 fn render_current_timestamp(&self) -> String {
443 self.graded.render_current_timestamp()
444 }
445}
446impl ExpressionRenderer for ContractDefaults<'_> {}
447impl DdlRenderer for ContractDefaults<'_> {}
448impl DialectCapabilities for ContractDefaults<'_> {}
449
450#[must_use]
457pub fn check(dialect: &dyn PlatformDialect) -> Report {
458 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
459}
460
461#[must_use]
464pub fn check_with_registry(
465 dialect: &dyn PlatformDialect,
466 registry: &crate::dialect::PlatformRegistry,
467) -> Report {
468 let info = dialect.info();
469 let mut findings = Vec::new();
470
471 check_registration(&info, registry, &mut findings);
472 check_family(info.dialect_family, &mut findings);
473 check_required_rendering(dialect, &mut findings);
474 check_decisions(dialect, &mut findings);
475 check_formula_renderer(dialect, &mut findings);
476 check_evolution(dialect, &mut findings);
477 check_run_again(dialect, &mut findings);
478 check_transactions(dialect, &mut findings);
479 check_paired_facts(dialect, &mut findings);
480 check_schema_fold(dialect, &mut findings);
481 check_deploy_client(&info, &mut findings);
482
483 findings.sort_by_key(|f| f.severity);
484 Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
485}
486
487fn check_registration(
498 info: &crate::dialect::DialectInfo,
499 registry: &crate::dialect::PlatformRegistry,
500 findings: &mut Vec<Finding>,
501) {
502 let name = info.name;
503 if matches!(
504 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
505 Err(crate::ResolveError::UnknownDialect { .. })
506 ) {
507 findings.push(Finding {
508 severity: Severity::Blocking,
509 what: format!(
510 "the name `{name}` does not resolve — nothing in this program registered it"
511 ),
512 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
513 crate's `lib.rs`, where `register` calls \
514 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
515 dialect one the registry finds, whether razeri loads your library from a \
516 plugin directory or links your crate in."
517 .to_owned(),
518 });
519 return;
520 }
521 for spelling in info.spellings() {
522 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
523 Ok(found) if found.info().name == name => {}
524 Ok(found) => findings.push(Finding {
525 severity: Severity::Blocking,
526 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
527 fix: format!(
528 "the registry holds `{name}` under another key. Register it under its own \
529 name, or drop `{spelling}` from its aliases."
530 ),
531 }),
532 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
533 severity: Severity::Blocking,
534 what: format!(
535 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
536 none of them — Razeri refuses that spelling rather than guessing which one \
537 a design meant",
538 claimed_by.join(" and ")
539 ),
540 fix: format!(
541 "give this dialect a name no other platform answers to. A platform name is \
542 read off the loaded dialects' own declared spellings, so a spelling another \
543 platform already claims is never `{name}`'s."
544 ),
545 }),
546 Err(_) => findings.push(Finding {
547 severity: Severity::Blocking,
548 what: format!(
549 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
550 ),
551 fix: format!(
552 "the `{name}` in the registry is a different build of this dialect and does \
553 not declare `{spelling}`. Register the one you are grading."
554 ),
555 }),
556 }
557 }
558}
559
560fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
563 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
564 findings.push(Finding {
565 severity: Severity::Blocking,
566 what: "this dialect's family is a custom one with no name".to_owned(),
567 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
568 prints and what a reader looks up."
569 .to_owned(),
570 });
571 }
572}
573
574fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
576 let declarable = declarable_types();
577 let empty_column: Vec<String> = declarable
578 .iter()
579 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
580 .map(|dt| format!("{dt:?}"))
581 .collect();
582 if !empty_column.is_empty() {
583 findings.push(Finding {
584 severity: Severity::Blocking,
585 what: format!(
586 "`render_data_type` answers nothing for {} of the types a design can declare",
587 empty_column.len()
588 ),
589 fix: "every declarable type needs a column spelling on this engine, even if the \
590 spelling is a wider type that stores it. A blank answer becomes a column \
591 declaration with no type in it."
592 .to_owned(),
593 });
594 }
595 let empty_cast: Vec<String> = declarable
596 .iter()
597 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
598 .map(|dt| format!("{dt:?}"))
599 .collect();
600 if !empty_cast.is_empty() {
601 findings.push(Finding {
602 severity: Severity::Blocking,
603 what: format!(
604 "`render_cast_type` answers nothing for {} declarable types",
605 empty_cast.len()
606 ),
607 fix: "the generated load opens by casting every source column to its declared type, \
608 so a blank answer fails the load on its first statement."
609 .to_owned(),
610 });
611 }
612 let quoted = SqlRenderer::quote_identifier(dialect, "order");
613 if quoted == "order" {
614 findings.push(Finding {
615 severity: Severity::Blocking,
616 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
617 reaches this engine bare"
618 .to_owned(),
619 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
620 backticks, or brackets."
621 .to_owned(),
622 });
623 }
624 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
625 findings.push(Finding {
626 severity: Severity::Blocking,
627 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
628 a row with it"
629 .to_owned(),
630 fix: "return this engine's own expression for the current moment.".to_owned(),
631 });
632 }
633}
634
635fn check_encoded_file_relation(
647 dialect: &dyn PlatformDialect,
648 probe: &str,
649 findings: &mut Vec<Finding>,
650) {
651 let Some(relation) =
652 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
653 else {
654 return;
655 };
656 if !relation.contains(probe) {
657 findings.push(Finding {
658 severity: Severity::Blocking,
659 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
660 the path it was given"
661 .to_owned(),
662 fix: "put the literal you were handed where your engine's file relation takes a \
663 path. It arrives already quoted and escaped."
664 .to_owned(),
665 });
666 }
667 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
668 findings.push(Finding {
669 severity: Severity::Blocking,
670 what: "this dialect states how to read a file in a named encoding and states no way \
671 to read a file at all"
672 .to_owned(),
673 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
674 first question before the second — a source whose bytes are UTF-8 never \
675 reaches the encoding arm — so an engine that answers only the second has \
676 written a relation nothing will ask it for."
677 .to_owned(),
678 });
679 }
680}
681
682fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
684 let spelling = SqlRenderer::sid_spelling(dialect);
685 if spelling.platform == UNKNOWN_PLATFORM {
686 findings.push(Finding {
687 severity: Severity::Blocking,
688 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
689 an `xxhash64` its engine very likely does not have"
690 .to_owned(),
691 fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
692 replacement and a Unicode literal, how it joins strings, how it casts to text, \
693 and how it spells a 64-bit hash. If it has no portable hash, name an artifact your prerequisites install \
694 — never the nearest-looking built-in, which keys a warehouse to a number \
695 nothing else can reproduce."
696 .to_owned(),
697 });
698 }
699
700 let join = match spelling.concat {
704 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
705 Concat::Plus => "+",
706 };
707 if join.trim().is_empty() {
708 findings.push(Finding {
709 severity: Severity::Blocking,
710 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
711 with a function or operator that has no name"
712 .to_owned(),
713 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
714 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
715 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
716 operator and no function."
717 .to_owned(),
718 });
719 }
720
721 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
726 && supplied.body.trim().is_empty()
727 {
728 findings.push(Finding {
729 severity: Severity::Blocking,
730 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
731 and then supplies an empty one"
732 .to_owned(),
733 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
734 one argument, or answer `None` and let Razeri compose the derivation from \
735 your `sid_spelling`. An empty body deploys without error and keys every row \
736 to nothing."
737 .to_owned(),
738 });
739 }
740
741 let probe = "'/razeri/probe.csv'";
744 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
745 && !relation.contains(probe)
746 {
747 findings.push(Finding {
748 severity: Severity::Blocking,
749 what: "`delimited_file_relation` returns a relation that does not carry the path it \
750 was given"
751 .to_owned(),
752 fix: "put the literal you were handed where your engine's file relation takes a \
753 path. It arrives already quoted and escaped. A relation that drops it reads \
754 one file for every entity the design binds, and the generated fill loads the \
755 wrong rows without failing."
756 .to_owned(),
757 });
758 }
759
760 check_encoded_file_relation(dialect, probe, findings);
761 check_routine_forms(dialect, findings);
762 check_slice_replacement(dialect, findings);
763
764 if let ConstraintEnforcement::DeclaredOnly(declared) =
767 DdlRenderer::constraint_enforcement(dialect)
768 && declared.note.split_whitespace().count() < 5
769 {
770 findings.push(Finding {
771 severity: Severity::Blocking,
772 what: "this dialect says its engine accepts constraints it does not enforce, and \
773 the note explaining that says almost nothing"
774 .to_owned(),
775 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
776 guarantee. Say what this engine really does with the declaration, and where \
777 the guarantee has to come from instead."
778 .to_owned(),
779 });
780 }
781
782 match DialectCapabilities::merge_shape(dialect) {
788 MergeShape::None => findings.push(Finding {
789 severity: Severity::Unanswered,
790 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
791 load for it"
792 .to_owned(),
793 fix: "if this engine has a MERGE or an upsert statement, say which in \
794 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
795 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
796 operator can act on, rather than a silence."
797 .to_owned(),
798 }),
799 MergeShape::Unsupported(orientation) => {
800 check_refusal_copy("the upsert refusal", orientation, findings);
801 }
802 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
803 _ => {}
804 }
805}
806
807fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
822 const VALUE: &str = "$razeri_slice_probe";
823 let plan = StoragePlan {
824 partition: PartitionStrategy::RangeByTimeDimension {
825 field: "razeri_probe_day".to_owned(),
826 granularity: PartitionGranularity::Day,
827 retention: None,
828 look_ahead: None,
829 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
830 },
831 compression: CompressionStrategy::None,
832 index: IndexStrategy::None,
833 };
834 let day = DataType::date();
835 let columns = ["razeri_probe_day"];
836 let entity = EntityRef {
837 schema: Some("razeri_probe"),
838 name: "razeri_probe_table",
839 role: TableRole::Written,
840 columns: &columns,
841 partition_column_type: Some(&day),
842 };
843 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
844 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
845 else {
846 return;
847 };
848
849 let carries = match &answer {
850 SliceReplacement::Emptied(statements) => {
851 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
852 }
853 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
854 };
855 if carries {
861 return;
862 }
863 findings.push(Finding {
864 severity: Severity::Blocking,
865 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
866 was handed"
867 .to_owned(),
868 fix: "put the value you were given where your statement names the slice, and build any \
869 partition NAME from it the same way your extend procedure built it. A replacement \
870 that drops the value clears a slice this call was not given — or every slice — \
871 and the load reports success over what it emptied."
872 .to_owned(),
873 });
874}
875
876fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
893 let answers = DialectCapabilities::evolution_answers(dialect);
894 let boolean = DataType::boolean();
895 let long = DataType::varchar(40);
896
897 if DdlRenderer::render_column_evolution(
898 dialect,
899 &ColumnEvolution::AddColumn {
900 schema: Some("s"),
901 table: "t",
902 column: "c",
903 data_type: &boolean,
904 },
905 )
906 .is_none()
907 {
908 findings.push(Finding {
909 severity: Severity::Blocking,
910 what: "this dialect writes no statement for a column that is new, which is the \
911 commonest change a deployed warehouse ever sees"
912 .to_owned(),
913 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
914 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
915 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
916 engine that genuinely has no way to add a column is worth saying out loud in \
917 the crate's own documentation."
918 .to_owned(),
919 });
920 }
921
922 if answers.parameterized_string_widening_is_lossless
923 && DdlRenderer::render_column_evolution(
924 dialect,
925 &ColumnEvolution::WidenColumn {
926 schema: Some("s"),
927 table: "t",
928 column: "c",
929 data_type: &long,
930 },
931 )
932 .is_none()
933 {
934 findings.push(Finding {
935 severity: Severity::Note,
936 what: "this dialect says its engine widens a string's declared length in place, and \
937 writes no statement for the change"
938 .to_owned(),
939 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
940 engine's spelling of it. Without it Razeri tells an operator the widening \
941 costs nothing and then hands them nothing to run, which reads as Razeri not \
942 having finished rather than as a statement they own."
943 .to_owned(),
944 });
945 }
946
947 if answers.nested_field_addition_is_lossless
948 && DdlRenderer::render_column_evolution(
949 dialect,
950 &ColumnEvolution::AddNestedField {
951 schema: Some("s"),
952 table: "t",
953 column: "c",
954 field: "f",
955 data_type: &long,
956 },
957 )
958 .is_none()
959 {
960 findings.push(Finding {
961 severity: Severity::Note,
962 what: "this dialect says its engine keeps every value when a record gains a field, \
963 and writes no statement for the change"
964 .to_owned(),
965 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
966 engines name the new field; some restate the whole column's type, which is \
967 why the change carries both the field's path and the column's type with the \
968 field in it."
969 .to_owned(),
970 });
971 }
972}
973
974fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
983 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
984 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
985 let relation = format!("{schema}.{name}");
986 let table = WrittenObject::new(Some(&schema), &name, &relation);
987 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
988 let mut lost: Vec<&str> = Vec::new();
989
990 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
991 Some(drop) if drop.text().contains(&column) => {}
992 _ => lost.push("render_drop_column"),
993 }
994 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
995 && !guarded.text().contains(&column)
996 {
997 lost.push("render_drop_column (only where it stands)");
998 }
999 for only_where_it_stands in [false, true] {
1000 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1001 && !drop.text().contains(&name)
1002 {
1003 lost.push("render_drop_view");
1004 }
1005 }
1006 let create = format!("CREATE TABLE {relation} (a INTEGER)");
1007 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1008 && !guarded.text().contains(&create)
1009 {
1010 lost.push("render_create_table_if_absent");
1011 }
1012 lost.dedup();
1013 if !lost.is_empty() {
1014 findings.push(Finding {
1015 severity: Severity::Blocking,
1016 what: format!(
1017 "{} returns a statement that does not carry the object it was handed",
1018 lost.join(", ")
1019 ),
1020 fix: "put the names you were given where your statement names the object, and keep \
1021 a create you guard whole inside the guard. A drop that loses its column drops \
1022 another one or none, and a guard that loses its statement makes nothing — \
1023 both under a run that reports success."
1024 .to_owned(),
1025 });
1026 }
1027
1028 let value = "razeri_probe_value";
1031 let loses_its_value = declarable_types().iter().any(|data_type| {
1032 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1033 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1034 .is_some_and(|compared| !compared.contains(value))
1035 })
1036 });
1037 if loses_its_value {
1038 findings.push(Finding {
1039 severity: Severity::Blocking,
1040 what: "`render_comparable` answers an expression that does not carry the value it \
1041 stands for"
1042 .to_owned(),
1043 fix: "answer the value itself where your engine compares the type directly, an \
1044 expression over it where it compares one only that way, and `None` where it \
1045 cannot compare one at all — then Razeri refuses such a column by name."
1046 .to_owned(),
1047 });
1048 }
1049
1050 if let Some(limits) = DialectCapabilities::text_limits(dialect)
1053 && (limits.longest_varchar == Some(0)
1054 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1055 {
1056 findings.push(Finding {
1057 severity: Severity::Blocking,
1058 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1059 unbounded text cannot be"
1060 .to_owned(),
1061 fix: "state the longest varchar(n) your engine creates — one more is refused at \
1062 CREATE TABLE — and name what it refuses a text column as, or leave it None."
1063 .to_owned(),
1064 });
1065 }
1066
1067 match DialectCapabilities::catalogue_reach(dialect) {
1068 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1069 check_refusal_copy("the reason the schema check is not asked", reason, findings);
1070 }
1071 _ => {}
1072 }
1073}
1074
1075fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1079 if SqlRenderer::render_procedure_log(
1082 dialect,
1083 razeri_plugin_contract::ProcedureLogLevel::Info,
1084 "'razeri'",
1085 )
1086 .is_some_and(|line| line.trim().is_empty())
1087 {
1088 findings.push(Finding {
1089 severity: Severity::Blocking,
1090 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1091 fix: "answer `None` where your engine has no statement that writes a message: the \
1092 line is then left out of the routine."
1093 .to_owned(),
1094 });
1095 }
1096 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1097 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1098 {
1099 findings.push(Finding {
1100 severity: Severity::Blocking,
1101 what: "this dialect says its begin statement leaves the commit to the client, and \
1102 that its engine has no transaction spanning statements"
1103 .to_owned(),
1104 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1105 run in one transaction. Where there is none, the bundle already tells the \
1106 operator the boundary is theirs."
1107 .to_owned(),
1108 });
1109 }
1110}
1111
1112fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1123 if restrictions.slice_pruning_needs_literal {
1124 findings.push(Finding {
1125 severity: Severity::Note,
1126 what: "this dialect says its planner reads one slice of a partitioned table only \
1127 from a literal, so a load scoped to one day cannot simply bind the day"
1128 .to_owned(),
1129 fix: "Razeri will put the day into the statement text through this engine's \
1130 dynamic-execution form rather than binding it to the load procedure's \
1131 parameter. Confirm that is what your engine wants: the statement is composed \
1132 once per call and its text differs per day, which some planners cache and \
1133 some do not. If a bound parameter does prune here, drop the restriction and \
1134 Razeri binds it like everywhere else."
1135 .to_owned(),
1136 });
1137 }
1138}
1139
1140fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1143 let bitwise = BitwiseOperator::ALL.map(|operator| {
1147 (
1148 format!("render_bitwise_operator({operator:?})"),
1149 FormulaRenderer::render_bitwise_operator(dialect, operator),
1150 )
1151 });
1152 let blank: Vec<String> = [
1153 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1154 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1155 (
1156 "render_not_ilike_operator".to_owned(),
1157 FormulaRenderer::render_not_ilike_operator(dialect),
1158 ),
1159 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1160 ]
1161 .into_iter()
1162 .chain(bitwise)
1163 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1164 .map(|(method, _)| method)
1165 .collect();
1166 if !blank.is_empty() {
1167 findings.push(Finding {
1168 severity: Severity::Blocking,
1169 what: format!("{} answers an operator with no text in it", blank.join(", ")),
1170 fix: "answer `None` where your engine has no such operator: a formula that needs it \
1171 is then refused by name, and the reader is told to write the function your \
1172 engine has. An empty answer writes the two operands side by side."
1173 .to_owned(),
1174 });
1175 }
1176
1177 let formula = SqlRenderer::to_formula_renderer(dialect);
1178 let dialect_name = SqlRenderer::dialect_name(dialect);
1179 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1180 findings.push(Finding {
1181 severity: Severity::Blocking,
1182 what: "`to_formula_renderer` was not overridden, so every formula in every design \
1183 renders through Razeri's ANSI defaults instead of this dialect"
1184 .to_owned(),
1185 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1186 formula this engine sees is silently somebody else's."
1187 .to_owned(),
1188 });
1189 }
1190}
1191
1192fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1198 match SqlRenderer::procedure_form(dialect) {
1199 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1203 ProcedureForm::Agnostic => findings.push(Finding {
1204 severity: Severity::Unanswered,
1205 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1206 annotated form — which no engine parses"
1207 .to_owned(),
1208 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1209 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
1210 offers instead."
1211 .to_owned(),
1212 }),
1213 ProcedureForm::Unsupported(orientation) => {
1214 check_refusal_copy("the stored-procedure refusal", orientation, findings);
1215 if DialectCapabilities::supports_stored_procedures(dialect) {
1216 findings.push(Finding {
1217 severity: Severity::Blocking,
1218 what: "this dialect says its engine has stored procedures, and its \
1219 procedure form refuses to write one"
1220 .to_owned(),
1221 fix: "decide which is true. If the engine has none, leave \
1222 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1223 has them, answer `procedure_form` with the grammar it speaks."
1224 .to_owned(),
1225 });
1226 }
1227 }
1228 ProcedureForm::Custom(grammar) => check_custom_grammar(
1229 CustomGrammar {
1230 kind: RoutineKind::Procedure,
1231 grammar,
1232 form_method: "procedure_form",
1233 render_method: "render_procedure",
1234 noun: "procedures",
1235 },
1236 |spec| SqlRenderer::render_procedure(dialect, spec),
1237 findings,
1238 ),
1239 _ => {}
1240 }
1241 check_procedures_are_used(dialect, findings);
1242
1243 match SqlRenderer::scalar_function_form(dialect) {
1244 ScalarFunctionForm::Unsupported(orientation) => {
1245 check_refusal_copy("the scalar-function refusal", orientation, findings);
1246 }
1247 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1248 CustomGrammar {
1249 kind: RoutineKind::ScalarFunction,
1250 grammar,
1251 form_method: "scalar_function_form",
1252 render_method: "render_scalar_function",
1253 noun: "scalar functions",
1254 },
1255 |spec| SqlRenderer::render_scalar_function(dialect, spec),
1256 findings,
1257 ),
1258 _ => {}
1259 }
1260
1261 match SqlRenderer::table_function_form(dialect) {
1262 TableFunctionForm::Unsupported(orientation) => {
1263 check_refusal_copy("the table-function refusal", orientation, findings);
1264 }
1265 TableFunctionForm::Custom(grammar) => check_custom_grammar(
1266 CustomGrammar {
1267 kind: RoutineKind::TableFunction,
1268 grammar,
1269 form_method: "table_function_form",
1270 render_method: "render_table_function",
1271 noun: "table functions",
1272 },
1273 |spec| SqlRenderer::render_table_function(dialect, spec),
1274 findings,
1275 ),
1276 _ => {}
1277 }
1278
1279 check_parameter_type_spelling(dialect, findings);
1280}
1281
1282fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1294 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1295 return;
1296 };
1297 if DialectCapabilities::supports_stored_procedures(dialect) {
1298 return;
1299 }
1300 findings.push(Finding {
1301 severity: Severity::Blocking,
1302 what: format!(
1303 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1304 load is ever written as one"
1305 ),
1306 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1307 it before it writes a load as a procedure, and its default is `false`."
1308 .to_owned(),
1309 });
1310}
1311
1312fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1327 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1328 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1329 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1330 if !spells_its_own {
1331 return;
1332 }
1333 let qualified = DataType::varchar(100);
1336 let column = SqlRenderer::render_data_type(dialect, &qualified);
1337 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1338 if column == parameter && column.contains('(') {
1339 findings.push(Finding {
1340 severity: Severity::Note,
1341 what: format!(
1342 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1343 column of that type"
1344 ),
1345 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1346 a length or a precision in a parameter list — Oracle does, and answers \
1347 PLS-00103 at the opening parenthesis — override \
1348 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1349 declarations keep the length either way."
1350 .to_owned(),
1351 });
1352 }
1353}
1354
1355#[derive(Clone, Copy)]
1357struct CustomGrammar {
1358 kind: RoutineKind,
1360 grammar: &'static str,
1362 form_method: &'static str,
1364 render_method: &'static str,
1366 noun: &'static str,
1368}
1369
1370fn check_custom_grammar(
1387 claim: CustomGrammar,
1388 render: impl Fn(&RoutineSpec) -> Option<String>,
1389 findings: &mut Vec<Finding>,
1390) {
1391 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1392 if grammar.trim().is_empty() {
1395 findings.push(Finding {
1396 severity: Severity::Blocking,
1397 what: format!("`{form_method}` says Custom, and names no grammar"),
1398 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
1399 of a report, and of the comment Razeri writes where a routine could not be \
1400 rendered, is told this engine speaks."
1401 .to_owned(),
1402 });
1403 }
1404
1405 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1406 probe.or_replace = true;
1407 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1408 findings.push(Finding {
1409 severity: Severity::Blocking,
1410 what: format!(
1411 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1412 itself — and `{render_method}` returns nothing"
1413 ),
1414 fix: format!(
1415 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1416 composed — name, parameters with their types already in your spelling, body, \
1417 comment — and returns the whole CREATE statement your engine parses. Razeri \
1418 emits what you return and writes nothing of its own: a near-neighbour grammar \
1419 fails on deploy, and a refusal would say this engine has no {noun} when it \
1420 has them in a grammar of its own."
1421 ),
1422 });
1423 }
1424}
1425
1426fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1428 if copy.trim().is_empty() {
1429 findings.push(Finding {
1430 severity: Severity::Blocking,
1431 what: format!("{subject} has no words in it"),
1432 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1433 next thing to try, and it has to be about their engine."
1434 .to_owned(),
1435 });
1436 return;
1437 }
1438 if copy.split_whitespace().count() < 5 {
1439 findings.push(Finding {
1440 severity: Severity::Note,
1441 what: format!("{subject} is very short"),
1442 fix: "a refusal earns its place by naming the alternative — what this engine has \
1443 instead of the thing it cannot do."
1444 .to_owned(),
1445 });
1446 }
1447}
1448
1449fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1451 let caps: &dyn DialectCapabilities = dialect;
1452 let ren: &dyn SqlRenderer = dialect;
1453 let ddl: &dyn DdlRenderer = dialect;
1454 let mut disagreements: Vec<&'static str> = Vec::new();
1455
1456 macro_rules! pair {
1457 ($m:ident, $renderer_value:expr) => {
1458 if caps.$m() != $renderer_value {
1459 disagreements.push(stringify!($m));
1460 }
1461 };
1462 }
1463 pair!(supports_on_conflict, ren.supports_on_conflict());
1464 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1465 pair!(supports_update_from, ren.supports_update_from());
1466 pair!(supports_named_windows, ren.supports_named_windows());
1467 pair!(supports_compound_assignment, ren.supports_compound_assignment());
1468 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1469 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1470 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1471 pair!(uses_alter_table_update, ren.uses_alter_table_update());
1472 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1473 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1474 pair!(supports_sync_drop, ddl.supports_sync_drop());
1475 pair!(supports_force_drop, ddl.supports_force_drop());
1476
1477 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1486 disagreements.push("supports_table_functions");
1487 }
1488 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1491 disagreements.push("supports_stored_functions");
1492 }
1493
1494 if !disagreements.is_empty() {
1495 findings.push(Finding {
1496 severity: Severity::Blocking,
1497 what: format!(
1498 "this dialect declares {} {} two ways and the two disagree: {}",
1499 disagreements.len(),
1500 if disagreements.len() == 1 { "fact" } else { "facts" },
1501 disagreements.join(", ")
1502 ),
1503 fix: "each of these is declared once on the capability trait and once in the \
1504 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1505 and move the other one to match."
1506 .to_owned(),
1507 });
1508 }
1509}
1510
1511fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1520 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1521 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1522 return;
1523 };
1524 if DialectCapabilities::supports_schemas(dialect) {
1525 findings.push(Finding {
1526 severity: Severity::Blocking,
1527 what: "this dialect folds a schema into an object's name on an engine it says has \
1528 schemas"
1529 .to_owned(),
1530 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1531 schemas, and only there is it asked; or answer \
1532 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1533 .to_owned(),
1534 });
1535 return;
1536 }
1537 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1538 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1539 if qualified != expected {
1540 findings.push(Finding {
1541 severity: Severity::Blocking,
1542 what: format!(
1543 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1544 the same object `{qualified}`"
1545 ),
1546 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1547 schema. Razeri names some objects through it and the rest through the fold, \
1548 so a difference creates a table under one name and reads it under another."
1549 .to_owned(),
1550 });
1551 }
1552}
1553
1554fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1561 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1562 return;
1563 };
1564 if client.command.trim().is_empty() {
1565 findings.push(Finding {
1566 severity: Severity::Blocking,
1567 what: "this dialect describes a deploy client with no command".to_owned(),
1568 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1569 });
1570 }
1571 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1572 .into_iter()
1573 .filter_map(|(field, args)| {
1574 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1575 })
1576 .collect();
1577 if !unnamed.is_empty() {
1578 findings.push(Finding {
1579 severity: Severity::Blocking,
1580 what: format!(
1581 "the deploy client's {} never names the file to run",
1582 unnamed.join(" and ")
1583 ),
1584 fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1585 inside an argument such as `--file={file}`. The script writes the file there \
1586 and nowhere else."
1587 .to_owned(),
1588 });
1589 }
1590 if let Some(terminator) = client.block_terminator
1591 && [terminator.open, terminator.end, terminator.close]
1592 .iter()
1593 .any(|part| part.trim().is_empty())
1594 {
1595 findings.push(Finding {
1596 severity: Severity::Blocking,
1597 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1598 fix: "name the line before a routine, what ends it, and the line after it — \
1599 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1600 the routine open, and the next statement is read into it."
1601 .to_owned(),
1602 });
1603 }
1604}
1605
1606fn declarable_types() -> Vec<DataType> {
1614 vec![
1615 DataType::tinyint(),
1616 DataType::smallint(),
1617 DataType::int(),
1618 DataType::bigint(),
1619 DataType::double(),
1620 DataType::boolean(),
1621 DataType::text(),
1622 DataType::varchar(200),
1623 DataType::uuid(),
1624 DataType::json(),
1625 DataType::date(),
1626 DataType::timestamp(),
1627 DataType::timestamp_tz(),
1628 DataType::decimal(18, 2),
1629 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1630 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1631 DataType::Struct(StructDataType::new(vec![
1632 StructField::new("street", DataType::varchar(200)),
1633 StructField::new("zip", DataType::varchar(10)),
1634 ])),
1635 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1636 StructField::new("key", DataType::varchar(100)),
1637 StructField::new("value", DataType::varchar(1000)),
1638 ])))),
1639 ]
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644 use super::*;
1645 use crate::dialect::DialectInfo;
1646 use razeri_plugin_contract::{
1647 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1648 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1649 TaskRenderer, TextCast,
1650 };
1651 use std::marker::PhantomData;
1652
1653 struct BrokenPlugin;
1661
1662 impl SqlRenderer for BrokenPlugin {
1663 fn render_data_type(&self, _dt: &DataType) -> String {
1664 String::new()
1665 }
1666 fn quote_identifier(&self, name: &str) -> String {
1667 name.to_owned()
1668 }
1669 fn render_cast_type(&self, _dt: &DataType) -> String {
1670 String::new()
1671 }
1672 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1673 name.to_owned()
1674 }
1675 fn render_current_timestamp(&self) -> String {
1676 String::new()
1677 }
1678 fn dialect_name(&self) -> &'static str {
1679 "brokendb"
1680 }
1681 fn supports_on_conflict(&self) -> bool {
1684 true
1685 }
1686 }
1687 impl ExpressionRenderer for BrokenPlugin {}
1688 impl DdlRenderer for BrokenPlugin {}
1689 impl DmlRenderer for BrokenPlugin {}
1690 impl ProcedureRenderer for BrokenPlugin {}
1691 impl FormulaRenderer for BrokenPlugin {}
1692 impl TaskRenderer for BrokenPlugin {}
1693 impl ReplicationRenderer for BrokenPlugin {}
1694 impl StorageRenderer for BrokenPlugin {}
1695 impl DialectCapabilities for BrokenPlugin {
1696 fn supports_on_conflict(&self) -> bool {
1697 false
1698 }
1699 }
1700 impl PlatformDialect for BrokenPlugin {
1701 fn info(&self) -> DialectInfo {
1702 DialectInfo {
1703 name: "brokendb",
1704 display_name: "Broken DB",
1705 aliases: &[],
1706 dialect_family: DialectFamily::Custom(""),
1707 family: "Broken",
1708 is_cloud_native: false,
1709 is_olap: false,
1710 is_postgres_compatible: false,
1711 versions: &[],
1712 deploy_script: None,
1713 }
1714 }
1715 }
1716
1717 trait FixtureName: Send + Sync + 'static {
1726 const NAME: &'static str;
1728 const CONCAT: Concat = Concat::Function("CONCAT");
1732 const POWER: Option<&'static str> = Some("^");
1735 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1737 const DROP_LOSES_ITS_COLUMN: bool = false;
1739 const COMPARES_SOMETHING_ELSE: bool = false;
1741 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1744 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1746 }
1747
1748 struct Unlinked;
1751 impl FixtureName for Unlinked {
1752 const NAME: &'static str = "healthydb";
1753 }
1754
1755 struct Outside;
1758 impl FixtureName for Outside {
1759 const NAME: &'static str = "outsidedb";
1760 }
1761
1762 struct BinaryConcat;
1767 impl FixtureName for BinaryConcat {
1768 const NAME: &'static str = "binaryconcatdb";
1769 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1770 }
1771
1772 struct OperatorConcat;
1776 impl FixtureName for OperatorConcat {
1777 const NAME: &'static str = "operatorconcatdb";
1778 const CONCAT: Concat = Concat::Operator("||");
1779 }
1780
1781 struct NamelessConcat;
1783 impl FixtureName for NamelessConcat {
1784 const NAME: &'static str = "namelessconcatdb";
1785 const CONCAT: Concat = Concat::Operator(" ");
1786 }
1787
1788 struct NoPowerOperator;
1791 impl FixtureName for NoPowerOperator {
1792 const NAME: &'static str = "nopowerdb";
1793 const POWER: Option<&'static str> = None;
1794 }
1795
1796 struct BlankPowerOperator;
1798 impl FixtureName for BlankPowerOperator {
1799 const NAME: &'static str = "blankpowerdb";
1800 const POWER: Option<&'static str> = Some(" ");
1801 }
1802
1803 struct ComparesSomethingElse;
1805 impl FixtureName for ComparesSomethingElse {
1806 const NAME: &'static str = "comparesotherdb";
1807 const COMPARES_SOMETHING_ELSE: bool = true;
1808 }
1809
1810 struct DropLosesItsColumn;
1812 impl FixtureName for DropLosesItsColumn {
1813 const NAME: &'static str = "droplosesdb";
1814 const DROP_LOSES_ITS_COLUMN: bool = true;
1815 }
1816
1817 struct BeginWithoutTransactions;
1820 impl FixtureName for BeginWithoutTransactions {
1821 const NAME: &'static str = "beginwithouttxdb";
1822 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1823 }
1824
1825 struct NoShiftOperator;
1827 impl FixtureName for NoShiftOperator {
1828 const NAME: &'static str = "noshiftdb";
1829 const SHIFT_LEFT: Option<&'static str> = None;
1830 }
1831
1832 struct BlankShiftOperator;
1834 impl FixtureName for BlankShiftOperator {
1835 const NAME: &'static str = "blankshiftdb";
1836 const SHIFT_LEFT: Option<&'static str> = Some("");
1837 }
1838
1839 struct DescribedClient;
1841 impl FixtureName for DescribedClient {
1842 const NAME: &'static str = "describedclientdb";
1843 const DEPLOY: Option<crate::dialect::DeployScript> =
1844 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1845 command: "sqlite3",
1846 run_file: &["-bail", "{target}", ".read {file}"],
1847 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1848 block_terminator: None,
1849 }));
1850 }
1851
1852 struct FilelessClient;
1854 impl FixtureName for FilelessClient {
1855 const NAME: &'static str = "filelessclientdb";
1856 const DEPLOY: Option<crate::dialect::DeployScript> =
1857 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1858 command: "sqlite3",
1859 run_file: &["-bail", "{target}"],
1860 read_rows: None,
1861 block_terminator: None,
1862 }));
1863 }
1864
1865 struct BlankBlockEnd;
1867 impl FixtureName for BlankBlockEnd {
1868 const NAME: &'static str = "blankblockenddb";
1869 const DEPLOY: Option<crate::dialect::DeployScript> =
1870 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1871 command: "isql",
1872 run_file: &["-bail", "-input", "{file}", "{target}"],
1873 read_rows: None,
1874 block_terminator: Some(crate::dialect::BlockTerminator {
1875 open: "SET TERM ^ ;",
1876 end: " ",
1877 close: "SET TERM ; ^",
1878 }),
1879 }));
1880 }
1881
1882 struct Answered<N: FixtureName>(PhantomData<N>);
1883
1884 impl<N: FixtureName> Answered<N> {
1885 const fn new() -> Self {
1886 Self(PhantomData)
1887 }
1888 }
1889
1890 type HealthyPlugin = Answered<Unlinked>;
1891 type OutsidePlugin = Answered<Outside>;
1892
1893 inventory::submit! {
1894 crate::builder::DialectRegistration::new(
1895 Outside::NAME,
1896 || std::sync::Arc::new(OutsidePlugin::new()),
1897 )
1898 }
1899
1900 impl<N: FixtureName> SqlRenderer for Answered<N> {
1901 fn render_data_type(&self, dt: &DataType) -> String {
1902 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1903 }
1904 fn quote_identifier(&self, name: &str) -> String {
1905 format!("\"{name}\"")
1906 }
1907 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1908 schema.map_or_else(
1909 || self.quote_identifier(name),
1910 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1911 )
1912 }
1913 fn render_current_timestamp(&self) -> String {
1914 "NOW()".to_owned()
1915 }
1916 fn dialect_name(&self) -> &'static str {
1917 N::NAME
1918 }
1919 fn sid_spelling(&self) -> SidSpelling {
1920 SidSpelling {
1921 platform: N::NAME,
1922 hash: ("answered_xxhash64(", ")"),
1923 cast_type: TextCast::Direct("STRING"),
1924 concat: N::CONCAT,
1925 through_helper: false,
1926 ..razeri_plugin_contract::ANSI_SID_SPELLING
1927 }
1928 }
1929 fn procedure_form(&self) -> ProcedureForm {
1930 ProcedureForm::Unsupported(
1931 "This engine has no stored procedures; write the load as a scheduled query \
1932 instead.",
1933 )
1934 }
1935 fn scalar_function_form(&self) -> ScalarFunctionForm {
1936 ScalarFunctionForm::PostgreSql
1937 }
1938 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1939 Box::new(Self::new())
1940 }
1941 }
1942 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
1943 fn render_comparable(
1944 &self,
1945 value: &str,
1946 _data_type: &DataType,
1947 _held: HeldAs,
1948 ) -> Option<String> {
1949 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
1950 }
1951 }
1952 impl<N: FixtureName> DdlRenderer for Answered<N> {
1953 fn render_drop_column(
1954 &self,
1955 table: WrittenObject<'_>,
1956 column: &str,
1957 only_where_it_stands: bool,
1958 ) -> Option<razeri_plugin_contract::Written> {
1959 if N::DROP_LOSES_ITS_COLUMN {
1960 return Some(razeri_plugin_contract::Written::Statement(format!(
1961 "ALTER TABLE {} DROP COLUMN c",
1962 table.relation
1963 )));
1964 }
1965 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
1966 Some(razeri_plugin_contract::Written::Statement(format!(
1967 "ALTER TABLE {} DROP COLUMN{guard} {column}",
1968 table.relation
1969 )))
1970 }
1971 }
1972 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1973 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1974 impl<N: FixtureName> FormulaRenderer for Answered<N> {
1975 fn render_power_operator(&self) -> Option<String> {
1976 N::POWER.map(str::to_owned)
1977 }
1978 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
1979 match operator {
1980 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
1981 other => Some(other.standard_spelling().to_owned()),
1982 }
1983 }
1984 }
1985 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1986 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1987 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1988 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1989 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
1990 N::BEGIN_WITHOUT_TRANSACTIONS
1991 }
1992 fn supports_multi_statement_transactions(&self) -> bool {
1993 !N::BEGIN_WITHOUT_TRANSACTIONS
1994 }
1995 fn merge_shape(&self) -> MergeShape {
1996 MergeShape::Merge(MergeRestrictions::none())
1997 }
1998 }
1999 impl<N: FixtureName> PlatformDialect for Answered<N> {
2000 fn info(&self) -> DialectInfo {
2001 DialectInfo {
2002 name: N::NAME,
2003 display_name: "An Answered Engine",
2004 aliases: &[],
2007 dialect_family: DialectFamily::Custom(N::NAME),
2008 family: "Answered",
2009 is_cloud_native: false,
2010 is_olap: false,
2011 is_postgres_compatible: false,
2012 versions: &[],
2013 deploy_script: N::DEPLOY,
2014 }
2015 }
2016 }
2017
2018 struct Twin;
2024
2025 impl SqlRenderer for Twin {
2026 fn render_data_type(&self, dt: &DataType) -> String {
2027 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2028 }
2029 fn quote_identifier(&self, name: &str) -> String {
2030 format!("\"{name}\"")
2031 }
2032 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2033 self.quote_identifier(name)
2034 }
2035 fn render_current_timestamp(&self) -> String {
2036 "NOW()".to_owned()
2037 }
2038 }
2039 impl ExpressionRenderer for Twin {}
2040 impl DdlRenderer for Twin {}
2041 impl DmlRenderer for Twin {}
2042 impl ProcedureRenderer for Twin {}
2043 impl FormulaRenderer for Twin {}
2044 impl TaskRenderer for Twin {}
2045 impl ReplicationRenderer for Twin {}
2046 impl StorageRenderer for Twin {}
2047 impl DialectCapabilities for Twin {}
2048 impl PlatformDialect for Twin {
2049 fn info(&self) -> DialectInfo {
2050 DialectInfo {
2051 name: "twindb",
2052 display_name: "Twin DB",
2053 aliases: &["healthydb"],
2055 dialect_family: DialectFamily::Custom("twindb"),
2056 family: "Twin",
2057 is_cloud_native: false,
2058 is_olap: false,
2059 is_postgres_compatible: false,
2060 versions: &[],
2061 deploy_script: None,
2062 }
2063 }
2064 }
2065
2066 fn mentions(report: &Report, needle: &str) -> bool {
2068 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2069 }
2070
2071 #[test]
2075 fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2076 let report = check(&BrokenPlugin);
2077 let printed = report.to_string();
2078 let first = printed.lines().next().unwrap_or_default();
2079 assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2080 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2081 assert!(printed.contains("Taken as the contract's default"), "{printed}");
2082 let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2083 assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2084 let view = report
2085 .taken_as_default
2086 .iter()
2087 .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2088 .expect("listed");
2089 assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2090 assert_eq!(view.shows_in, "schema.sql");
2091 assert!(printed.contains(&view.answer), "{printed}");
2092 }
2093
2094 #[test]
2098 fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2099 let report = check(&Answered::<Outside>::new());
2100 let printed = report.to_string();
2101 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2102 assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2103 }
2104
2105 #[test]
2109 fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2110 let report = check(&BrokenPlugin);
2111 let key = report
2112 .findings
2113 .iter()
2114 .find(|finding| finding.what.contains("surrogate-key spelling"))
2115 .expect("the key's finding");
2116 for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2117 assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2118 }
2119 assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2120 }
2121
2122 #[test]
2126 fn the_kit_catches_a_deliberately_broken_plugin() {
2127 let report = check(&BrokenPlugin);
2128 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2129
2130 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2131 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2132 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2133 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2134 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2135 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2136 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2137 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2138 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2139 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2140 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
2141
2142 assert!(
2143 report.count(Severity::Blocking) >= 8,
2144 "the broken plugin owes more than {} blocking fixes:\n{report}",
2145 report.count(Severity::Blocking)
2146 );
2147 }
2148
2149 #[test]
2153 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2154 let report = check(&HealthyPlugin::new());
2155 let unregistered: Vec<&Finding> =
2156 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2157 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2158 assert_eq!(
2159 report.findings.len(),
2160 1,
2161 "a plugin that answered every question owes nothing else:\n{report}"
2162 );
2163 }
2164
2165 #[test]
2170 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2171 let binary = Answered::<BinaryConcat>::new();
2172 let report = check(&binary);
2173 assert_eq!(
2174 report.findings.len(),
2175 check(&HealthyPlugin::new()).findings.len(),
2176 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2177 );
2178 assert!(
2179 !report.to_string().contains("joins"),
2180 "nothing about the join is outstanding:\n{report}"
2181 );
2182
2183 let key = razeri_plugin_contract::sid::sid_expression(
2184 SqlRenderer::sid_spelling(&binary),
2185 razeri_plugin_contract::sid::Backslash::of(&binary),
2186 None,
2187 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2188 );
2189 assert!(
2190 key.contains("CONCAT(CONCAT("),
2191 "a two-argument engine gets one call per pair:\n{key}"
2192 );
2193 }
2194
2195 #[test]
2199 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2200 let operator = Answered::<OperatorConcat>::new();
2201 let report = check(&operator);
2202 assert_eq!(
2203 report.findings.len(),
2204 check(&HealthyPlugin::new()).findings.len(),
2205 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2206 );
2207 let key = razeri_plugin_contract::sid::sid_expression(
2208 SqlRenderer::sid_spelling(&operator),
2209 razeri_plugin_contract::sid::Backslash::of(&operator),
2210 None,
2211 &["a".to_owned(), "b".to_owned()],
2212 );
2213 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2214 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2215 }
2216
2217 #[test]
2220 fn a_join_that_names_nothing_must_be_fixed() {
2221 let report = check(&Answered::<NamelessConcat>::new());
2222 assert!(
2223 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2224 && finding.what.contains("joins the parts of a composite key")),
2225 "{report}"
2226 );
2227 assert!(
2228 !check(&Answered::<OperatorConcat>::new())
2229 .findings
2230 .iter()
2231 .any(|finding| finding.what.contains("joins the parts of a composite key")),
2232 "a named operator is not the finding"
2233 );
2234 }
2235
2236 #[test]
2240 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2241 let none = check(&Answered::<NoPowerOperator>::new());
2242 assert_eq!(
2243 none.findings.len(),
2244 check(&HealthyPlugin::new()).findings.len(),
2245 "saying the engine has no such operator owes nothing:\n{none}"
2246 );
2247 let blank = check(&Answered::<BlankPowerOperator>::new());
2248 assert!(
2249 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2250 && finding.what.contains("render_power_operator")),
2251 "{blank}"
2252 );
2253 }
2254
2255 #[test]
2258 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2259 let none = check(&Answered::<NoShiftOperator>::new());
2260 assert_eq!(
2261 none.findings.len(),
2262 check(&HealthyPlugin::new()).findings.len(),
2263 "saying the engine has no left shift owes nothing:\n{none}"
2264 );
2265 let blank = check(&Answered::<BlankShiftOperator>::new());
2266 assert!(
2267 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2268 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2269 "{blank}"
2270 );
2271 }
2272
2273 #[test]
2276 fn a_drop_that_loses_its_column_must_be_fixed() {
2277 let healthy = check(&HealthyPlugin::new());
2278 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2279 let lost = check(&Answered::<DropLosesItsColumn>::new());
2280 assert!(
2281 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2282 && finding.what.contains("render_drop_column")),
2283 "{lost}"
2284 );
2285 }
2286
2287 #[test]
2290 fn a_comparison_that_loses_its_value_must_be_fixed() {
2291 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2292 let report = check(&Answered::<ComparesSomethingElse>::new());
2293 assert!(
2294 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2295 && finding.what.contains("render_comparable")),
2296 "{report}"
2297 );
2298 }
2299
2300 #[test]
2304 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2305 let report = check(&Answered::<BeginWithoutTransactions>::new());
2306 assert!(
2307 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2308 && finding.what.contains("leaves the commit to the client")),
2309 "{report}"
2310 );
2311 }
2312
2313 #[test]
2315 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2316 let report = check(&Answered::<BlankBlockEnd>::new());
2317 assert!(
2318 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2319 && finding.what.contains("block terminator")),
2320 "{report}"
2321 );
2322 }
2323
2324 #[test]
2327 fn a_deploy_client_has_to_be_told_which_file_to_run() {
2328 let described = check(&Answered::<DescribedClient>::new());
2329 assert_eq!(
2330 described.findings.len(),
2331 check(&HealthyPlugin::new()).findings.len(),
2332 "a client described in full owes nothing:\n{described}"
2333 );
2334 let fileless = check(&Answered::<FilelessClient>::new());
2335 assert!(
2336 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2337 && finding.what.contains("run_file never names the file")),
2338 "{fileless}"
2339 );
2340 }
2341
2342 #[test]
2346 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2347 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2348 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2349 fn render_data_type(&self, dt: &DataType) -> String {
2350 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2351 }
2352 fn quote_identifier(&self, name: &str) -> String {
2353 format!("\"{name}\"")
2354 }
2355 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2356 match schema {
2357 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2358 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2359 None => self.quote_identifier(name),
2360 }
2361 }
2362 fn render_current_timestamp(&self) -> String {
2363 "CURRENT_TIMESTAMP".to_owned()
2364 }
2365 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2366 Some(format!("{schema}_{name}"))
2367 }
2368 }
2369 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2370 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2371 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2372 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2373 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2374 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2375 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2376 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2377 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2378 fn supports_schemas(&self) -> bool {
2379 S
2380 }
2381 }
2382 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2383 fn info(&self) -> DialectInfo {
2384 DialectInfo {
2385 name: "folderdb",
2386 display_name: "Folder DB",
2387 aliases: &[],
2388 dialect_family: DialectFamily::Custom("folderdb"),
2389 family: "Folder",
2390 is_cloud_native: false,
2391 is_olap: false,
2392 is_postgres_compatible: false,
2393 versions: &[],
2394 deploy_script: None,
2395 }
2396 }
2397 }
2398
2399 let fold_findings = |dialect: &dyn PlatformDialect| {
2400 let mut findings = Vec::new();
2401 check_schema_fold(dialect, &mut findings);
2402 findings
2403 };
2404 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2405 let disagrees = fold_findings(&Folder::<false, false>);
2406 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2407 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2408 let has_schemas = fold_findings(&Folder::<true, true>);
2409 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2410 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2411 }
2412
2413 #[test]
2422 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2423 let mut quiet = Vec::new();
2424 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2425 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2426
2427 let mut stated = Vec::new();
2428 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2429 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2430 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2431 assert!(
2432 stated[0].what.contains("literal"),
2433 "the finding names the restriction:\n{}",
2434 stated[0].what
2435 );
2436 assert!(
2437 stated[0].fix.contains("dynamic-execution"),
2438 "and says what Razeri does with the answer:\n{}",
2439 stated[0].fix
2440 );
2441
2442 let mut two_clauses = Vec::new();
2445 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2446 assert!(two_clauses.is_empty(), "one restriction is not the other");
2447 }
2448
2449 #[test]
2455 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2456 assert!(
2457 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2458 "the fixture registers through the registry, which is the whole path being graded"
2459 );
2460 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2464 assert!(
2465 registry
2466 .list()
2467 .iter()
2468 .filter(|info| info.name != Outside::NAME)
2469 .all(|info| !info.answers_to(Outside::NAME)),
2470 "the fixture's name must be one no other platform claims"
2471 );
2472
2473 let report = check(&OutsidePlugin::new());
2474 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2475 assert!(report.is_clean(), "and the report says so:\n{report}");
2476 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2477 assert!(
2478 report.to_string().contains("nothing to fix"),
2479 "a clean dialect is told it is clean:\n{report}"
2480 );
2481 }
2482
2483 #[test]
2489 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2490 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2491 for info in registry.list() {
2492 assert_eq!(
2493 info.spellings().first().copied(),
2494 Some(info.name),
2495 "a platform's own name leads its spellings"
2496 );
2497 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2498 }
2499 }
2500
2501 #[test]
2507 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2508 let mut registry = crate::dialect::PlatformRegistry::new();
2509 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2510 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2511
2512 let report = check_with_registry(&Twin, ®istry);
2513 let collision = report
2514 .findings
2515 .iter()
2516 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2517 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2518 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2519 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2520 assert!(collision.what.contains("twindb"), "{collision:?}");
2521 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2522 assert!(
2523 !mentions(&report, "does not resolve"),
2524 "a collision is not an unregistered platform:\n{report}"
2525 );
2526 }
2527
2528 #[test]
2532 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2533 let mut registry = crate::dialect::PlatformRegistry::new();
2534 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2535
2536 let mut findings = Vec::new();
2537 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2538 assert!(findings.is_empty(), "{findings:?}");
2539
2540 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2541 assert!(
2542 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2543 "'{spelling}' is the same platform"
2544 );
2545 }
2546 assert!(
2547 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2548 );
2549 }
2550
2551 #[test]
2556 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2557 let mut registry = crate::dialect::PlatformRegistry::new();
2558 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2559
2560 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2561 assert!(
2562 report.is_clean(),
2563 "a loaded dialect that answered everything owes nothing:\n{report}"
2564 );
2565 }
2566
2567 #[test]
2571 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2572 let mut collided = crate::dialect::PlatformRegistry::new();
2573 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2574 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2575
2576 let mut fixes: Vec<String> = Vec::new();
2577 for report in [
2578 check(&BrokenPlugin),
2579 check(&HealthyPlugin::new()),
2580 check(&OutsidePlugin::new()),
2581 check_with_registry(&Twin, &collided),
2584 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2585 ] {
2586 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2587 }
2588 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2589 for fix in &fixes {
2590 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2591 assert!(
2592 !fix.contains(path),
2593 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2594 );
2595 }
2596 }
2597 }
2598
2599 #[test]
2606 fn no_finding_holds_a_run_of_spaces() {
2607 struct Declining;
2608 impl SqlRenderer for Declining {
2609 fn render_data_type(&self, dt: &DataType) -> String {
2610 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2611 }
2612 fn quote_identifier(&self, name: &str) -> String {
2613 format!("\"{name}\"")
2614 }
2615 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2616 name.to_owned()
2617 }
2618 fn render_current_timestamp(&self) -> String {
2619 "CURRENT_TIMESTAMP".to_owned()
2620 }
2621 fn procedure_form(&self) -> ProcedureForm {
2622 ProcedureForm::Custom("Declining PSQL")
2623 }
2624 }
2625 impl ExpressionRenderer for Declining {}
2626 impl DdlRenderer for Declining {
2627 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2628 None
2629 }
2630 }
2631 impl DialectCapabilities for Declining {
2632 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2633 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2634 .and_a_nested_field()
2635 }
2636 }
2637 impl DmlRenderer for Declining {}
2638 impl ProcedureRenderer for Declining {}
2639 impl FormulaRenderer for Declining {}
2640 impl TaskRenderer for Declining {}
2641 impl ReplicationRenderer for Declining {}
2642 impl StorageRenderer for Declining {}
2643 impl PlatformDialect for Declining {
2644 fn info(&self) -> DialectInfo {
2645 DialectInfo {
2646 name: "decliningdb",
2647 display_name: "Declining DB",
2648 aliases: &[],
2649 dialect_family: DialectFamily::Custom("decliningdb"),
2650 family: "Declining",
2651 is_cloud_native: false,
2652 is_olap: false,
2653 is_postgres_compatible: false,
2654 versions: &[],
2655 deploy_script: None,
2656 }
2657 }
2658 }
2659
2660 let mut findings = Vec::new();
2661 check_evolution(&Declining, &mut findings);
2662 check_parameter_type_spelling(&Declining, &mut findings);
2663 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2664
2665 for report in [
2666 check(&BrokenPlugin),
2667 check(&HealthyPlugin::new()),
2668 check(&OutsidePlugin::new()),
2669 check(&Declining),
2670 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2671 ] {
2672 findings.extend(report.findings);
2673 }
2674 for finding in &findings {
2675 for text in [&finding.what, &finding.fix] {
2676 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2677 }
2678 }
2679 }
2680
2681 #[test]
2691 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2692 for (kind, form_method, render_method) in [
2693 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2694 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2695 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2696 ] {
2697 let mut promised = Vec::new();
2698 check_custom_grammar(
2699 CustomGrammar {
2700 kind,
2701 grammar: "PL/SQL",
2702 form_method,
2703 render_method,
2704 noun: "routines",
2705 },
2706 |_| None,
2707 &mut promised,
2708 );
2709 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2710 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2711 assert!(promised[0].what.contains(form_method), "{promised:?}");
2712 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2713 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2714
2715 let mut kept = Vec::new();
2717 check_custom_grammar(
2718 CustomGrammar {
2719 kind,
2720 grammar: "PL/SQL",
2721 form_method,
2722 render_method,
2723 noun: "routines",
2724 },
2725 |spec| Some(format!("CREATE {} …", spec.name)),
2726 &mut kept,
2727 );
2728 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2729 }
2730 }
2731
2732 #[test]
2737 fn a_procedure_form_and_the_procedure_capability_must_agree() {
2738 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2739 let mut findings = Vec::new();
2740 check_routine_forms(dialect, &mut findings);
2741 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
2742 }
2743
2744 struct Claims<const CUSTOM: bool, const HAS: bool>;
2747 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2748 fn render_data_type(&self, dt: &DataType) -> String {
2749 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2750 }
2751 fn quote_identifier(&self, name: &str) -> String {
2752 format!("\"{name}\"")
2753 }
2754 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2755 self.quote_identifier(name)
2756 }
2757 fn render_current_timestamp(&self) -> String {
2758 "CURRENT_TIMESTAMP".to_owned()
2759 }
2760 fn procedure_form(&self) -> ProcedureForm {
2761 if CUSTOM {
2762 ProcedureForm::Custom("Firebird PSQL")
2763 } else {
2764 ProcedureForm::Unsupported(
2765 "This engine has no stored procedures; schedule the load as a query.",
2766 )
2767 }
2768 }
2769 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2770 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2771 }
2772 }
2773 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2774 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2775 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2776 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2777 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2778 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2779 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2780 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2781 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2782 fn supports_stored_procedures(&self) -> bool {
2783 HAS
2784 }
2785 }
2786 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2787 fn info(&self) -> DialectInfo {
2788 DialectInfo {
2789 name: "claimsdb",
2790 display_name: "Claims DB",
2791 aliases: &[],
2792 dialect_family: DialectFamily::Custom("claimsdb"),
2793 family: "Claims",
2794 is_cloud_native: false,
2795 is_olap: false,
2796 is_postgres_compatible: false,
2797 versions: &[],
2798 deploy_script: None,
2799 }
2800 }
2801 }
2802
2803 let disagreement = |report: &Report| {
2804 report
2805 .findings
2806 .iter()
2807 .filter(|finding| {
2808 finding.severity == Severity::Blocking
2809 && (finding.what.contains("says its engine has none")
2810 || finding.what.contains("refuses to write one"))
2811 })
2812 .count()
2813 };
2814 let mut findings = Vec::new();
2815 check_routine_forms(&Claims::<true, false>, &mut findings);
2816 let report =
2817 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
2818 assert_eq!(disagreement(&report), 1, "{report}");
2819 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2820
2821 for (agrees, label) in [
2822 (check_forms(&Claims::<true, true>), "spelled and used"),
2823 (check_forms(&Claims::<false, false>), "refused and absent"),
2824 ] {
2825 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2826 }
2827 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2828 }
2829
2830 #[test]
2836 fn a_custom_render_method_that_returns_nothing_is_blocking() {
2837 let mut findings = Vec::new();
2838 check_custom_grammar(
2839 CustomGrammar {
2840 kind: RoutineKind::Procedure,
2841 grammar: "PL/SQL",
2842 form_method: "procedure_form",
2843 render_method: "render_procedure",
2844 noun: "procedures",
2845 },
2846 |_| Some(" \n".to_owned()),
2847 &mut findings,
2848 );
2849 assert_eq!(findings.len(), 1, "{findings:?}");
2850 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2851 }
2852
2853 #[test]
2855 fn a_custom_grammar_with_no_name_is_blocking() {
2856 let mut findings = Vec::new();
2857 check_custom_grammar(
2858 CustomGrammar {
2859 kind: RoutineKind::ScalarFunction,
2860 grammar: " ",
2861 form_method: "scalar_function_form",
2862 render_method: "render_scalar_function",
2863 noun: "scalar functions",
2864 },
2865 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2866 &mut findings,
2867 );
2868 assert_eq!(findings.len(), 1, "{findings:?}");
2869 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2870 }
2871
2872 #[test]
2880 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2881 struct Overstated;
2882 impl SqlRenderer for Overstated {
2883 fn render_data_type(&self, dt: &DataType) -> String {
2884 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2885 }
2886 fn quote_identifier(&self, name: &str) -> String {
2887 format!("\"{name}\"")
2888 }
2889 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2890 name.to_owned()
2891 }
2892 fn render_current_timestamp(&self) -> String {
2893 "CURRENT_TIMESTAMP".to_owned()
2894 }
2895 fn dialect_name(&self) -> &'static str {
2896 "overstateddb"
2897 }
2898 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2899 Some(razeri_plugin_contract::SidHelperBody {
2900 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2901 body: " \n ",
2902 })
2903 }
2904 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2905 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2906 }
2907 }
2908 impl ExpressionRenderer for Overstated {}
2909 impl DdlRenderer for Overstated {}
2910 impl DmlRenderer for Overstated {}
2911 impl ProcedureRenderer for Overstated {}
2912 impl FormulaRenderer for Overstated {}
2913 impl TaskRenderer for Overstated {}
2914 impl ReplicationRenderer for Overstated {}
2915 impl StorageRenderer for Overstated {}
2916 impl DialectCapabilities for Overstated {}
2917 impl PlatformDialect for Overstated {
2918 fn info(&self) -> DialectInfo {
2919 DialectInfo {
2920 name: "overstateddb",
2921 display_name: "Overstated DB",
2922 aliases: &[],
2923 dialect_family: DialectFamily::Custom(""),
2924 family: "Overstated",
2925 is_cloud_native: false,
2926 is_olap: false,
2927 is_postgres_compatible: false,
2928 versions: &[],
2929 deploy_script: None,
2930 }
2931 }
2932 }
2933
2934 let report = check(&Overstated);
2935 for (fragment, why) in [
2936 ("supplies an empty one", "a blank helper body"),
2937 ("does not carry the path", "a file relation that drops its path"),
2938 ] {
2939 let found = report
2940 .findings
2941 .iter()
2942 .find(|f| f.what.contains(fragment))
2943 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
2944 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
2945 }
2946
2947 let quiet = check(&HealthyPlugin::new());
2950 for fragment in ["supplies an empty one", "does not carry the path"] {
2951 assert!(
2952 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
2953 "silence is the default and owes nothing:\n{quiet}"
2954 );
2955 }
2956 }
2957
2958 #[test]
2962 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
2963 let mut mute = Vec::new();
2964 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
2965 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
2966 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
2967
2968 let silence = check(&BrokenPlugin);
2969 assert!(
2970 silence
2971 .findings
2972 .iter()
2973 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
2974 "a dialect that never answered is unanswered, not wrong:\n{silence}"
2975 );
2976 assert!(
2977 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
2978 "the fix line names the way to state a measured refusal:\n{silence}"
2979 );
2980 }
2981
2982 #[test]
2986 fn the_report_reads_as_a_summary_then_a_fix_list() {
2987 let text = check(&BrokenPlugin).to_string();
2988 let first = text.lines().next().expect("a summary line");
2989 assert!(first.starts_with("brokendb:"), "{first}");
2990 assert!(first.contains("to fix"), "{first}");
2991 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
2992 }
2993}