1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 BitwiseOperator, CatalogueReach, ColumnEvolution, Concat, ConstraintEnforcement, DataType,
47 DdlRenderer, DelimitedFile, DialectCapabilities, DialectFamily, EntityRef, ExpressionRenderer,
48 FileDelimiter, FormulaRenderer, HeldAs, MergeRestrictions, MergeShape, ProcedureForm,
49 ReplicationRenderer, RoutineKind, RoutineSpec, SID_PREREQUISITE_STATEMENT_DELIMITER,
50 ScalarFunctionForm, SidPrerequisiteKind, SliceRef, SliceReplacement, SqlRenderer,
51 StorageRenderer, TableFunctionForm, TableRole, UNKNOWN_PLATFORM, WrittenObject,
52 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
53 storage_strategy::{
54 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
55 },
56};
57use std::fmt;
58use std::fmt::Write as _;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
62pub enum Severity {
63 Blocking,
66 Unanswered,
69 Note,
71}
72
73impl fmt::Display for Severity {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(match self {
76 Self::Blocking => "must fix",
77 Self::Unanswered => "unanswered",
78 Self::Note => "worth a look",
79 })
80 }
81}
82
83#[derive(Debug, Clone)]
86pub struct Finding {
87 pub severity: Severity,
89 pub what: String,
91 pub fix: String,
93}
94
95#[derive(Debug, Clone)]
97pub struct Report {
98 pub dialect: String,
100 pub findings: Vec<Finding>,
102 pub taken_as_default: Vec<TakenAsDefault>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct TakenAsDefault {
112 pub name: &'static str,
114 pub shows_in: &'static str,
116 pub answer: String,
118}
119
120impl Report {
121 #[must_use]
125 pub fn is_clean(&self) -> bool {
126 !self.findings.iter().any(|f| f.severity != Severity::Note)
127 }
128
129 #[must_use]
131 pub fn count(&self, severity: Severity) -> usize {
132 self.findings.iter().filter(|f| f.severity == severity).count()
133 }
134}
135
136impl Report {
137 #[must_use]
144 pub fn head(&self) -> String {
145 let mut out = String::new();
146 let name = &self.dialect;
147 let blocking = self.count(Severity::Blocking);
148 let summary = if self.findings.is_empty() {
149 "nothing to fix".to_owned()
150 } else {
151 format!(
152 "{blocking} to fix, {} unanswered, {} worth a look",
153 self.count(Severity::Unanswered),
154 self.count(Severity::Note)
155 )
156 };
157 let _ = writeln!(out, "{name}: {summary}, in the answers this dialect states.");
158 let zero = if blocking == 0 {
159 let phrase = if self.findings.is_empty() { "nothing to fix" } else { "0 to fix" };
160 format!(" \"{phrase}\" means no question is open, not that the SQL runs.")
161 } else {
162 String::new()
163 };
164 let _ = writeln!(
165 out,
166 "This report read the dialect's answers and ran nothing on an engine.{zero} Only the \
167 SQL Razeri renders for it, run on your engine, shows that it runs."
168 );
169 for finding in &self.findings {
170 let _ = writeln!(out);
171 let _ = writeln!(out, " [{}] {}", finding.severity, finding.what);
172 let _ = writeln!(out, " → {}", finding.fix);
173 }
174 out
175 }
176}
177
178impl fmt::Display for Report {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(&self.head())?;
181 writeln!(f)?;
182 if self.taken_as_default.is_empty() {
183 return writeln!(
184 f,
185 "This dialect answers every question Razeri asks here about the shape of a \
186 statement differently from the contract's default."
187 );
188 }
189 let count = self.taken_as_default.len();
190 writeln!(
191 f,
192 "Taken as the contract's default: this dialect answers {count} {} as the contract's \
193 default does, so Razeri writes the default's SQL, shown beside each. Confirm each \
194 on your engine.",
195 if count == 1 { "question" } else { "questions" }
196 )?;
197 for taken in &self.taken_as_default {
198 writeln!(f)?;
199 writeln!(f, " {} → {}", taken.name, taken.answer)?;
200 writeln!(f, " in {}", taken.shows_in)?;
201 }
202 Ok(())
203 }
204}
205
206#[derive(Clone, Copy)]
209enum Asked {
210 Sql(fn(&dyn SqlRenderer) -> String),
211 Expression(fn(&dyn ExpressionRenderer) -> String),
212 Ddl(fn(&dyn DdlRenderer) -> String),
213 Capabilities(fn(&dyn DialectCapabilities) -> String),
214}
215
216#[derive(Clone, Copy)]
219struct Seam {
220 name: &'static str,
221 shows_in: &'static str,
222 asked: Asked,
223}
224
225impl Seam {
226 fn answer_of(&self, dialect: &dyn PlatformDialect) -> String {
228 match self.asked {
229 Asked::Sql(answer) => answer(dialect),
230 Asked::Expression(answer) => answer(dialect),
231 Asked::Ddl(answer) => answer(dialect),
232 Asked::Capabilities(answer) => answer(dialect),
233 }
234 }
235
236 fn default_of(&self, defaults: &ContractDefaults<'_>) -> String {
238 match self.asked {
239 Asked::Sql(answer) => answer(defaults),
240 Asked::Expression(answer) => answer(defaults),
241 Asked::Ddl(answer) => answer(defaults),
242 Asked::Capabilities(answer) => answer(defaults),
243 }
244 }
245}
246
247fn statement_seams() -> &'static [Seam] {
255 SEAMS
256}
257
258const SCHEMA: &str = "schema.sql";
259const LOADS: &str = "loads.sql";
260const RECONCILE: &str = "reconcile.sql";
261const EVERY: &str = "every file";
262const fn seam(name: &'static str, shows_in: &'static str, asked: Asked) -> Seam {
263 Seam { name, shows_in, asked }
264}
265const SEAMS: &[Seam] = &[
266 seam(
267 "DdlRenderer::supports_if_not_exists",
268 SCHEMA,
269 Asked::Ddl(|d| said(d.supports_if_not_exists())),
270 ),
271 seam(
272 "DdlRenderer::supports_primary_key_constraint",
273 SCHEMA,
274 Asked::Ddl(|d| said(d.supports_primary_key_constraint())),
275 ),
276 seam(
277 "DdlRenderer::supports_foreign_key_constraint",
278 SCHEMA,
279 Asked::Ddl(|d| said(d.supports_foreign_key_constraint())),
280 ),
281 seam(
282 "DdlRenderer::render_create_view_prefix",
283 SCHEMA,
284 Asked::Ddl(|d| quoted(d.render_create_view_prefix())),
285 ),
286 seam(
287 "DdlRenderer::replaces_a_view_that_is_read",
288 SCHEMA,
289 Asked::Ddl(|d| said(d.replaces_a_view_that_is_read())),
290 ),
291 seam(
292 "DdlRenderer::render_drop_view_prefix",
293 SCHEMA,
294 Asked::Ddl(|d| quoted(&d.render_drop_view_prefix(true))),
295 ),
296 seam(
297 "DdlRenderer::render_drop_table_prefix",
298 SCHEMA,
299 Asked::Ddl(|d| quoted(&d.render_drop_table_prefix(true))),
300 ),
301 seam(
302 "DdlRenderer::render_drop_function",
303 SCHEMA,
304 Asked::Ddl(|d| quoted(&d.render_drop_function("f", &["BIGINT"], true))),
305 ),
306 seam(
307 "DdlRenderer::supports_drop_function_arg_types",
308 SCHEMA,
309 Asked::Ddl(|d| said(d.supports_drop_function_arg_types())),
310 ),
311 seam(
312 "DdlRenderer::render_create_schema_statement",
313 SCHEMA,
314 Asked::Ddl(|d| quoted(&d.render_create_schema_statement("s", true))),
315 ),
316 seam(
317 "DialectCapabilities::supports_schemas",
318 SCHEMA,
319 Asked::Capabilities(|d| said(d.supports_schemas())),
320 ),
321 seam(
322 "SqlRenderer::uses_schema_in_qualified_names",
323 SCHEMA,
324 Asked::Sql(|d| said(d.uses_schema_in_qualified_names())),
325 ),
326 seam(
327 "DdlRenderer::supports_ddl_transactions",
328 SCHEMA,
329 Asked::Ddl(|d| said(d.supports_ddl_transactions())),
330 ),
331 seam(
332 "DdlRenderer::render_begin_transaction",
333 SCHEMA,
334 Asked::Ddl(|d| quoted(d.render_begin_transaction())),
335 ),
336 seam("DdlRenderer::render_commit", SCHEMA, Asked::Ddl(|d| quoted(d.render_commit()))),
337 seam(
338 "SqlRenderer::scalar_function_body_is_expression",
339 SCHEMA,
340 Asked::Sql(|d| said(d.scalar_function_body_is_expression())),
341 ),
342 seam(
343 "DialectCapabilities::max_identifier_length",
344 EVERY,
345 Asked::Capabilities(|d| said(d.max_identifier_length())),
346 ),
347 seam(
348 "DialectCapabilities::begin_transaction_keyword",
349 LOADS,
350 Asked::Capabilities(|d| quoted(d.begin_transaction_keyword())),
351 ),
352 seam(
353 "SqlRenderer::merge_source_requires_inlined_ctes",
354 LOADS,
355 Asked::Sql(|d| said(d.merge_source_requires_inlined_ctes())),
356 ),
357 seam(
358 "SqlRenderer::insert_accepts_leading_ctes",
359 LOADS,
360 Asked::Sql(|d| said(d.insert_accepts_leading_ctes())),
361 ),
362 seam(
363 "SqlRenderer::is_reserved_word",
364 LOADS,
365 Asked::Sql(|d| {
366 if d.is_reserved_word("matched") {
367 "`matched` is reserved: a load writes it quoted".to_owned()
368 } else {
369 "`matched` is free: a load writes `1 AS matched`".to_owned()
370 }
371 }),
372 ),
373 seam(
374 "SqlRenderer::render_empty_table",
375 LOADS,
376 Asked::Sql(|d| quoted(&d.render_empty_table("t"))),
377 ),
378 seam(
379 "SqlRenderer::render_procedure_variable_reference",
380 LOADS,
381 Asked::Sql(|d| quoted(&d.render_procedure_variable_reference("v"))),
382 ),
383 seam(
384 "ExpressionRenderer::render_concat",
385 LOADS,
386 Asked::Expression(|d| quoted(&d.render_concat(&["a".into(), "b".into()]))),
387 ),
388 seam(
389 "SqlRenderer::statement_separator",
390 LOADS,
391 Asked::Sql(|d| quoted(d.statement_separator())),
392 ),
393 seam(
394 "SqlRenderer::render_one_row_source",
395 RECONCILE,
396 Asked::Sql(|d| match d.render_one_row_source() {
397 Some(source) => quoted(&source),
398 None => "none: a `SELECT` with no `FROM`".to_owned(),
399 }),
400 ),
401];
402
403fn said(answer: impl fmt::Display) -> String {
405 format!("`{answer}`")
406}
407
408fn quoted(sql: &str) -> String {
410 format!("`{}`", sql.replace('\n', "\\n"))
411}
412
413#[must_use]
424pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
425 let defaults = ContractDefaults { graded: dialect };
426 statement_seams()
427 .iter()
428 .filter_map(|seam| {
429 let default = seam.default_of(&defaults);
430 (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
431 name: seam.name,
432 shows_in: seam.shows_in,
433 answer: default,
434 })
435 })
436 .collect()
437}
438
439struct ContractDefaults<'a> {
443 graded: &'a dyn PlatformDialect,
444}
445
446impl SqlRenderer for ContractDefaults<'_> {
447 fn render_data_type(&self, dt: &DataType) -> String {
448 self.graded.render_data_type(dt)
449 }
450
451 fn quote_identifier(&self, name: &str) -> String {
452 self.graded.quote_identifier(name)
453 }
454
455 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
456 self.graded.qualify_name(schema, name)
457 }
458
459 fn render_current_timestamp(&self) -> String {
460 self.graded.render_current_timestamp()
461 }
462}
463impl ExpressionRenderer for ContractDefaults<'_> {}
464impl DdlRenderer for ContractDefaults<'_> {}
465impl DialectCapabilities for ContractDefaults<'_> {}
466
467#[must_use]
474pub fn check(dialect: &dyn PlatformDialect) -> Report {
475 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
476}
477
478#[must_use]
481pub fn check_with_registry(
482 dialect: &dyn PlatformDialect,
483 registry: &crate::dialect::PlatformRegistry,
484) -> Report {
485 let info = dialect.info();
486 let mut findings = Vec::new();
487
488 check_registration(&info, registry, &mut findings);
489 check_family(info.dialect_family, &mut findings);
490 check_required_rendering(dialect, &mut findings);
491 check_decisions(dialect, &mut findings);
492 check_formula_renderer(dialect, &mut findings);
493 check_evolution(dialect, &mut findings);
494 check_run_again(dialect, &mut findings);
495 check_transactions(dialect, &mut findings);
496 check_paired_facts(dialect, &mut findings);
497 check_schema_fold(dialect, &mut findings);
498 check_deploy_client(&info, &mut findings);
499
500 findings.sort_by_key(|f| f.severity);
501 Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
502}
503
504fn check_registration(
515 info: &crate::dialect::DialectInfo,
516 registry: &crate::dialect::PlatformRegistry,
517 findings: &mut Vec<Finding>,
518) {
519 let name = info.name;
520 if matches!(
521 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
522 Err(crate::ResolveError::UnknownDialect { .. })
523 ) {
524 findings.push(Finding {
525 severity: Severity::Blocking,
526 what: format!(
527 "the name `{name}` does not resolve — nothing in this program registered it"
528 ),
529 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
530 crate's `lib.rs`, where `register` calls \
531 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
532 dialect one the registry finds, whether razeri loads your library from a \
533 plugin directory or links your crate in."
534 .to_owned(),
535 });
536 return;
537 }
538 for spelling in info.spellings() {
539 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
540 Ok(found) if found.info().name == name => {}
541 Ok(found) => findings.push(Finding {
542 severity: Severity::Blocking,
543 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
544 fix: format!(
545 "the registry holds `{name}` under another key. Register it under its own \
546 name, or drop `{spelling}` from its aliases."
547 ),
548 }),
549 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
550 severity: Severity::Blocking,
551 what: format!(
552 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
553 none of them — Razeri refuses that spelling rather than guessing which one \
554 a design meant",
555 claimed_by.join(" and ")
556 ),
557 fix: format!(
558 "give this dialect a name no other platform answers to. A platform name is \
559 read off the loaded dialects' own declared spellings, so a spelling another \
560 platform already claims is never `{name}`'s."
561 ),
562 }),
563 Err(_) => findings.push(Finding {
564 severity: Severity::Blocking,
565 what: format!(
566 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
567 ),
568 fix: format!(
569 "the `{name}` in the registry is a different build of this dialect and does \
570 not declare `{spelling}`. Register the one you are grading."
571 ),
572 }),
573 }
574 }
575}
576
577fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
580 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
581 findings.push(Finding {
582 severity: Severity::Blocking,
583 what: "this dialect's family is a custom one with no name".to_owned(),
584 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
585 prints and what a reader looks up."
586 .to_owned(),
587 });
588 }
589}
590
591fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
593 let declarable = declarable_types();
594 let empty_column: Vec<String> = declarable
595 .iter()
596 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
597 .map(|dt| format!("{dt:?}"))
598 .collect();
599 if !empty_column.is_empty() {
600 findings.push(Finding {
601 severity: Severity::Blocking,
602 what: format!(
603 "`render_data_type` answers nothing for {} of the types a design can declare",
604 empty_column.len()
605 ),
606 fix: "every declarable type needs a column spelling on this engine, even if the \
607 spelling is a wider type that stores it. A blank answer becomes a column \
608 declaration with no type in it."
609 .to_owned(),
610 });
611 }
612 let empty_cast: Vec<String> = declarable
613 .iter()
614 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
615 .map(|dt| format!("{dt:?}"))
616 .collect();
617 if !empty_cast.is_empty() {
618 findings.push(Finding {
619 severity: Severity::Blocking,
620 what: format!(
621 "`render_cast_type` answers nothing for {} declarable types",
622 empty_cast.len()
623 ),
624 fix: "the generated load opens by casting every source column to its declared type, \
625 so a blank answer fails the load on its first statement."
626 .to_owned(),
627 });
628 }
629 let quoted = SqlRenderer::quote_identifier(dialect, "order");
630 if quoted == "order" {
631 findings.push(Finding {
632 severity: Severity::Blocking,
633 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
634 reaches this engine bare"
635 .to_owned(),
636 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
637 backticks, or brackets."
638 .to_owned(),
639 });
640 }
641 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
642 findings.push(Finding {
643 severity: Severity::Blocking,
644 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
645 a row with it"
646 .to_owned(),
647 fix: "return this engine's own expression for the current moment.".to_owned(),
648 });
649 }
650}
651
652fn check_encoded_file_relation(
664 dialect: &dyn PlatformDialect,
665 probe: &str,
666 findings: &mut Vec<Finding>,
667) {
668 let Some(relation) =
669 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
670 else {
671 return;
672 };
673 if !relation.contains(probe) {
674 findings.push(Finding {
675 severity: Severity::Blocking,
676 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
677 the path it was given"
678 .to_owned(),
679 fix: "put the literal you were handed where your engine's file relation takes a \
680 path. It arrives already quoted and escaped."
681 .to_owned(),
682 });
683 }
684 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
685 findings.push(Finding {
686 severity: Severity::Blocking,
687 what: "this dialect states how to read a file in a named encoding and states no way \
688 to read a file at all"
689 .to_owned(),
690 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
691 first question before the second — a source whose bytes are UTF-8 never \
692 reaches the encoding arm — so an engine that answers only the second has \
693 written a relation nothing will ask it for."
694 .to_owned(),
695 });
696 }
697}
698
699fn check_file_settings(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
712 let columns = ["a".to_owned(), "b".to_owned()];
713 let plain =
714 DelimitedFile::new("'/razeri/probe.csv'", "'/razeri'", "probe.csv").with_columns(&columns);
715 let read = |file: &DelimitedFile<'_>| {
716 SqlRenderer::delimited_file_relation_for(dialect, file)
717 .map(|relation| (relation, SqlRenderer::delimited_file_statements_for(dialect, file)))
718 };
719 let Some(plain_read) = read(&plain) else { return };
720 for (told, said) in [
721 (plain.with_delimiter(FileDelimiter::Pipe), "a file delimited by `|`"),
722 (plain.with_quote('\''), "a file quoted with `'`"),
723 (plain.with_header(false), "a file with no header line"),
724 ] {
725 if read(&told).as_ref() == Some(&plain_read) {
726 findings.push(Finding {
727 severity: Severity::Blocking,
728 what: format!(
729 "`delimited_file_relation_for` reads {said} exactly as it reads a \
730 comma-delimited file with a header"
731 ),
732 fix: "put the file's `delimiter`, `quote` and `header` where your engine's read \
733 takes them, or answer `None` for a file your engine cannot be told about. \
734 Razeri then declines that file by name, rather than loading each line into \
735 one column."
736 .to_owned(),
737 });
738 }
739 }
740}
741
742#[must_use]
758pub fn key_refusal_reason(dialect: &dyn PlatformDialect) -> Option<String> {
759 if ReplicationRenderer::sid_prerequisite_kind(dialect) != SidPrerequisiteKind::Refused {
760 return None;
761 }
762 let script = ReplicationRenderer::sid_prerequisites(dialect);
763 let lines: Vec<&str> = script
764 .lines()
765 .map(str::trim)
766 .filter(|line| *line != SID_PREREQUISITE_STATEMENT_DELIMITER.trim())
767 .map(|line| line.strip_prefix("--").map_or(line, str::trim))
768 .collect();
769 Some(lines.join("\n").trim().to_owned())
770}
771
772fn first_sentence(prose: &str) -> String {
774 let flat = prose.split_whitespace().collect::<Vec<_>>().join(" ");
775 flat.find(". ").map_or(flat.clone(), |end| flat[..=end].to_owned())
776}
777
778fn check_key_refusal(reason: &str, findings: &mut Vec<Finding>) {
786 if reason.is_empty() {
787 findings.push(Finding {
788 severity: Severity::Blocking,
789 what: "this dialect says its engine cannot compute razeri's surrogate key, and gives \
790 no reason"
791 .to_owned(),
792 fix: "return the reason from `ReplicationRenderer::sid_prerequisites`, as comment \
793 lines: why this engine cannot compute XXH64 with seed 0, and what an operator \
794 can do instead. Razeri prints it where it refuses a design that keys."
795 .to_owned(),
796 });
797 return;
798 }
799 findings.push(Finding {
800 severity: Severity::Note,
801 what: "this dialect says its engine cannot compute razeri's surrogate key, so razeri \
802 renders a design that needs no key and refuses one that keys, with this reason"
803 .to_owned(),
804 fix: format!("its reason: {}", first_sentence(reason)),
805 });
806}
807
808fn check_key_spelling(
812 dialect: &dyn PlatformDialect,
813 spelling: &razeri_plugin_contract::SidSpelling,
814 findings: &mut Vec<Finding>,
815) {
816 let refusal = key_refusal_reason(dialect);
817 if let Some(reason) = &refusal {
818 check_key_refusal(reason, findings);
819 }
820 if spelling.platform == UNKNOWN_PLATFORM && refusal.is_none() {
821 findings.push(Finding {
822 severity: Severity::Blocking,
823 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
824 an `xxhash64` its engine very likely does not have"
825 .to_owned(),
826 fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
827 replacement and a Unicode literal, how it joins strings, how it casts to text, \
828 and how it spells a 64-bit hash. If it has no portable hash, name an artifact \
829 your prerequisites install — never the nearest-looking built-in, which keys a \
830 warehouse to a number nothing else can reproduce. If the engine cannot define \
831 a function at all, answer `ReplicationRenderer::sid_prerequisite_kind` with \
832 `SidPrerequisiteKind::Refused` and say why in `sid_prerequisites`: that is an \
833 answer, and razeri then refuses only the designs that key."
834 .to_owned(),
835 });
836 }
837}
838
839fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
841 let spelling = SqlRenderer::sid_spelling(dialect);
842 check_key_spelling(dialect, &spelling, findings);
843
844 let join = match spelling.concat {
848 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
849 Concat::Plus => "+",
850 };
851 if join.trim().is_empty() {
852 findings.push(Finding {
853 severity: Severity::Blocking,
854 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
855 with a function or operator that has no name"
856 .to_owned(),
857 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
858 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
859 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
860 operator and no function."
861 .to_owned(),
862 });
863 }
864
865 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
870 && supplied.body.trim().is_empty()
871 {
872 findings.push(Finding {
873 severity: Severity::Blocking,
874 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
875 and then supplies an empty one"
876 .to_owned(),
877 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
878 one argument, or answer `None` and let Razeri compose the derivation from \
879 your `sid_spelling`. An empty body deploys without error and keys every row \
880 to nothing."
881 .to_owned(),
882 });
883 }
884
885 let probe = "'/razeri/probe.csv'";
888 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
889 && !relation.contains(probe)
890 {
891 findings.push(Finding {
892 severity: Severity::Blocking,
893 what: "`delimited_file_relation` returns a relation that does not carry the path it \
894 was given"
895 .to_owned(),
896 fix: "put the literal you were handed where your engine's file relation takes a \
897 path. It arrives already quoted and escaped. A relation that drops it reads \
898 one file for every entity the design binds, and the generated fill loads the \
899 wrong rows without failing."
900 .to_owned(),
901 });
902 }
903
904 check_encoded_file_relation(dialect, probe, findings);
905 check_file_settings(dialect, findings);
906 check_routine_forms(dialect, findings);
907 check_slice_replacement(dialect, findings);
908
909 if let ConstraintEnforcement::DeclaredOnly(declared) =
912 DdlRenderer::constraint_enforcement(dialect)
913 && declared.note.split_whitespace().count() < 5
914 {
915 findings.push(Finding {
916 severity: Severity::Blocking,
917 what: "this dialect says its engine accepts constraints it does not enforce, and \
918 the note explaining that says almost nothing"
919 .to_owned(),
920 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
921 guarantee. Say what this engine really does with the declaration, and where \
922 the guarantee has to come from instead."
923 .to_owned(),
924 });
925 }
926
927 match DialectCapabilities::merge_shape(dialect) {
933 MergeShape::None => findings.push(Finding {
934 severity: Severity::Unanswered,
935 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
936 load for it"
937 .to_owned(),
938 fix: "if this engine has a MERGE or an upsert statement, say which in \
939 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
940 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
941 operator can act on, rather than a silence."
942 .to_owned(),
943 }),
944 MergeShape::Unsupported(orientation) => {
945 check_refusal_copy("the upsert refusal", orientation, findings);
946 }
947 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
948 _ => {}
949 }
950}
951
952fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
967 const VALUE: &str = "$razeri_slice_probe";
968 let plan = StoragePlan {
969 partition: PartitionStrategy::RangeByTimeDimension {
970 field: "razeri_probe_day".to_owned(),
971 granularity: PartitionGranularity::Day,
972 retention: None,
973 look_ahead: None,
974 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
975 },
976 compression: CompressionStrategy::None,
977 index: IndexStrategy::None,
978 };
979 let day = DataType::date();
980 let columns = ["razeri_probe_day"];
981 let entity = EntityRef {
982 schema: Some("razeri_probe"),
983 name: "razeri_probe_table",
984 role: TableRole::Written,
985 columns: &columns,
986 partition_column_type: Some(&day),
987 };
988 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
989 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
990 else {
991 return;
992 };
993
994 let carries = match &answer {
995 SliceReplacement::Emptied(statements) => {
996 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
997 }
998 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
999 };
1000 if carries {
1006 return;
1007 }
1008 findings.push(Finding {
1009 severity: Severity::Blocking,
1010 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
1011 was handed"
1012 .to_owned(),
1013 fix: "put the value you were given where your statement names the slice, and build any \
1014 partition NAME from it the same way your extend procedure built it. A replacement \
1015 that drops the value clears a slice this call was not given — or every slice — \
1016 and the load reports success over what it emptied."
1017 .to_owned(),
1018 });
1019}
1020
1021fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1038 let answers = DialectCapabilities::evolution_answers(dialect);
1039 let boolean = DataType::boolean();
1040 let long = DataType::varchar(40);
1041
1042 if DdlRenderer::render_column_evolution(
1043 dialect,
1044 &ColumnEvolution::AddColumn {
1045 schema: Some("s"),
1046 table: "t",
1047 column: "c",
1048 data_type: &boolean,
1049 },
1050 )
1051 .is_none()
1052 {
1053 findings.push(Finding {
1054 severity: Severity::Blocking,
1055 what: "this dialect writes no statement for a column that is new, which is the \
1056 commonest change a deployed warehouse ever sees"
1057 .to_owned(),
1058 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
1059 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
1060 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
1061 engine that genuinely has no way to add a column is worth saying out loud in \
1062 the crate's own documentation."
1063 .to_owned(),
1064 });
1065 }
1066
1067 if answers.parameterized_string_widening_is_lossless
1068 && DdlRenderer::render_column_evolution(
1069 dialect,
1070 &ColumnEvolution::WidenColumn {
1071 schema: Some("s"),
1072 table: "t",
1073 column: "c",
1074 data_type: &long,
1075 },
1076 )
1077 .is_none()
1078 {
1079 findings.push(Finding {
1080 severity: Severity::Note,
1081 what: "this dialect says its engine widens a string's declared length in place, and \
1082 writes no statement for the change"
1083 .to_owned(),
1084 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
1085 engine's spelling of it. Without it Razeri tells an operator the widening \
1086 costs nothing and then hands them nothing to run, which reads as Razeri not \
1087 having finished rather than as a statement they own."
1088 .to_owned(),
1089 });
1090 }
1091
1092 if answers.nested_field_addition_is_lossless
1093 && DdlRenderer::render_column_evolution(
1094 dialect,
1095 &ColumnEvolution::AddNestedField {
1096 schema: Some("s"),
1097 table: "t",
1098 column: "c",
1099 field: "f",
1100 data_type: &long,
1101 },
1102 )
1103 .is_none()
1104 {
1105 findings.push(Finding {
1106 severity: Severity::Note,
1107 what: "this dialect says its engine keeps every value when a record gains a field, \
1108 and writes no statement for the change"
1109 .to_owned(),
1110 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
1111 engines name the new field; some restate the whole column's type, which is \
1112 why the change carries both the field's path and the column's type with the \
1113 field in it."
1114 .to_owned(),
1115 });
1116 }
1117}
1118
1119fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1128 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
1129 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
1130 let relation = format!("{schema}.{name}");
1131 let table = WrittenObject::new(Some(&schema), &name, &relation);
1132 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
1133 let mut lost: Vec<&str> = Vec::new();
1134
1135 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
1136 Some(drop) if drop.text().contains(&column) => {}
1137 _ => lost.push("render_drop_column"),
1138 }
1139 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
1140 && !guarded.text().contains(&column)
1141 {
1142 lost.push("render_drop_column (only where it stands)");
1143 }
1144 for only_where_it_stands in [false, true] {
1145 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1146 && !drop.text().contains(&name)
1147 {
1148 lost.push("render_drop_view");
1149 }
1150 }
1151 let copy_name = SqlRenderer::quote_identifier(dialect, "razeri_probe_copy");
1153 let copy_relation = format!("{schema}.{copy_name}");
1154 let copy = WrittenObject::new(Some(&schema), ©_name, ©_relation);
1155 let copied = DdlRenderer::render_copy_table(dialect, table, copy);
1156 if !(copied.text().contains(&relation) && copied.text().contains(©_relation)) {
1157 lost.push("render_copy_table");
1158 }
1159 let create = format!("CREATE TABLE {relation} (a INTEGER)");
1160 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1161 && !guarded.text().contains(&create)
1162 {
1163 lost.push("render_create_table_if_absent");
1164 }
1165 lost.dedup();
1166 if !lost.is_empty() {
1167 findings.push(Finding {
1168 severity: Severity::Blocking,
1169 what: format!(
1170 "{} returns a statement that does not carry the object it was handed",
1171 lost.join(", ")
1172 ),
1173 fix: "put the names you were given where your statement names the object, and keep \
1174 a create you guard whole inside the guard. A drop that loses its column drops \
1175 another one or none, and a guard that loses its statement makes nothing — \
1176 both under a run that reports success."
1177 .to_owned(),
1178 });
1179 }
1180
1181 let value = "razeri_probe_value";
1184 let loses_its_value = declarable_types().iter().any(|data_type| {
1185 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1186 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1187 .is_some_and(|compared| !compared.contains(value))
1188 })
1189 });
1190 if loses_its_value {
1191 findings.push(Finding {
1192 severity: Severity::Blocking,
1193 what: "`render_comparable` answers an expression that does not carry the value it \
1194 stands for"
1195 .to_owned(),
1196 fix: "answer the value itself where your engine compares the type directly, an \
1197 expression over it where it compares one only that way, and `None` where it \
1198 cannot compare one at all — then Razeri refuses such a column by name."
1199 .to_owned(),
1200 });
1201 }
1202
1203 if let Some(limits) = DialectCapabilities::text_limits(dialect)
1206 && (limits.longest_varchar == Some(0)
1207 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1208 {
1209 findings.push(Finding {
1210 severity: Severity::Blocking,
1211 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1212 unbounded text cannot be"
1213 .to_owned(),
1214 fix: "state the longest varchar(n) your engine creates — one more is refused at \
1215 CREATE TABLE — and name what it refuses a text column as, or leave it None."
1216 .to_owned(),
1217 });
1218 }
1219
1220 match DialectCapabilities::catalogue_reach(dialect) {
1221 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1222 check_refusal_copy("the reason the schema check is not asked", reason, findings);
1223 }
1224 _ => {}
1225 }
1226}
1227
1228fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1232 if SqlRenderer::render_procedure_log(
1235 dialect,
1236 razeri_plugin_contract::ProcedureLogLevel::Info,
1237 "'razeri'",
1238 )
1239 .is_some_and(|line| line.trim().is_empty())
1240 {
1241 findings.push(Finding {
1242 severity: Severity::Blocking,
1243 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1244 fix: "answer `None` where your engine has no statement that writes a message: the \
1245 line is then left out of the routine."
1246 .to_owned(),
1247 });
1248 }
1249 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1250 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1251 {
1252 findings.push(Finding {
1253 severity: Severity::Blocking,
1254 what: "this dialect says its begin statement leaves the commit to the client, and \
1255 that its engine has no transaction spanning statements"
1256 .to_owned(),
1257 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1258 run in one transaction. Where there is none, the bundle already tells the \
1259 operator the boundary is theirs."
1260 .to_owned(),
1261 });
1262 }
1263}
1264
1265fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1276 if restrictions.slice_pruning_needs_literal {
1277 findings.push(Finding {
1278 severity: Severity::Note,
1279 what: "this dialect says its planner reads one slice of a partitioned table only \
1280 from a literal, so a load scoped to one day cannot simply bind the day"
1281 .to_owned(),
1282 fix: "Razeri will put the day into the statement text through this engine's \
1283 dynamic-execution form rather than binding it to the load procedure's \
1284 parameter. Confirm that is what your engine wants: the statement is composed \
1285 once per call and its text differs per day, which some planners cache and \
1286 some do not. If a bound parameter does prune here, drop the restriction and \
1287 Razeri binds it like everywhere else."
1288 .to_owned(),
1289 });
1290 }
1291}
1292
1293fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1296 let bitwise = BitwiseOperator::ALL.map(|operator| {
1300 (
1301 format!("render_bitwise_operator({operator:?})"),
1302 FormulaRenderer::render_bitwise_operator(dialect, operator),
1303 )
1304 });
1305 let blank: Vec<String> = [
1306 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1307 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1308 (
1309 "render_not_ilike_operator".to_owned(),
1310 FormulaRenderer::render_not_ilike_operator(dialect),
1311 ),
1312 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1313 ]
1314 .into_iter()
1315 .chain(bitwise)
1316 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1317 .map(|(method, _)| method)
1318 .collect();
1319 if !blank.is_empty() {
1320 findings.push(Finding {
1321 severity: Severity::Blocking,
1322 what: format!("{} answers an operator with no text in it", blank.join(", ")),
1323 fix: "answer `None` where your engine has no such operator: a formula that needs it \
1324 is then refused by name, and the reader is told to write the function your \
1325 engine has. An empty answer writes the two operands side by side."
1326 .to_owned(),
1327 });
1328 }
1329
1330 let formula = SqlRenderer::to_formula_renderer(dialect);
1331 let dialect_name = SqlRenderer::dialect_name(dialect);
1332 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1333 findings.push(Finding {
1334 severity: Severity::Blocking,
1335 what: "`to_formula_renderer` was not overridden, so every formula in every design \
1336 renders through Razeri's ANSI defaults instead of this dialect"
1337 .to_owned(),
1338 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1339 formula this engine sees is silently somebody else's."
1340 .to_owned(),
1341 });
1342 }
1343}
1344
1345fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1351 match SqlRenderer::procedure_form(dialect) {
1352 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1356 ProcedureForm::Agnostic => findings.push(Finding {
1357 severity: Severity::Unanswered,
1358 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1359 annotated form — which no engine parses"
1360 .to_owned(),
1361 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1362 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
1363 offers instead."
1364 .to_owned(),
1365 }),
1366 ProcedureForm::Unsupported(orientation) => {
1367 check_refusal_copy("the stored-procedure refusal", orientation, findings);
1368 if DialectCapabilities::supports_stored_procedures(dialect) {
1369 findings.push(Finding {
1370 severity: Severity::Blocking,
1371 what: "this dialect says its engine has stored procedures, and its \
1372 procedure form refuses to write one"
1373 .to_owned(),
1374 fix: "decide which is true. If the engine has none, leave \
1375 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1376 has them, answer `procedure_form` with the grammar it speaks."
1377 .to_owned(),
1378 });
1379 }
1380 }
1381 ProcedureForm::Custom(grammar) => check_custom_grammar(
1382 CustomGrammar {
1383 kind: RoutineKind::Procedure,
1384 grammar,
1385 form_method: "procedure_form",
1386 render_method: "render_procedure",
1387 noun: "procedures",
1388 },
1389 |spec| SqlRenderer::render_procedure(dialect, spec),
1390 findings,
1391 ),
1392 _ => {}
1393 }
1394 check_procedures_are_used(dialect, findings);
1395
1396 match SqlRenderer::scalar_function_form(dialect) {
1397 ScalarFunctionForm::Unsupported(orientation) => {
1398 check_refusal_copy("the scalar-function refusal", orientation, findings);
1399 }
1400 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1401 CustomGrammar {
1402 kind: RoutineKind::ScalarFunction,
1403 grammar,
1404 form_method: "scalar_function_form",
1405 render_method: "render_scalar_function",
1406 noun: "scalar functions",
1407 },
1408 |spec| SqlRenderer::render_scalar_function(dialect, spec),
1409 findings,
1410 ),
1411 _ => {}
1412 }
1413
1414 match SqlRenderer::table_function_form(dialect) {
1415 TableFunctionForm::Unsupported(orientation) => {
1416 check_refusal_copy("the table-function refusal", orientation, findings);
1417 }
1418 TableFunctionForm::Custom(grammar) => check_custom_grammar(
1419 CustomGrammar {
1420 kind: RoutineKind::TableFunction,
1421 grammar,
1422 form_method: "table_function_form",
1423 render_method: "render_table_function",
1424 noun: "table functions",
1425 },
1426 |spec| SqlRenderer::render_table_function(dialect, spec),
1427 findings,
1428 ),
1429 _ => {}
1430 }
1431
1432 check_parameter_type_spelling(dialect, findings);
1433}
1434
1435fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1447 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1448 return;
1449 };
1450 if DialectCapabilities::supports_stored_procedures(dialect) {
1451 return;
1452 }
1453 findings.push(Finding {
1454 severity: Severity::Blocking,
1455 what: format!(
1456 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1457 load is ever written as one"
1458 ),
1459 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1460 it before it writes a load as a procedure, and its default is `false`."
1461 .to_owned(),
1462 });
1463}
1464
1465fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1480 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1481 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1482 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1483 if !spells_its_own {
1484 return;
1485 }
1486 let qualified = DataType::varchar(100);
1489 let column = SqlRenderer::render_data_type(dialect, &qualified);
1490 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1491 if column == parameter && column.contains('(') {
1492 findings.push(Finding {
1493 severity: Severity::Note,
1494 what: format!(
1495 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1496 column of that type"
1497 ),
1498 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1499 a length or a precision in a parameter list — Oracle does, and answers \
1500 PLS-00103 at the opening parenthesis — override \
1501 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1502 declarations keep the length either way."
1503 .to_owned(),
1504 });
1505 }
1506}
1507
1508#[derive(Clone, Copy)]
1510struct CustomGrammar {
1511 kind: RoutineKind,
1513 grammar: &'static str,
1515 form_method: &'static str,
1517 render_method: &'static str,
1519 noun: &'static str,
1521}
1522
1523fn check_custom_grammar(
1540 claim: CustomGrammar,
1541 render: impl Fn(&RoutineSpec) -> Option<String>,
1542 findings: &mut Vec<Finding>,
1543) {
1544 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1545 if grammar.trim().is_empty() {
1548 findings.push(Finding {
1549 severity: Severity::Blocking,
1550 what: format!("`{form_method}` says Custom, and names no grammar"),
1551 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
1552 of a report, and of the comment Razeri writes where a routine could not be \
1553 rendered, is told this engine speaks."
1554 .to_owned(),
1555 });
1556 }
1557
1558 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1559 probe.or_replace = true;
1560 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1561 findings.push(Finding {
1562 severity: Severity::Blocking,
1563 what: format!(
1564 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1565 itself — and `{render_method}` returns nothing"
1566 ),
1567 fix: format!(
1568 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1569 composed — name, parameters with their types already in your spelling, body, \
1570 comment — and returns the whole CREATE statement your engine parses. Razeri \
1571 emits what you return and writes nothing of its own: a near-neighbour grammar \
1572 fails on deploy, and a refusal would say this engine has no {noun} when it \
1573 has them in a grammar of its own."
1574 ),
1575 });
1576 }
1577}
1578
1579fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1581 if copy.trim().is_empty() {
1582 findings.push(Finding {
1583 severity: Severity::Blocking,
1584 what: format!("{subject} has no words in it"),
1585 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1586 next thing to try, and it has to be about their engine."
1587 .to_owned(),
1588 });
1589 return;
1590 }
1591 if copy.split_whitespace().count() < 5 {
1592 findings.push(Finding {
1593 severity: Severity::Note,
1594 what: format!("{subject} is very short"),
1595 fix: "a refusal earns its place by naming the alternative — what this engine has \
1596 instead of the thing it cannot do."
1597 .to_owned(),
1598 });
1599 }
1600}
1601
1602fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1610 let caps: &dyn DialectCapabilities = dialect;
1611 let ren: &dyn SqlRenderer = dialect;
1612 let mut disagreements: Vec<&'static str> = Vec::new();
1613
1614 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1623 disagreements.push("supports_table_functions");
1624 }
1625 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1628 disagreements.push("supports_stored_functions");
1629 }
1630
1631 if !disagreements.is_empty() {
1632 findings.push(Finding {
1633 severity: Severity::Blocking,
1634 what: format!(
1635 "this dialect declares {} {} two ways and the two disagree: {}",
1636 disagreements.len(),
1637 if disagreements.len() == 1 { "fact" } else { "facts" },
1638 disagreements.join(", ")
1639 ),
1640 fix: "each of these is answered as a yes or no on the capability trait and as a \
1641 routine form on the renderer, and Razeri reads both. Decide which is true of \
1642 your engine and move the other one to match."
1643 .to_owned(),
1644 });
1645 }
1646}
1647
1648fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1657 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1658 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1659 return;
1660 };
1661 if DialectCapabilities::supports_schemas(dialect) {
1662 findings.push(Finding {
1663 severity: Severity::Blocking,
1664 what: "this dialect folds a schema into an object's name on an engine it says has \
1665 schemas"
1666 .to_owned(),
1667 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1668 schemas, and only there is it asked; or answer \
1669 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1670 .to_owned(),
1671 });
1672 return;
1673 }
1674 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1675 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1676 if qualified != expected {
1677 findings.push(Finding {
1678 severity: Severity::Blocking,
1679 what: format!(
1680 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1681 the same object `{qualified}`"
1682 ),
1683 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1684 schema. Razeri names some objects through it and the rest through the fold, \
1685 so a difference creates a table under one name and reads it under another."
1686 .to_owned(),
1687 });
1688 }
1689}
1690
1691fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1698 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1699 return;
1700 };
1701 if client.command.trim().is_empty() {
1702 findings.push(Finding {
1703 severity: Severity::Blocking,
1704 what: "this dialect describes a deploy client with no command".to_owned(),
1705 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1706 });
1707 }
1708 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1709 .into_iter()
1710 .filter_map(|(field, args)| {
1711 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1712 })
1713 .collect();
1714 if !unnamed.is_empty() {
1715 findings.push(Finding {
1716 severity: Severity::Blocking,
1717 what: format!(
1718 "the deploy client's {} never names the file to run",
1719 unnamed.join(" and ")
1720 ),
1721 fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1722 inside an argument such as `--file={file}`. The script writes the file there \
1723 and nowhere else."
1724 .to_owned(),
1725 });
1726 }
1727 if let Some(terminator) = client.block_terminator
1728 && [terminator.open, terminator.end, terminator.close]
1729 .iter()
1730 .any(|part| part.trim().is_empty())
1731 {
1732 findings.push(Finding {
1733 severity: Severity::Blocking,
1734 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1735 fix: "name the line before a routine, what ends it, and the line after it — \
1736 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1737 the routine open, and the next statement is read into it."
1738 .to_owned(),
1739 });
1740 }
1741}
1742
1743fn declarable_types() -> Vec<DataType> {
1751 vec![
1752 DataType::tinyint(),
1753 DataType::smallint(),
1754 DataType::int(),
1755 DataType::bigint(),
1756 DataType::double(),
1757 DataType::boolean(),
1758 DataType::text(),
1759 DataType::varchar(200),
1760 DataType::uuid(),
1761 DataType::json(),
1762 DataType::date(),
1763 DataType::timestamp(),
1764 DataType::timestamp_tz(),
1765 DataType::decimal(18, 2),
1766 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1767 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1768 DataType::Struct(StructDataType::new(vec![
1769 StructField::new("street", DataType::varchar(200)),
1770 StructField::new("zip", DataType::varchar(10)),
1771 ])),
1772 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1773 StructField::new("key", DataType::varchar(100)),
1774 StructField::new("value", DataType::varchar(1000)),
1775 ])))),
1776 ]
1777}
1778
1779#[cfg(test)]
1780mod tests {
1781 use super::*;
1782 use crate::dialect::DialectInfo;
1783 use razeri_plugin_contract::{
1784 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1785 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1786 TaskRenderer, TextCast,
1787 };
1788 use std::marker::PhantomData;
1789
1790 struct BrokenPlugin;
1798
1799 impl SqlRenderer for BrokenPlugin {
1800 fn render_data_type(&self, _dt: &DataType) -> String {
1801 String::new()
1802 }
1803 fn quote_identifier(&self, name: &str) -> String {
1804 name.to_owned()
1805 }
1806 fn render_cast_type(&self, _dt: &DataType) -> String {
1807 String::new()
1808 }
1809 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1810 name.to_owned()
1811 }
1812 fn render_current_timestamp(&self) -> String {
1813 String::new()
1814 }
1815 fn dialect_name(&self) -> &'static str {
1816 "brokendb"
1817 }
1818 }
1820 impl ExpressionRenderer for BrokenPlugin {}
1821 impl DdlRenderer for BrokenPlugin {}
1822 impl DmlRenderer for BrokenPlugin {}
1823 impl ProcedureRenderer for BrokenPlugin {}
1824 impl FormulaRenderer for BrokenPlugin {}
1825 impl TaskRenderer for BrokenPlugin {}
1826 impl ReplicationRenderer for BrokenPlugin {}
1827 impl StorageRenderer for BrokenPlugin {}
1828 impl DialectCapabilities for BrokenPlugin {
1829 fn supports_table_functions(&self) -> bool {
1832 false
1833 }
1834 }
1835 impl PlatformDialect for BrokenPlugin {
1836 fn info(&self) -> DialectInfo {
1837 DialectInfo {
1838 name: "brokendb",
1839 display_name: "Broken DB",
1840 aliases: &[],
1841 dialect_family: DialectFamily::Custom(""),
1842 family: "Broken",
1843 is_cloud_native: false,
1844 is_olap: false,
1845 is_postgres_compatible: false,
1846 versions: &[],
1847 deploy_script: None,
1848 }
1849 }
1850 }
1851
1852 trait FixtureName: Send + Sync + 'static {
1861 const NAME: &'static str;
1863 const CONCAT: Concat = Concat::Function("CONCAT");
1867 const POWER: Option<&'static str> = Some("^");
1870 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1872 const DROP_LOSES_ITS_COLUMN: bool = false;
1874 const COMPARES_SOMETHING_ELSE: bool = false;
1876 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1879 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1881 const KEY_REFUSED: Option<&'static str> = None;
1884 }
1885
1886 struct Unlinked;
1889 impl FixtureName for Unlinked {
1890 const NAME: &'static str = "healthydb";
1891 }
1892
1893 struct Outside;
1896 impl FixtureName for Outside {
1897 const NAME: &'static str = "outsidedb";
1898 }
1899
1900 struct BinaryConcat;
1905 impl FixtureName for BinaryConcat {
1906 const NAME: &'static str = "binaryconcatdb";
1907 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1908 }
1909
1910 struct OperatorConcat;
1914 impl FixtureName for OperatorConcat {
1915 const NAME: &'static str = "operatorconcatdb";
1916 const CONCAT: Concat = Concat::Operator("||");
1917 }
1918
1919 struct NamelessConcat;
1921 impl FixtureName for NamelessConcat {
1922 const NAME: &'static str = "namelessconcatdb";
1923 const CONCAT: Concat = Concat::Operator(" ");
1924 }
1925
1926 struct NoPowerOperator;
1929 impl FixtureName for NoPowerOperator {
1930 const NAME: &'static str = "nopowerdb";
1931 const POWER: Option<&'static str> = None;
1932 }
1933
1934 struct BlankPowerOperator;
1936 impl FixtureName for BlankPowerOperator {
1937 const NAME: &'static str = "blankpowerdb";
1938 const POWER: Option<&'static str> = Some(" ");
1939 }
1940
1941 struct ComparesSomethingElse;
1943 impl FixtureName for ComparesSomethingElse {
1944 const NAME: &'static str = "comparesotherdb";
1945 const COMPARES_SOMETHING_ELSE: bool = true;
1946 }
1947
1948 struct DropLosesItsColumn;
1950 impl FixtureName for DropLosesItsColumn {
1951 const NAME: &'static str = "droplosesdb";
1952 const DROP_LOSES_ITS_COLUMN: bool = true;
1953 }
1954
1955 struct BeginWithoutTransactions;
1958 impl FixtureName for BeginWithoutTransactions {
1959 const NAME: &'static str = "beginwithouttxdb";
1960 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1961 }
1962
1963 struct NoShiftOperator;
1965 impl FixtureName for NoShiftOperator {
1966 const NAME: &'static str = "noshiftdb";
1967 const SHIFT_LEFT: Option<&'static str> = None;
1968 }
1969
1970 struct BlankShiftOperator;
1972 impl FixtureName for BlankShiftOperator {
1973 const NAME: &'static str = "blankshiftdb";
1974 const SHIFT_LEFT: Option<&'static str> = Some("");
1975 }
1976
1977 struct DescribedClient;
1979 impl FixtureName for DescribedClient {
1980 const NAME: &'static str = "describedclientdb";
1981 const DEPLOY: Option<crate::dialect::DeployScript> =
1982 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1983 command: "sqlite3",
1984 run_file: &["-bail", "{target}", ".read {file}"],
1985 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1986 block_terminator: None,
1987 }));
1988 }
1989
1990 struct FilelessClient;
1992 impl FixtureName for FilelessClient {
1993 const NAME: &'static str = "filelessclientdb";
1994 const DEPLOY: Option<crate::dialect::DeployScript> =
1995 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1996 command: "sqlite3",
1997 run_file: &["-bail", "{target}"],
1998 read_rows: None,
1999 block_terminator: None,
2000 }));
2001 }
2002
2003 struct BlankBlockEnd;
2005 impl FixtureName for BlankBlockEnd {
2006 const NAME: &'static str = "blankblockenddb";
2007 const DEPLOY: Option<crate::dialect::DeployScript> =
2008 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2009 command: "isql",
2010 run_file: &["-bail", "-input", "{file}", "{target}"],
2011 read_rows: None,
2012 block_terminator: Some(crate::dialect::BlockTerminator {
2013 open: "SET TERM ^ ;",
2014 end: " ",
2015 close: "SET TERM ; ^",
2016 }),
2017 }));
2018 }
2019
2020 struct Answered<N: FixtureName>(PhantomData<N>);
2021
2022 impl<N: FixtureName> Answered<N> {
2023 const fn new() -> Self {
2024 Self(PhantomData)
2025 }
2026 }
2027
2028 type HealthyPlugin = Answered<Unlinked>;
2029 type OutsidePlugin = Answered<Outside>;
2030
2031 inventory::submit! {
2032 crate::builder::DialectRegistration::new(
2033 Outside::NAME,
2034 || std::sync::Arc::new(OutsidePlugin::new()),
2035 )
2036 }
2037
2038 struct KeyRefused;
2041 impl FixtureName for KeyRefused {
2042 const NAME: &'static str = "keyrefuseddb";
2043 const KEY_REFUSED: Option<&'static str> = Some(
2044 "-- Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2045 a function.\n--\n-- Load into an engine that computes the key, and read it \
2046 from here.\n",
2047 );
2048 }
2049
2050 struct KeyRefusedSilently;
2052 impl FixtureName for KeyRefusedSilently {
2053 const NAME: &'static str = "keyrefusedsilentlydb";
2054 const KEY_REFUSED: Option<&'static str> = Some("");
2055 }
2056
2057 fn graded_alone<N: FixtureName>() -> Report {
2060 let mut registry = crate::dialect::PlatformRegistry::new();
2061 registry.register(N::NAME.to_owned(), std::sync::Arc::new(Answered::<N>::new()));
2062 check_with_registry(&Answered::<N>::new(), ®istry)
2063 }
2064
2065 impl<N: FixtureName> SqlRenderer for Answered<N> {
2066 fn render_data_type(&self, dt: &DataType) -> String {
2067 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2068 }
2069 fn quote_identifier(&self, name: &str) -> String {
2070 format!("\"{name}\"")
2071 }
2072 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2073 schema.map_or_else(
2074 || self.quote_identifier(name),
2075 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
2076 )
2077 }
2078 fn render_current_timestamp(&self) -> String {
2079 "NOW()".to_owned()
2080 }
2081 fn dialect_name(&self) -> &'static str {
2082 N::NAME
2083 }
2084 fn sid_spelling(&self) -> SidSpelling {
2085 if N::KEY_REFUSED.is_some() {
2086 return SidSpelling {
2087 platform: razeri_plugin_contract::UNKNOWN_PLATFORM,
2088 ..razeri_plugin_contract::ANSI_SID_SPELLING
2089 };
2090 }
2091 SidSpelling {
2092 platform: N::NAME,
2093 hash: ("answered_xxhash64(", ")"),
2094 cast_type: TextCast::Direct("STRING"),
2095 concat: N::CONCAT,
2096 through_helper: false,
2097 ..razeri_plugin_contract::ANSI_SID_SPELLING
2098 }
2099 }
2100 fn procedure_form(&self) -> ProcedureForm {
2101 ProcedureForm::Unsupported(
2102 "This engine has no stored procedures; write the load as a scheduled query \
2103 instead.",
2104 )
2105 }
2106 fn scalar_function_form(&self) -> ScalarFunctionForm {
2107 ScalarFunctionForm::PostgreSql
2108 }
2109 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
2110 Box::new(Self::new())
2111 }
2112 }
2113 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
2114 fn render_comparable(
2115 &self,
2116 value: &str,
2117 _data_type: &DataType,
2118 _held: HeldAs,
2119 ) -> Option<String> {
2120 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
2121 }
2122 }
2123 impl<N: FixtureName> DdlRenderer for Answered<N> {
2124 fn render_drop_column(
2125 &self,
2126 table: WrittenObject<'_>,
2127 column: &str,
2128 only_where_it_stands: bool,
2129 ) -> Option<razeri_plugin_contract::Written> {
2130 if N::DROP_LOSES_ITS_COLUMN {
2131 return Some(razeri_plugin_contract::Written::Statement(format!(
2132 "ALTER TABLE {} DROP COLUMN c",
2133 table.relation
2134 )));
2135 }
2136 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
2137 Some(razeri_plugin_contract::Written::Statement(format!(
2138 "ALTER TABLE {} DROP COLUMN{guard} {column}",
2139 table.relation
2140 )))
2141 }
2142 }
2143 impl<N: FixtureName> DmlRenderer for Answered<N> {}
2144 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
2145 impl<N: FixtureName> FormulaRenderer for Answered<N> {
2146 fn render_power_operator(&self) -> Option<String> {
2147 N::POWER.map(str::to_owned)
2148 }
2149 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
2150 match operator {
2151 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
2152 other => Some(other.standard_spelling().to_owned()),
2153 }
2154 }
2155 }
2156 impl<N: FixtureName> TaskRenderer for Answered<N> {}
2157 impl<N: FixtureName> ReplicationRenderer for Answered<N> {
2158 fn sid_prerequisites(&self) -> String {
2159 N::KEY_REFUSED.unwrap_or_default().to_owned()
2160 }
2161 fn sid_prerequisite_kind(&self) -> SidPrerequisiteKind {
2162 if N::KEY_REFUSED.is_some() {
2163 SidPrerequisiteKind::Refused
2164 } else {
2165 razeri_plugin_contract::kind_of_prerequisite_script(&self.sid_prerequisites())
2166 }
2167 }
2168 }
2169 impl<N: FixtureName> StorageRenderer for Answered<N> {}
2170 impl<N: FixtureName> DialectCapabilities for Answered<N> {
2171 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
2172 N::BEGIN_WITHOUT_TRANSACTIONS
2173 }
2174 fn supports_multi_statement_transactions(&self) -> bool {
2175 !N::BEGIN_WITHOUT_TRANSACTIONS
2176 }
2177 fn merge_shape(&self) -> MergeShape {
2178 MergeShape::Merge(MergeRestrictions::none())
2179 }
2180 }
2181 impl<N: FixtureName> PlatformDialect for Answered<N> {
2182 fn info(&self) -> DialectInfo {
2183 DialectInfo {
2184 name: N::NAME,
2185 display_name: "An Answered Engine",
2186 aliases: &[],
2189 dialect_family: DialectFamily::Custom(N::NAME),
2190 family: "Answered",
2191 is_cloud_native: false,
2192 is_olap: false,
2193 is_postgres_compatible: false,
2194 versions: &[],
2195 deploy_script: N::DEPLOY,
2196 }
2197 }
2198 }
2199
2200 struct Twin;
2206
2207 impl SqlRenderer for Twin {
2208 fn render_data_type(&self, dt: &DataType) -> String {
2209 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2210 }
2211 fn quote_identifier(&self, name: &str) -> String {
2212 format!("\"{name}\"")
2213 }
2214 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2215 self.quote_identifier(name)
2216 }
2217 fn render_current_timestamp(&self) -> String {
2218 "NOW()".to_owned()
2219 }
2220 }
2221 impl ExpressionRenderer for Twin {}
2222 impl DdlRenderer for Twin {}
2223 impl DmlRenderer for Twin {}
2224 impl ProcedureRenderer for Twin {}
2225 impl FormulaRenderer for Twin {}
2226 impl TaskRenderer for Twin {}
2227 impl ReplicationRenderer for Twin {}
2228 impl StorageRenderer for Twin {}
2229 impl DialectCapabilities for Twin {}
2230 impl PlatformDialect for Twin {
2231 fn info(&self) -> DialectInfo {
2232 DialectInfo {
2233 name: "twindb",
2234 display_name: "Twin DB",
2235 aliases: &["healthydb"],
2237 dialect_family: DialectFamily::Custom("twindb"),
2238 family: "Twin",
2239 is_cloud_native: false,
2240 is_olap: false,
2241 is_postgres_compatible: false,
2242 versions: &[],
2243 deploy_script: None,
2244 }
2245 }
2246 }
2247
2248 fn mentions(report: &Report, needle: &str) -> bool {
2250 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2251 }
2252
2253 #[test]
2257 fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2258 let report = check(&BrokenPlugin);
2259 let printed = report.to_string();
2260 let first = printed.lines().next().unwrap_or_default();
2261 assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2262 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2263 assert!(printed.contains("Taken as the contract's default"), "{printed}");
2264 let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2265 assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2266 let view = report
2267 .taken_as_default
2268 .iter()
2269 .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2270 .expect("listed");
2271 assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2272 assert_eq!(view.shows_in, "schema.sql");
2273 assert!(printed.contains(&view.answer), "{printed}");
2274 }
2275
2276 #[test]
2280 fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2281 let report = check(&Answered::<Outside>::new());
2282 let printed = report.to_string();
2283 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2284 assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2285 }
2286
2287 #[test]
2291 fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2292 let report = check(&BrokenPlugin);
2293 let key = report
2294 .findings
2295 .iter()
2296 .find(|finding| finding.what.contains("surrogate-key spelling"))
2297 .expect("the key's finding");
2298 for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2299 assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2300 }
2301 assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2302 }
2303
2304 #[test]
2308 fn the_kit_catches_a_deliberately_broken_plugin() {
2309 let report = check(&BrokenPlugin);
2310 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2311
2312 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2313 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2314 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2315 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2316 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2317 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2318 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2319 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2320 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2321 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2322 assert!(mentions(&report, "supports_table_functions"), "paired fact:\n{report}");
2323
2324 assert!(
2325 report.count(Severity::Blocking) >= 8,
2326 "the broken plugin owes more than {} blocking fixes:\n{report}",
2327 report.count(Severity::Blocking)
2328 );
2329 }
2330
2331 #[test]
2335 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2336 let report = check(&HealthyPlugin::new());
2337 let unregistered: Vec<&Finding> =
2338 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2339 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2340 assert_eq!(
2341 report.findings.len(),
2342 1,
2343 "a plugin that answered every question owes nothing else:\n{report}"
2344 );
2345 }
2346
2347 #[test]
2352 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2353 let binary = Answered::<BinaryConcat>::new();
2354 let report = check(&binary);
2355 assert_eq!(
2356 report.findings.len(),
2357 check(&HealthyPlugin::new()).findings.len(),
2358 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2359 );
2360 assert!(
2361 !report.to_string().contains("joins"),
2362 "nothing about the join is outstanding:\n{report}"
2363 );
2364
2365 let key = razeri_plugin_contract::sid::sid_expression(
2366 SqlRenderer::sid_spelling(&binary),
2367 razeri_plugin_contract::sid::Backslash::of(&binary),
2368 None,
2369 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2370 );
2371 assert!(
2372 key.contains("CONCAT(CONCAT("),
2373 "a two-argument engine gets one call per pair:\n{key}"
2374 );
2375 }
2376
2377 #[test]
2381 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2382 let operator = Answered::<OperatorConcat>::new();
2383 let report = check(&operator);
2384 assert_eq!(
2385 report.findings.len(),
2386 check(&HealthyPlugin::new()).findings.len(),
2387 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2388 );
2389 let key = razeri_plugin_contract::sid::sid_expression(
2390 SqlRenderer::sid_spelling(&operator),
2391 razeri_plugin_contract::sid::Backslash::of(&operator),
2392 None,
2393 &["a".to_owned(), "b".to_owned()],
2394 );
2395 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2396 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2397 }
2398
2399 #[test]
2402 fn a_join_that_names_nothing_must_be_fixed() {
2403 let report = check(&Answered::<NamelessConcat>::new());
2404 assert!(
2405 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2406 && finding.what.contains("joins the parts of a composite key")),
2407 "{report}"
2408 );
2409 assert!(
2410 !check(&Answered::<OperatorConcat>::new())
2411 .findings
2412 .iter()
2413 .any(|finding| finding.what.contains("joins the parts of a composite key")),
2414 "a named operator is not the finding"
2415 );
2416 }
2417
2418 #[test]
2422 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2423 let none = check(&Answered::<NoPowerOperator>::new());
2424 assert_eq!(
2425 none.findings.len(),
2426 check(&HealthyPlugin::new()).findings.len(),
2427 "saying the engine has no such operator owes nothing:\n{none}"
2428 );
2429 let blank = check(&Answered::<BlankPowerOperator>::new());
2430 assert!(
2431 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2432 && finding.what.contains("render_power_operator")),
2433 "{blank}"
2434 );
2435 }
2436
2437 #[test]
2440 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2441 let none = check(&Answered::<NoShiftOperator>::new());
2442 assert_eq!(
2443 none.findings.len(),
2444 check(&HealthyPlugin::new()).findings.len(),
2445 "saying the engine has no left shift owes nothing:\n{none}"
2446 );
2447 let blank = check(&Answered::<BlankShiftOperator>::new());
2448 assert!(
2449 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2450 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2451 "{blank}"
2452 );
2453 }
2454
2455 #[test]
2458 fn a_drop_that_loses_its_column_must_be_fixed() {
2459 let healthy = check(&HealthyPlugin::new());
2460 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2461 let lost = check(&Answered::<DropLosesItsColumn>::new());
2462 assert!(
2463 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2464 && finding.what.contains("render_drop_column")),
2465 "{lost}"
2466 );
2467 }
2468
2469 #[test]
2472 fn a_comparison_that_loses_its_value_must_be_fixed() {
2473 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2474 let report = check(&Answered::<ComparesSomethingElse>::new());
2475 assert!(
2476 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2477 && finding.what.contains("render_comparable")),
2478 "{report}"
2479 );
2480 }
2481
2482 #[test]
2486 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2487 let report = check(&Answered::<BeginWithoutTransactions>::new());
2488 assert!(
2489 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2490 && finding.what.contains("leaves the commit to the client")),
2491 "{report}"
2492 );
2493 }
2494
2495 #[test]
2497 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2498 let report = check(&Answered::<BlankBlockEnd>::new());
2499 assert!(
2500 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2501 && finding.what.contains("block terminator")),
2502 "{report}"
2503 );
2504 }
2505
2506 #[test]
2509 fn a_deploy_client_has_to_be_told_which_file_to_run() {
2510 let described = check(&Answered::<DescribedClient>::new());
2511 assert_eq!(
2512 described.findings.len(),
2513 check(&HealthyPlugin::new()).findings.len(),
2514 "a client described in full owes nothing:\n{described}"
2515 );
2516 let fileless = check(&Answered::<FilelessClient>::new());
2517 assert!(
2518 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2519 && finding.what.contains("run_file never names the file")),
2520 "{fileless}"
2521 );
2522 }
2523
2524 #[test]
2528 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2529 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2530 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2531 fn render_data_type(&self, dt: &DataType) -> String {
2532 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2533 }
2534 fn quote_identifier(&self, name: &str) -> String {
2535 format!("\"{name}\"")
2536 }
2537 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2538 match schema {
2539 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2540 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2541 None => self.quote_identifier(name),
2542 }
2543 }
2544 fn render_current_timestamp(&self) -> String {
2545 "CURRENT_TIMESTAMP".to_owned()
2546 }
2547 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2548 Some(format!("{schema}_{name}"))
2549 }
2550 }
2551 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2552 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2553 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2554 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2555 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2556 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2557 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2558 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2559 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2560 fn supports_schemas(&self) -> bool {
2561 S
2562 }
2563 }
2564 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2565 fn info(&self) -> DialectInfo {
2566 DialectInfo {
2567 name: "folderdb",
2568 display_name: "Folder DB",
2569 aliases: &[],
2570 dialect_family: DialectFamily::Custom("folderdb"),
2571 family: "Folder",
2572 is_cloud_native: false,
2573 is_olap: false,
2574 is_postgres_compatible: false,
2575 versions: &[],
2576 deploy_script: None,
2577 }
2578 }
2579 }
2580
2581 let fold_findings = |dialect: &dyn PlatformDialect| {
2582 let mut findings = Vec::new();
2583 check_schema_fold(dialect, &mut findings);
2584 findings
2585 };
2586 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2587 let disagrees = fold_findings(&Folder::<false, false>);
2588 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2589 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2590 let has_schemas = fold_findings(&Folder::<true, true>);
2591 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2592 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2593 }
2594
2595 #[test]
2604 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2605 let mut quiet = Vec::new();
2606 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2607 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2608
2609 let mut stated = Vec::new();
2610 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2611 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2612 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2613 assert!(
2614 stated[0].what.contains("literal"),
2615 "the finding names the restriction:\n{}",
2616 stated[0].what
2617 );
2618 assert!(
2619 stated[0].fix.contains("dynamic-execution"),
2620 "and says what Razeri does with the answer:\n{}",
2621 stated[0].fix
2622 );
2623
2624 let mut two_clauses = Vec::new();
2627 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2628 assert!(two_clauses.is_empty(), "one restriction is not the other");
2629 }
2630
2631 #[test]
2641 fn a_stated_key_refusal_answers_the_key() {
2642 let report = graded_alone::<KeyRefused>();
2643 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2644 assert_eq!(report.count(Severity::Unanswered), 0, "{report}");
2645 assert!(report.is_clean(), "the dialect is ready:\n{report}");
2646 let listed: Vec<_> = report
2647 .findings
2648 .iter()
2649 .filter(|finding| finding.what.contains("cannot compute razeri's surrogate key"))
2650 .collect();
2651 assert_eq!(listed.len(), 1, "{report}");
2652 assert_eq!(listed[0].severity, Severity::Note, "{report}");
2653 assert_eq!(
2654 listed[0].fix, "its reason: Key Refused DB cannot compute razeri's surrogate key.",
2655 "{report}"
2656 );
2657 assert!(
2658 !report.to_string().contains("states no surrogate-key spelling"),
2659 "a refusing dialect is never told to spell the key:\n{report}"
2660 );
2661 assert_eq!(
2662 key_refusal_reason(&Answered::<KeyRefused>::new()).as_deref(),
2663 Some(
2664 "Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2665 a function.\n\nLoad into an engine that computes the key, and read it from here."
2666 )
2667 );
2668 assert_eq!(key_refusal_reason(&OutsidePlugin::new()), None);
2669 }
2670
2671 #[test]
2674 fn a_key_refusal_with_no_reason_is_to_fix() {
2675 let report = graded_alone::<KeyRefusedSilently>();
2676 let owed: Vec<_> = report
2677 .findings
2678 .iter()
2679 .filter(|finding| finding.severity == Severity::Blocking)
2680 .collect();
2681 assert_eq!(owed.len(), 1, "{report}");
2682 assert!(owed[0].what.contains("gives no reason"), "{report}");
2683 assert!(owed[0].fix.contains("`ReplicationRenderer::sid_prerequisites`"), "{report}");
2684 }
2685
2686 #[test]
2687 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2688 assert!(
2689 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2690 "the fixture registers through the registry, which is the whole path being graded"
2691 );
2692 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2696 assert!(
2697 registry
2698 .list()
2699 .iter()
2700 .filter(|info| info.name != Outside::NAME)
2701 .all(|info| !info.answers_to(Outside::NAME)),
2702 "the fixture's name must be one no other platform claims"
2703 );
2704
2705 let report = check(&OutsidePlugin::new());
2706 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2707 assert!(report.is_clean(), "and the report says so:\n{report}");
2708 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2709 assert!(
2710 report.to_string().contains("nothing to fix"),
2711 "a clean dialect is told it is clean:\n{report}"
2712 );
2713 }
2714
2715 #[test]
2721 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2722 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2723 for info in registry.list() {
2724 assert_eq!(
2725 info.spellings().first().copied(),
2726 Some(info.name),
2727 "a platform's own name leads its spellings"
2728 );
2729 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2730 }
2731 }
2732
2733 #[test]
2739 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2740 let mut registry = crate::dialect::PlatformRegistry::new();
2741 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2742 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2743
2744 let report = check_with_registry(&Twin, ®istry);
2745 let collision = report
2746 .findings
2747 .iter()
2748 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2749 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2750 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2751 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2752 assert!(collision.what.contains("twindb"), "{collision:?}");
2753 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2754 assert!(
2755 !mentions(&report, "does not resolve"),
2756 "a collision is not an unregistered platform:\n{report}"
2757 );
2758 }
2759
2760 #[test]
2764 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2765 let mut registry = crate::dialect::PlatformRegistry::new();
2766 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2767
2768 let mut findings = Vec::new();
2769 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2770 assert!(findings.is_empty(), "{findings:?}");
2771
2772 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2773 assert!(
2774 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2775 "'{spelling}' is the same platform"
2776 );
2777 }
2778 assert!(
2779 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2780 );
2781 }
2782
2783 #[test]
2788 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2789 let mut registry = crate::dialect::PlatformRegistry::new();
2790 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2791
2792 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2793 assert!(
2794 report.is_clean(),
2795 "a loaded dialect that answered everything owes nothing:\n{report}"
2796 );
2797 }
2798
2799 #[test]
2803 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2804 let mut collided = crate::dialect::PlatformRegistry::new();
2805 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2806 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2807
2808 let mut fixes: Vec<String> = Vec::new();
2809 for report in [
2810 check(&BrokenPlugin),
2811 check(&HealthyPlugin::new()),
2812 check(&OutsidePlugin::new()),
2813 check_with_registry(&Twin, &collided),
2816 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2817 ] {
2818 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2819 }
2820 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2821 for fix in &fixes {
2822 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2823 assert!(
2824 !fix.contains(path),
2825 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2826 );
2827 }
2828 }
2829 }
2830
2831 #[test]
2838 fn no_finding_holds_a_run_of_spaces() {
2839 struct Declining;
2840 impl SqlRenderer for Declining {
2841 fn render_data_type(&self, dt: &DataType) -> String {
2842 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2843 }
2844 fn quote_identifier(&self, name: &str) -> String {
2845 format!("\"{name}\"")
2846 }
2847 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2848 name.to_owned()
2849 }
2850 fn render_current_timestamp(&self) -> String {
2851 "CURRENT_TIMESTAMP".to_owned()
2852 }
2853 fn procedure_form(&self) -> ProcedureForm {
2854 ProcedureForm::Custom("Declining PSQL")
2855 }
2856 }
2857 impl ExpressionRenderer for Declining {}
2858 impl DdlRenderer for Declining {
2859 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2860 None
2861 }
2862 }
2863 impl DialectCapabilities for Declining {
2864 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2865 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2866 .and_a_nested_field()
2867 }
2868 }
2869 impl DmlRenderer for Declining {}
2870 impl ProcedureRenderer for Declining {}
2871 impl FormulaRenderer for Declining {}
2872 impl TaskRenderer for Declining {}
2873 impl ReplicationRenderer for Declining {}
2874 impl StorageRenderer for Declining {}
2875 impl PlatformDialect for Declining {
2876 fn info(&self) -> DialectInfo {
2877 DialectInfo {
2878 name: "decliningdb",
2879 display_name: "Declining DB",
2880 aliases: &[],
2881 dialect_family: DialectFamily::Custom("decliningdb"),
2882 family: "Declining",
2883 is_cloud_native: false,
2884 is_olap: false,
2885 is_postgres_compatible: false,
2886 versions: &[],
2887 deploy_script: None,
2888 }
2889 }
2890 }
2891
2892 let mut findings = Vec::new();
2893 check_evolution(&Declining, &mut findings);
2894 check_parameter_type_spelling(&Declining, &mut findings);
2895 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2896
2897 for report in [
2898 check(&BrokenPlugin),
2899 check(&HealthyPlugin::new()),
2900 check(&OutsidePlugin::new()),
2901 check(&Declining),
2902 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2903 ] {
2904 findings.extend(report.findings);
2905 }
2906 for finding in &findings {
2907 for text in [&finding.what, &finding.fix] {
2908 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2909 }
2910 }
2911 }
2912
2913 #[test]
2923 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2924 for (kind, form_method, render_method) in [
2925 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2926 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2927 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2928 ] {
2929 let mut promised = Vec::new();
2930 check_custom_grammar(
2931 CustomGrammar {
2932 kind,
2933 grammar: "PL/SQL",
2934 form_method,
2935 render_method,
2936 noun: "routines",
2937 },
2938 |_| None,
2939 &mut promised,
2940 );
2941 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2942 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2943 assert!(promised[0].what.contains(form_method), "{promised:?}");
2944 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2945 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2946
2947 let mut kept = Vec::new();
2949 check_custom_grammar(
2950 CustomGrammar {
2951 kind,
2952 grammar: "PL/SQL",
2953 form_method,
2954 render_method,
2955 noun: "routines",
2956 },
2957 |spec| Some(format!("CREATE {} …", spec.name)),
2958 &mut kept,
2959 );
2960 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2961 }
2962 }
2963
2964 #[test]
2969 fn a_procedure_form_and_the_procedure_capability_must_agree() {
2970 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2971 let mut findings = Vec::new();
2972 check_routine_forms(dialect, &mut findings);
2973 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
2974 }
2975
2976 struct Claims<const CUSTOM: bool, const HAS: bool>;
2979 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2980 fn render_data_type(&self, dt: &DataType) -> String {
2981 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2982 }
2983 fn quote_identifier(&self, name: &str) -> String {
2984 format!("\"{name}\"")
2985 }
2986 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2987 self.quote_identifier(name)
2988 }
2989 fn render_current_timestamp(&self) -> String {
2990 "CURRENT_TIMESTAMP".to_owned()
2991 }
2992 fn procedure_form(&self) -> ProcedureForm {
2993 if CUSTOM {
2994 ProcedureForm::Custom("Firebird PSQL")
2995 } else {
2996 ProcedureForm::Unsupported(
2997 "This engine has no stored procedures; schedule the load as a query.",
2998 )
2999 }
3000 }
3001 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
3002 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
3003 }
3004 }
3005 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
3006 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
3007 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
3008 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
3009 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
3010 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
3011 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
3012 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
3013 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
3014 fn supports_stored_procedures(&self) -> bool {
3015 HAS
3016 }
3017 }
3018 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
3019 fn info(&self) -> DialectInfo {
3020 DialectInfo {
3021 name: "claimsdb",
3022 display_name: "Claims DB",
3023 aliases: &[],
3024 dialect_family: DialectFamily::Custom("claimsdb"),
3025 family: "Claims",
3026 is_cloud_native: false,
3027 is_olap: false,
3028 is_postgres_compatible: false,
3029 versions: &[],
3030 deploy_script: None,
3031 }
3032 }
3033 }
3034
3035 let disagreement = |report: &Report| {
3036 report
3037 .findings
3038 .iter()
3039 .filter(|finding| {
3040 finding.severity == Severity::Blocking
3041 && (finding.what.contains("says its engine has none")
3042 || finding.what.contains("refuses to write one"))
3043 })
3044 .count()
3045 };
3046 let mut findings = Vec::new();
3047 check_routine_forms(&Claims::<true, false>, &mut findings);
3048 let report =
3049 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
3050 assert_eq!(disagreement(&report), 1, "{report}");
3051 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
3052
3053 for (agrees, label) in [
3054 (check_forms(&Claims::<true, true>), "spelled and used"),
3055 (check_forms(&Claims::<false, false>), "refused and absent"),
3056 ] {
3057 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
3058 }
3059 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
3060 }
3061
3062 #[test]
3068 fn a_custom_render_method_that_returns_nothing_is_blocking() {
3069 let mut findings = Vec::new();
3070 check_custom_grammar(
3071 CustomGrammar {
3072 kind: RoutineKind::Procedure,
3073 grammar: "PL/SQL",
3074 form_method: "procedure_form",
3075 render_method: "render_procedure",
3076 noun: "procedures",
3077 },
3078 |_| Some(" \n".to_owned()),
3079 &mut findings,
3080 );
3081 assert_eq!(findings.len(), 1, "{findings:?}");
3082 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
3083 }
3084
3085 #[test]
3087 fn a_custom_grammar_with_no_name_is_blocking() {
3088 let mut findings = Vec::new();
3089 check_custom_grammar(
3090 CustomGrammar {
3091 kind: RoutineKind::ScalarFunction,
3092 grammar: " ",
3093 form_method: "scalar_function_form",
3094 render_method: "render_scalar_function",
3095 noun: "scalar functions",
3096 },
3097 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
3098 &mut findings,
3099 );
3100 assert_eq!(findings.len(), 1, "{findings:?}");
3101 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
3102 }
3103
3104 #[test]
3112 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
3113 struct Overstated;
3114 impl SqlRenderer for Overstated {
3115 fn render_data_type(&self, dt: &DataType) -> String {
3116 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3117 }
3118 fn quote_identifier(&self, name: &str) -> String {
3119 format!("\"{name}\"")
3120 }
3121 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3122 name.to_owned()
3123 }
3124 fn render_current_timestamp(&self) -> String {
3125 "CURRENT_TIMESTAMP".to_owned()
3126 }
3127 fn dialect_name(&self) -> &'static str {
3128 "overstateddb"
3129 }
3130 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
3131 Some(razeri_plugin_contract::SidHelperBody {
3132 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
3133 body: " \n ",
3134 })
3135 }
3136 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
3137 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
3138 }
3139 }
3140 impl ExpressionRenderer for Overstated {}
3141 impl DdlRenderer for Overstated {}
3142 impl DmlRenderer for Overstated {}
3143 impl ProcedureRenderer for Overstated {}
3144 impl FormulaRenderer for Overstated {}
3145 impl TaskRenderer for Overstated {}
3146 impl ReplicationRenderer for Overstated {}
3147 impl StorageRenderer for Overstated {}
3148 impl DialectCapabilities for Overstated {}
3149 impl PlatformDialect for Overstated {
3150 fn info(&self) -> DialectInfo {
3151 DialectInfo {
3152 name: "overstateddb",
3153 display_name: "Overstated DB",
3154 aliases: &[],
3155 dialect_family: DialectFamily::Custom(""),
3156 family: "Overstated",
3157 is_cloud_native: false,
3158 is_olap: false,
3159 is_postgres_compatible: false,
3160 versions: &[],
3161 deploy_script: None,
3162 }
3163 }
3164 }
3165
3166 let report = check(&Overstated);
3167 for (fragment, why) in [
3168 ("supplies an empty one", "a blank helper body"),
3169 ("does not carry the path", "a file relation that drops its path"),
3170 ] {
3171 let found = report
3172 .findings
3173 .iter()
3174 .find(|f| f.what.contains(fragment))
3175 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
3176 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
3177 }
3178
3179 let quiet = check(&HealthyPlugin::new());
3182 for fragment in ["supplies an empty one", "does not carry the path"] {
3183 assert!(
3184 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
3185 "silence is the default and owes nothing:\n{quiet}"
3186 );
3187 }
3188 }
3189
3190 #[test]
3194 fn a_file_read_that_ignores_the_delimiter_quote_and_header_is_blocking() {
3195 struct Deaf<const OVERRIDES: bool>;
3196 impl<const OVERRIDES: bool> SqlRenderer for Deaf<OVERRIDES> {
3197 fn render_data_type(&self, dt: &DataType) -> String {
3198 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3199 }
3200 fn quote_identifier(&self, name: &str) -> String {
3201 format!("\"{name}\"")
3202 }
3203 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3204 name.to_owned()
3205 }
3206 fn render_current_timestamp(&self) -> String {
3207 "CURRENT_TIMESTAMP".to_owned()
3208 }
3209 fn delimited_file_relation(&self, path_literal: &str) -> Option<String> {
3210 Some(format!("scan({path_literal})"))
3211 }
3212 fn delimited_file_relation_for(&self, file: &DelimitedFile<'_>) -> Option<String> {
3213 if OVERRIDES {
3214 Some(format!("scan({})", file.path_literal))
3215 } else {
3216 file.is_plain().then(|| format!("scan({})", file.path_literal))
3218 }
3219 }
3220 }
3221 impl<const OVERRIDES: bool> ExpressionRenderer for Deaf<OVERRIDES> {}
3222 impl<const OVERRIDES: bool> DdlRenderer for Deaf<OVERRIDES> {}
3223 impl<const OVERRIDES: bool> DmlRenderer for Deaf<OVERRIDES> {}
3224 impl<const OVERRIDES: bool> ProcedureRenderer for Deaf<OVERRIDES> {}
3225 impl<const OVERRIDES: bool> FormulaRenderer for Deaf<OVERRIDES> {}
3226 impl<const OVERRIDES: bool> TaskRenderer for Deaf<OVERRIDES> {}
3227 impl<const OVERRIDES: bool> ReplicationRenderer for Deaf<OVERRIDES> {}
3228 impl<const OVERRIDES: bool> StorageRenderer for Deaf<OVERRIDES> {}
3229 impl<const OVERRIDES: bool> DialectCapabilities for Deaf<OVERRIDES> {}
3230 impl<const OVERRIDES: bool> PlatformDialect for Deaf<OVERRIDES> {
3231 fn info(&self) -> DialectInfo {
3232 DialectInfo {
3233 name: "deafdb",
3234 display_name: "Deaf DB",
3235 aliases: &[],
3236 dialect_family: DialectFamily::Custom(""),
3237 family: "Deaf",
3238 is_cloud_native: false,
3239 is_olap: false,
3240 is_postgres_compatible: false,
3241 versions: &[],
3242 deploy_script: None,
3243 }
3244 }
3245 }
3246
3247 let mut deaf = Vec::new();
3248 check_file_settings(&Deaf::<true>, &mut deaf);
3249 assert_eq!(deaf.len(), 3, "the delimiter, the quote and the header: {deaf:?}");
3250 assert!(deaf.iter().all(|finding| finding.severity == Severity::Blocking), "{deaf:?}");
3251 assert!(deaf[0].what.contains("delimited by `|`"), "{deaf:?}");
3252
3253 let mut default = Vec::new();
3254 check_file_settings(&Deaf::<false>, &mut default);
3255 assert!(default.is_empty(), "`None` is a complete answer: {default:?}");
3256 }
3257
3258 #[test]
3262 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
3263 let mut mute = Vec::new();
3264 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
3265 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
3266 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
3267
3268 let silence = check(&BrokenPlugin);
3269 assert!(
3270 silence
3271 .findings
3272 .iter()
3273 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
3274 "a dialect that never answered is unanswered, not wrong:\n{silence}"
3275 );
3276 assert!(
3277 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
3278 "the fix line names the way to state a measured refusal:\n{silence}"
3279 );
3280 }
3281
3282 #[test]
3286 fn the_report_reads_as_a_summary_then_a_fix_list() {
3287 let text = check(&BrokenPlugin).to_string();
3288 let first = text.lines().next().expect("a summary line");
3289 assert!(first.starts_with("brokendb:"), "{first}");
3290 assert!(first.contains("to fix"), "{first}");
3291 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
3292 }
3293}