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 RoutineKind, RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer,
50 StorageRenderer, TableFunctionForm, TableRole, UNKNOWN_PLATFORM, WrittenObject,
51 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
52 storage_strategy::{
53 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
54 },
55};
56use std::fmt;
57use std::fmt::Write as _;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum Severity {
62 Blocking,
65 Unanswered,
68 Note,
70}
71
72impl fmt::Display for Severity {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(match self {
75 Self::Blocking => "must fix",
76 Self::Unanswered => "unanswered",
77 Self::Note => "worth a look",
78 })
79 }
80}
81
82#[derive(Debug, Clone)]
85pub struct Finding {
86 pub severity: Severity,
88 pub what: String,
90 pub fix: String,
92}
93
94#[derive(Debug, Clone)]
96pub struct Report {
97 pub dialect: String,
99 pub findings: Vec<Finding>,
101 pub taken_as_default: Vec<TakenAsDefault>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TakenAsDefault {
111 pub name: &'static str,
113 pub shows_in: &'static str,
115 pub answer: String,
117}
118
119impl Report {
120 #[must_use]
124 pub fn is_clean(&self) -> bool {
125 !self.findings.iter().any(|f| f.severity != Severity::Note)
126 }
127
128 #[must_use]
130 pub fn count(&self, severity: Severity) -> usize {
131 self.findings.iter().filter(|f| f.severity == severity).count()
132 }
133}
134
135impl Report {
136 #[must_use]
143 pub fn head(&self) -> String {
144 let mut out = String::new();
145 let name = &self.dialect;
146 let blocking = self.count(Severity::Blocking);
147 let summary = if self.findings.is_empty() {
148 "nothing to fix".to_owned()
149 } else {
150 format!(
151 "{blocking} to fix, {} unanswered, {} worth a look",
152 self.count(Severity::Unanswered),
153 self.count(Severity::Note)
154 )
155 };
156 let _ = writeln!(out, "{name}: {summary}, in the answers this dialect states.");
157 let zero = if blocking == 0 {
158 let phrase = if self.findings.is_empty() { "nothing to fix" } else { "0 to fix" };
159 format!(" \"{phrase}\" means no question is open, not that the SQL runs.")
160 } else {
161 String::new()
162 };
163 let _ = writeln!(
164 out,
165 "This report read the dialect's answers and ran nothing on an engine.{zero} Only the \
166 SQL Razeri renders for it, run on your engine, shows that it runs."
167 );
168 for finding in &self.findings {
169 let _ = writeln!(out);
170 let _ = writeln!(out, " [{}] {}", finding.severity, finding.what);
171 let _ = writeln!(out, " → {}", finding.fix);
172 }
173 out
174 }
175}
176
177impl fmt::Display for Report {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.write_str(&self.head())?;
180 writeln!(f)?;
181 if self.taken_as_default.is_empty() {
182 return writeln!(
183 f,
184 "This dialect answers every question Razeri asks here about the shape of a \
185 statement differently from the contract's default."
186 );
187 }
188 let count = self.taken_as_default.len();
189 writeln!(
190 f,
191 "Taken as the contract's default: this dialect answers {count} {} as the contract's \
192 default does, so Razeri writes the default's SQL, shown beside each. Confirm each \
193 on your engine.",
194 if count == 1 { "question" } else { "questions" }
195 )?;
196 for taken in &self.taken_as_default {
197 writeln!(f)?;
198 writeln!(f, " {} → {}", taken.name, taken.answer)?;
199 writeln!(f, " in {}", taken.shows_in)?;
200 }
201 Ok(())
202 }
203}
204
205#[derive(Clone, Copy)]
208enum Asked {
209 Sql(fn(&dyn SqlRenderer) -> String),
210 Expression(fn(&dyn ExpressionRenderer) -> String),
211 Ddl(fn(&dyn DdlRenderer) -> String),
212 Capabilities(fn(&dyn DialectCapabilities) -> String),
213}
214
215#[derive(Clone, Copy)]
218struct Seam {
219 name: &'static str,
220 shows_in: &'static str,
221 asked: Asked,
222}
223
224impl Seam {
225 fn answer_of(&self, dialect: &dyn PlatformDialect) -> String {
227 match self.asked {
228 Asked::Sql(answer) => answer(dialect),
229 Asked::Expression(answer) => answer(dialect),
230 Asked::Ddl(answer) => answer(dialect),
231 Asked::Capabilities(answer) => answer(dialect),
232 }
233 }
234
235 fn default_of(&self, defaults: &ContractDefaults<'_>) -> String {
237 match self.asked {
238 Asked::Sql(answer) => answer(defaults),
239 Asked::Expression(answer) => answer(defaults),
240 Asked::Ddl(answer) => answer(defaults),
241 Asked::Capabilities(answer) => answer(defaults),
242 }
243 }
244}
245
246fn statement_seams() -> &'static [Seam] {
254 SEAMS
255}
256
257const SCHEMA: &str = "schema.sql";
258const LOADS: &str = "loads.sql";
259const RECONCILE: &str = "reconcile.sql";
260const EVERY: &str = "every file";
261const fn seam(name: &'static str, shows_in: &'static str, asked: Asked) -> Seam {
262 Seam { name, shows_in, asked }
263}
264const SEAMS: &[Seam] = &[
265 seam(
266 "DdlRenderer::supports_if_not_exists",
267 SCHEMA,
268 Asked::Ddl(|d| said(d.supports_if_not_exists())),
269 ),
270 seam(
271 "DdlRenderer::supports_primary_key_constraint",
272 SCHEMA,
273 Asked::Ddl(|d| said(d.supports_primary_key_constraint())),
274 ),
275 seam(
276 "DdlRenderer::supports_foreign_key_constraint",
277 SCHEMA,
278 Asked::Ddl(|d| said(d.supports_foreign_key_constraint())),
279 ),
280 seam(
281 "DdlRenderer::render_create_view_prefix",
282 SCHEMA,
283 Asked::Ddl(|d| quoted(d.render_create_view_prefix())),
284 ),
285 seam(
286 "DdlRenderer::replaces_a_view_that_is_read",
287 SCHEMA,
288 Asked::Ddl(|d| said(d.replaces_a_view_that_is_read())),
289 ),
290 seam(
291 "DdlRenderer::render_drop_view_prefix",
292 SCHEMA,
293 Asked::Ddl(|d| quoted(&d.render_drop_view_prefix(true))),
294 ),
295 seam(
296 "DdlRenderer::render_drop_table_prefix",
297 SCHEMA,
298 Asked::Ddl(|d| quoted(&d.render_drop_table_prefix(true))),
299 ),
300 seam(
301 "DdlRenderer::render_drop_function",
302 SCHEMA,
303 Asked::Ddl(|d| quoted(&d.render_drop_function("f", &["BIGINT"], true))),
304 ),
305 seam(
306 "DdlRenderer::supports_drop_function_arg_types",
307 SCHEMA,
308 Asked::Ddl(|d| said(d.supports_drop_function_arg_types())),
309 ),
310 seam(
311 "DdlRenderer::render_create_schema_statement",
312 SCHEMA,
313 Asked::Ddl(|d| quoted(&d.render_create_schema_statement("s", true))),
314 ),
315 seam(
316 "DialectCapabilities::supports_schemas",
317 SCHEMA,
318 Asked::Capabilities(|d| said(d.supports_schemas())),
319 ),
320 seam(
321 "SqlRenderer::uses_schema_in_qualified_names",
322 SCHEMA,
323 Asked::Sql(|d| said(d.uses_schema_in_qualified_names())),
324 ),
325 seam(
326 "DdlRenderer::supports_ddl_transactions",
327 SCHEMA,
328 Asked::Ddl(|d| said(d.supports_ddl_transactions())),
329 ),
330 seam(
331 "DdlRenderer::render_begin_transaction",
332 SCHEMA,
333 Asked::Ddl(|d| quoted(d.render_begin_transaction())),
334 ),
335 seam("DdlRenderer::render_commit", SCHEMA, Asked::Ddl(|d| quoted(d.render_commit()))),
336 seam(
337 "SqlRenderer::scalar_function_body_is_expression",
338 SCHEMA,
339 Asked::Sql(|d| said(d.scalar_function_body_is_expression())),
340 ),
341 seam(
342 "DialectCapabilities::max_identifier_length",
343 EVERY,
344 Asked::Capabilities(|d| said(d.max_identifier_length())),
345 ),
346 seam(
347 "DialectCapabilities::begin_transaction_keyword",
348 LOADS,
349 Asked::Capabilities(|d| quoted(d.begin_transaction_keyword())),
350 ),
351 seam(
352 "SqlRenderer::merge_source_requires_inlined_ctes",
353 LOADS,
354 Asked::Sql(|d| said(d.merge_source_requires_inlined_ctes())),
355 ),
356 seam(
357 "SqlRenderer::insert_accepts_leading_ctes",
358 LOADS,
359 Asked::Sql(|d| said(d.insert_accepts_leading_ctes())),
360 ),
361 seam(
362 "SqlRenderer::is_reserved_word",
363 LOADS,
364 Asked::Sql(|d| {
365 if d.is_reserved_word("matched") {
366 "`matched` is reserved: a load writes it quoted".to_owned()
367 } else {
368 "`matched` is free: a load writes `1 AS matched`".to_owned()
369 }
370 }),
371 ),
372 seam(
373 "SqlRenderer::render_empty_table",
374 LOADS,
375 Asked::Sql(|d| quoted(&d.render_empty_table("t"))),
376 ),
377 seam(
378 "SqlRenderer::render_procedure_variable_reference",
379 LOADS,
380 Asked::Sql(|d| quoted(&d.render_procedure_variable_reference("v"))),
381 ),
382 seam(
383 "ExpressionRenderer::render_concat",
384 LOADS,
385 Asked::Expression(|d| quoted(&d.render_concat(&["a".into(), "b".into()]))),
386 ),
387 seam(
388 "SqlRenderer::statement_separator",
389 LOADS,
390 Asked::Sql(|d| quoted(d.statement_separator())),
391 ),
392 seam(
393 "SqlRenderer::render_one_row_source",
394 RECONCILE,
395 Asked::Sql(|d| match d.render_one_row_source() {
396 Some(source) => quoted(&source),
397 None => "none: a `SELECT` with no `FROM`".to_owned(),
398 }),
399 ),
400];
401
402fn said(answer: impl fmt::Display) -> String {
404 format!("`{answer}`")
405}
406
407fn quoted(sql: &str) -> String {
409 format!("`{}`", sql.replace('\n', "\\n"))
410}
411
412#[must_use]
423pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
424 let defaults = ContractDefaults { graded: dialect };
425 statement_seams()
426 .iter()
427 .filter_map(|seam| {
428 let default = seam.default_of(&defaults);
429 (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
430 name: seam.name,
431 shows_in: seam.shows_in,
432 answer: default,
433 })
434 })
435 .collect()
436}
437
438struct ContractDefaults<'a> {
442 graded: &'a dyn PlatformDialect,
443}
444
445impl SqlRenderer for ContractDefaults<'_> {
446 fn render_data_type(&self, dt: &DataType) -> String {
447 self.graded.render_data_type(dt)
448 }
449
450 fn quote_identifier(&self, name: &str) -> String {
451 self.graded.quote_identifier(name)
452 }
453
454 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
455 self.graded.qualify_name(schema, name)
456 }
457
458 fn render_current_timestamp(&self) -> String {
459 self.graded.render_current_timestamp()
460 }
461}
462impl ExpressionRenderer for ContractDefaults<'_> {}
463impl DdlRenderer for ContractDefaults<'_> {}
464impl DialectCapabilities for ContractDefaults<'_> {}
465
466#[must_use]
473pub fn check(dialect: &dyn PlatformDialect) -> Report {
474 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
475}
476
477#[must_use]
480pub fn check_with_registry(
481 dialect: &dyn PlatformDialect,
482 registry: &crate::dialect::PlatformRegistry,
483) -> Report {
484 let info = dialect.info();
485 let mut findings = Vec::new();
486
487 check_registration(&info, registry, &mut findings);
488 check_family(info.dialect_family, &mut findings);
489 check_required_rendering(dialect, &mut findings);
490 check_decisions(dialect, &mut findings);
491 check_formula_renderer(dialect, &mut findings);
492 check_evolution(dialect, &mut findings);
493 check_run_again(dialect, &mut findings);
494 check_transactions(dialect, &mut findings);
495 check_paired_facts(dialect, &mut findings);
496 check_schema_fold(dialect, &mut findings);
497 check_deploy_client(&info, &mut findings);
498
499 findings.sort_by_key(|f| f.severity);
500 Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
501}
502
503fn check_registration(
514 info: &crate::dialect::DialectInfo,
515 registry: &crate::dialect::PlatformRegistry,
516 findings: &mut Vec<Finding>,
517) {
518 let name = info.name;
519 if matches!(
520 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
521 Err(crate::ResolveError::UnknownDialect { .. })
522 ) {
523 findings.push(Finding {
524 severity: Severity::Blocking,
525 what: format!(
526 "the name `{name}` does not resolve — nothing in this program registered it"
527 ),
528 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
529 crate's `lib.rs`, where `register` calls \
530 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
531 dialect one the registry finds, whether razeri loads your library from a \
532 plugin directory or links your crate in."
533 .to_owned(),
534 });
535 return;
536 }
537 for spelling in info.spellings() {
538 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
539 Ok(found) if found.info().name == name => {}
540 Ok(found) => findings.push(Finding {
541 severity: Severity::Blocking,
542 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
543 fix: format!(
544 "the registry holds `{name}` under another key. Register it under its own \
545 name, or drop `{spelling}` from its aliases."
546 ),
547 }),
548 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
549 severity: Severity::Blocking,
550 what: format!(
551 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
552 none of them — Razeri refuses that spelling rather than guessing which one \
553 a design meant",
554 claimed_by.join(" and ")
555 ),
556 fix: format!(
557 "give this dialect a name no other platform answers to. A platform name is \
558 read off the loaded dialects' own declared spellings, so a spelling another \
559 platform already claims is never `{name}`'s."
560 ),
561 }),
562 Err(_) => findings.push(Finding {
563 severity: Severity::Blocking,
564 what: format!(
565 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
566 ),
567 fix: format!(
568 "the `{name}` in the registry is a different build of this dialect and does \
569 not declare `{spelling}`. Register the one you are grading."
570 ),
571 }),
572 }
573 }
574}
575
576fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
579 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
580 findings.push(Finding {
581 severity: Severity::Blocking,
582 what: "this dialect's family is a custom one with no name".to_owned(),
583 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
584 prints and what a reader looks up."
585 .to_owned(),
586 });
587 }
588}
589
590fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
592 let declarable = declarable_types();
593 let empty_column: Vec<String> = declarable
594 .iter()
595 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
596 .map(|dt| format!("{dt:?}"))
597 .collect();
598 if !empty_column.is_empty() {
599 findings.push(Finding {
600 severity: Severity::Blocking,
601 what: format!(
602 "`render_data_type` answers nothing for {} of the types a design can declare",
603 empty_column.len()
604 ),
605 fix: "every declarable type needs a column spelling on this engine, even if the \
606 spelling is a wider type that stores it. A blank answer becomes a column \
607 declaration with no type in it."
608 .to_owned(),
609 });
610 }
611 let empty_cast: Vec<String> = declarable
612 .iter()
613 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
614 .map(|dt| format!("{dt:?}"))
615 .collect();
616 if !empty_cast.is_empty() {
617 findings.push(Finding {
618 severity: Severity::Blocking,
619 what: format!(
620 "`render_cast_type` answers nothing for {} declarable types",
621 empty_cast.len()
622 ),
623 fix: "the generated load opens by casting every source column to its declared type, \
624 so a blank answer fails the load on its first statement."
625 .to_owned(),
626 });
627 }
628 let quoted = SqlRenderer::quote_identifier(dialect, "order");
629 if quoted == "order" {
630 findings.push(Finding {
631 severity: Severity::Blocking,
632 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
633 reaches this engine bare"
634 .to_owned(),
635 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
636 backticks, or brackets."
637 .to_owned(),
638 });
639 }
640 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
641 findings.push(Finding {
642 severity: Severity::Blocking,
643 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
644 a row with it"
645 .to_owned(),
646 fix: "return this engine's own expression for the current moment.".to_owned(),
647 });
648 }
649}
650
651fn check_encoded_file_relation(
663 dialect: &dyn PlatformDialect,
664 probe: &str,
665 findings: &mut Vec<Finding>,
666) {
667 let Some(relation) =
668 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
669 else {
670 return;
671 };
672 if !relation.contains(probe) {
673 findings.push(Finding {
674 severity: Severity::Blocking,
675 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
676 the path it was given"
677 .to_owned(),
678 fix: "put the literal you were handed where your engine's file relation takes a \
679 path. It arrives already quoted and escaped."
680 .to_owned(),
681 });
682 }
683 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
684 findings.push(Finding {
685 severity: Severity::Blocking,
686 what: "this dialect states how to read a file in a named encoding and states no way \
687 to read a file at all"
688 .to_owned(),
689 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
690 first question before the second — a source whose bytes are UTF-8 never \
691 reaches the encoding arm — so an engine that answers only the second has \
692 written a relation nothing will ask it for."
693 .to_owned(),
694 });
695 }
696}
697
698fn check_file_settings(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
711 let columns = ["a".to_owned(), "b".to_owned()];
712 let plain =
713 DelimitedFile::new("'/razeri/probe.csv'", "'/razeri'", "probe.csv").with_columns(&columns);
714 let read = |file: &DelimitedFile<'_>| {
715 SqlRenderer::delimited_file_relation_for(dialect, file)
716 .map(|relation| (relation, SqlRenderer::delimited_file_statements_for(dialect, file)))
717 };
718 let Some(plain_read) = read(&plain) else { return };
719 for (told, said) in [
720 (plain.with_delimiter(FileDelimiter::Pipe), "a file delimited by `|`"),
721 (plain.with_quote('\''), "a file quoted with `'`"),
722 (plain.with_header(false), "a file with no header line"),
723 ] {
724 if read(&told).as_ref() == Some(&plain_read) {
725 findings.push(Finding {
726 severity: Severity::Blocking,
727 what: format!(
728 "`delimited_file_relation_for` reads {said} exactly as it reads a \
729 comma-delimited file with a header"
730 ),
731 fix: "put the file's `delimiter`, `quote` and `header` where your engine's read \
732 takes them, or answer `None` for a file your engine cannot be told about. \
733 Razeri then declines that file by name, rather than loading each line into \
734 one column."
735 .to_owned(),
736 });
737 }
738 }
739}
740
741fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
743 let spelling = SqlRenderer::sid_spelling(dialect);
744 if spelling.platform == UNKNOWN_PLATFORM {
745 findings.push(Finding {
746 severity: Severity::Blocking,
747 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
748 an `xxhash64` its engine very likely does not have"
749 .to_owned(),
750 fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
751 replacement and a Unicode literal, how it joins strings, how it casts to text, \
752 and how it spells a 64-bit hash. If it has no portable hash, name an artifact your prerequisites install \
753 — never the nearest-looking built-in, which keys a warehouse to a number \
754 nothing else can reproduce."
755 .to_owned(),
756 });
757 }
758
759 let join = match spelling.concat {
763 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
764 Concat::Plus => "+",
765 };
766 if join.trim().is_empty() {
767 findings.push(Finding {
768 severity: Severity::Blocking,
769 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
770 with a function or operator that has no name"
771 .to_owned(),
772 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
773 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
774 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
775 operator and no function."
776 .to_owned(),
777 });
778 }
779
780 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
785 && supplied.body.trim().is_empty()
786 {
787 findings.push(Finding {
788 severity: Severity::Blocking,
789 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
790 and then supplies an empty one"
791 .to_owned(),
792 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
793 one argument, or answer `None` and let Razeri compose the derivation from \
794 your `sid_spelling`. An empty body deploys without error and keys every row \
795 to nothing."
796 .to_owned(),
797 });
798 }
799
800 let probe = "'/razeri/probe.csv'";
803 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
804 && !relation.contains(probe)
805 {
806 findings.push(Finding {
807 severity: Severity::Blocking,
808 what: "`delimited_file_relation` returns a relation that does not carry the path it \
809 was given"
810 .to_owned(),
811 fix: "put the literal you were handed where your engine's file relation takes a \
812 path. It arrives already quoted and escaped. A relation that drops it reads \
813 one file for every entity the design binds, and the generated fill loads the \
814 wrong rows without failing."
815 .to_owned(),
816 });
817 }
818
819 check_encoded_file_relation(dialect, probe, findings);
820 check_file_settings(dialect, findings);
821 check_routine_forms(dialect, findings);
822 check_slice_replacement(dialect, findings);
823
824 if let ConstraintEnforcement::DeclaredOnly(declared) =
827 DdlRenderer::constraint_enforcement(dialect)
828 && declared.note.split_whitespace().count() < 5
829 {
830 findings.push(Finding {
831 severity: Severity::Blocking,
832 what: "this dialect says its engine accepts constraints it does not enforce, and \
833 the note explaining that says almost nothing"
834 .to_owned(),
835 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
836 guarantee. Say what this engine really does with the declaration, and where \
837 the guarantee has to come from instead."
838 .to_owned(),
839 });
840 }
841
842 match DialectCapabilities::merge_shape(dialect) {
848 MergeShape::None => findings.push(Finding {
849 severity: Severity::Unanswered,
850 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
851 load for it"
852 .to_owned(),
853 fix: "if this engine has a MERGE or an upsert statement, say which in \
854 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
855 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
856 operator can act on, rather than a silence."
857 .to_owned(),
858 }),
859 MergeShape::Unsupported(orientation) => {
860 check_refusal_copy("the upsert refusal", orientation, findings);
861 }
862 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
863 _ => {}
864 }
865}
866
867fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
882 const VALUE: &str = "$razeri_slice_probe";
883 let plan = StoragePlan {
884 partition: PartitionStrategy::RangeByTimeDimension {
885 field: "razeri_probe_day".to_owned(),
886 granularity: PartitionGranularity::Day,
887 retention: None,
888 look_ahead: None,
889 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
890 },
891 compression: CompressionStrategy::None,
892 index: IndexStrategy::None,
893 };
894 let day = DataType::date();
895 let columns = ["razeri_probe_day"];
896 let entity = EntityRef {
897 schema: Some("razeri_probe"),
898 name: "razeri_probe_table",
899 role: TableRole::Written,
900 columns: &columns,
901 partition_column_type: Some(&day),
902 };
903 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
904 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
905 else {
906 return;
907 };
908
909 let carries = match &answer {
910 SliceReplacement::Emptied(statements) => {
911 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
912 }
913 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
914 };
915 if carries {
921 return;
922 }
923 findings.push(Finding {
924 severity: Severity::Blocking,
925 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
926 was handed"
927 .to_owned(),
928 fix: "put the value you were given where your statement names the slice, and build any \
929 partition NAME from it the same way your extend procedure built it. A replacement \
930 that drops the value clears a slice this call was not given — or every slice — \
931 and the load reports success over what it emptied."
932 .to_owned(),
933 });
934}
935
936fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
953 let answers = DialectCapabilities::evolution_answers(dialect);
954 let boolean = DataType::boolean();
955 let long = DataType::varchar(40);
956
957 if DdlRenderer::render_column_evolution(
958 dialect,
959 &ColumnEvolution::AddColumn {
960 schema: Some("s"),
961 table: "t",
962 column: "c",
963 data_type: &boolean,
964 },
965 )
966 .is_none()
967 {
968 findings.push(Finding {
969 severity: Severity::Blocking,
970 what: "this dialect writes no statement for a column that is new, which is the \
971 commonest change a deployed warehouse ever sees"
972 .to_owned(),
973 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
974 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
975 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
976 engine that genuinely has no way to add a column is worth saying out loud in \
977 the crate's own documentation."
978 .to_owned(),
979 });
980 }
981
982 if answers.parameterized_string_widening_is_lossless
983 && DdlRenderer::render_column_evolution(
984 dialect,
985 &ColumnEvolution::WidenColumn {
986 schema: Some("s"),
987 table: "t",
988 column: "c",
989 data_type: &long,
990 },
991 )
992 .is_none()
993 {
994 findings.push(Finding {
995 severity: Severity::Note,
996 what: "this dialect says its engine widens a string's declared length in place, and \
997 writes no statement for the change"
998 .to_owned(),
999 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
1000 engine's spelling of it. Without it Razeri tells an operator the widening \
1001 costs nothing and then hands them nothing to run, which reads as Razeri not \
1002 having finished rather than as a statement they own."
1003 .to_owned(),
1004 });
1005 }
1006
1007 if answers.nested_field_addition_is_lossless
1008 && DdlRenderer::render_column_evolution(
1009 dialect,
1010 &ColumnEvolution::AddNestedField {
1011 schema: Some("s"),
1012 table: "t",
1013 column: "c",
1014 field: "f",
1015 data_type: &long,
1016 },
1017 )
1018 .is_none()
1019 {
1020 findings.push(Finding {
1021 severity: Severity::Note,
1022 what: "this dialect says its engine keeps every value when a record gains a field, \
1023 and writes no statement for the change"
1024 .to_owned(),
1025 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
1026 engines name the new field; some restate the whole column's type, which is \
1027 why the change carries both the field's path and the column's type with the \
1028 field in it."
1029 .to_owned(),
1030 });
1031 }
1032}
1033
1034fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1043 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
1044 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
1045 let relation = format!("{schema}.{name}");
1046 let table = WrittenObject::new(Some(&schema), &name, &relation);
1047 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
1048 let mut lost: Vec<&str> = Vec::new();
1049
1050 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
1051 Some(drop) if drop.text().contains(&column) => {}
1052 _ => lost.push("render_drop_column"),
1053 }
1054 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
1055 && !guarded.text().contains(&column)
1056 {
1057 lost.push("render_drop_column (only where it stands)");
1058 }
1059 for only_where_it_stands in [false, true] {
1060 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1061 && !drop.text().contains(&name)
1062 {
1063 lost.push("render_drop_view");
1064 }
1065 }
1066 let create = format!("CREATE TABLE {relation} (a INTEGER)");
1067 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1068 && !guarded.text().contains(&create)
1069 {
1070 lost.push("render_create_table_if_absent");
1071 }
1072 lost.dedup();
1073 if !lost.is_empty() {
1074 findings.push(Finding {
1075 severity: Severity::Blocking,
1076 what: format!(
1077 "{} returns a statement that does not carry the object it was handed",
1078 lost.join(", ")
1079 ),
1080 fix: "put the names you were given where your statement names the object, and keep \
1081 a create you guard whole inside the guard. A drop that loses its column drops \
1082 another one or none, and a guard that loses its statement makes nothing — \
1083 both under a run that reports success."
1084 .to_owned(),
1085 });
1086 }
1087
1088 let value = "razeri_probe_value";
1091 let loses_its_value = declarable_types().iter().any(|data_type| {
1092 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1093 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1094 .is_some_and(|compared| !compared.contains(value))
1095 })
1096 });
1097 if loses_its_value {
1098 findings.push(Finding {
1099 severity: Severity::Blocking,
1100 what: "`render_comparable` answers an expression that does not carry the value it \
1101 stands for"
1102 .to_owned(),
1103 fix: "answer the value itself where your engine compares the type directly, an \
1104 expression over it where it compares one only that way, and `None` where it \
1105 cannot compare one at all — then Razeri refuses such a column by name."
1106 .to_owned(),
1107 });
1108 }
1109
1110 if let Some(limits) = DialectCapabilities::text_limits(dialect)
1113 && (limits.longest_varchar == Some(0)
1114 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1115 {
1116 findings.push(Finding {
1117 severity: Severity::Blocking,
1118 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1119 unbounded text cannot be"
1120 .to_owned(),
1121 fix: "state the longest varchar(n) your engine creates — one more is refused at \
1122 CREATE TABLE — and name what it refuses a text column as, or leave it None."
1123 .to_owned(),
1124 });
1125 }
1126
1127 match DialectCapabilities::catalogue_reach(dialect) {
1128 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1129 check_refusal_copy("the reason the schema check is not asked", reason, findings);
1130 }
1131 _ => {}
1132 }
1133}
1134
1135fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1139 if SqlRenderer::render_procedure_log(
1142 dialect,
1143 razeri_plugin_contract::ProcedureLogLevel::Info,
1144 "'razeri'",
1145 )
1146 .is_some_and(|line| line.trim().is_empty())
1147 {
1148 findings.push(Finding {
1149 severity: Severity::Blocking,
1150 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1151 fix: "answer `None` where your engine has no statement that writes a message: the \
1152 line is then left out of the routine."
1153 .to_owned(),
1154 });
1155 }
1156 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1157 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1158 {
1159 findings.push(Finding {
1160 severity: Severity::Blocking,
1161 what: "this dialect says its begin statement leaves the commit to the client, and \
1162 that its engine has no transaction spanning statements"
1163 .to_owned(),
1164 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1165 run in one transaction. Where there is none, the bundle already tells the \
1166 operator the boundary is theirs."
1167 .to_owned(),
1168 });
1169 }
1170}
1171
1172fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1183 if restrictions.slice_pruning_needs_literal {
1184 findings.push(Finding {
1185 severity: Severity::Note,
1186 what: "this dialect says its planner reads one slice of a partitioned table only \
1187 from a literal, so a load scoped to one day cannot simply bind the day"
1188 .to_owned(),
1189 fix: "Razeri will put the day into the statement text through this engine's \
1190 dynamic-execution form rather than binding it to the load procedure's \
1191 parameter. Confirm that is what your engine wants: the statement is composed \
1192 once per call and its text differs per day, which some planners cache and \
1193 some do not. If a bound parameter does prune here, drop the restriction and \
1194 Razeri binds it like everywhere else."
1195 .to_owned(),
1196 });
1197 }
1198}
1199
1200fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1203 let bitwise = BitwiseOperator::ALL.map(|operator| {
1207 (
1208 format!("render_bitwise_operator({operator:?})"),
1209 FormulaRenderer::render_bitwise_operator(dialect, operator),
1210 )
1211 });
1212 let blank: Vec<String> = [
1213 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1214 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1215 (
1216 "render_not_ilike_operator".to_owned(),
1217 FormulaRenderer::render_not_ilike_operator(dialect),
1218 ),
1219 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1220 ]
1221 .into_iter()
1222 .chain(bitwise)
1223 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1224 .map(|(method, _)| method)
1225 .collect();
1226 if !blank.is_empty() {
1227 findings.push(Finding {
1228 severity: Severity::Blocking,
1229 what: format!("{} answers an operator with no text in it", blank.join(", ")),
1230 fix: "answer `None` where your engine has no such operator: a formula that needs it \
1231 is then refused by name, and the reader is told to write the function your \
1232 engine has. An empty answer writes the two operands side by side."
1233 .to_owned(),
1234 });
1235 }
1236
1237 let formula = SqlRenderer::to_formula_renderer(dialect);
1238 let dialect_name = SqlRenderer::dialect_name(dialect);
1239 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1240 findings.push(Finding {
1241 severity: Severity::Blocking,
1242 what: "`to_formula_renderer` was not overridden, so every formula in every design \
1243 renders through Razeri's ANSI defaults instead of this dialect"
1244 .to_owned(),
1245 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1246 formula this engine sees is silently somebody else's."
1247 .to_owned(),
1248 });
1249 }
1250}
1251
1252fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1258 match SqlRenderer::procedure_form(dialect) {
1259 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1263 ProcedureForm::Agnostic => findings.push(Finding {
1264 severity: Severity::Unanswered,
1265 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1266 annotated form — which no engine parses"
1267 .to_owned(),
1268 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1269 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
1270 offers instead."
1271 .to_owned(),
1272 }),
1273 ProcedureForm::Unsupported(orientation) => {
1274 check_refusal_copy("the stored-procedure refusal", orientation, findings);
1275 if DialectCapabilities::supports_stored_procedures(dialect) {
1276 findings.push(Finding {
1277 severity: Severity::Blocking,
1278 what: "this dialect says its engine has stored procedures, and its \
1279 procedure form refuses to write one"
1280 .to_owned(),
1281 fix: "decide which is true. If the engine has none, leave \
1282 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1283 has them, answer `procedure_form` with the grammar it speaks."
1284 .to_owned(),
1285 });
1286 }
1287 }
1288 ProcedureForm::Custom(grammar) => check_custom_grammar(
1289 CustomGrammar {
1290 kind: RoutineKind::Procedure,
1291 grammar,
1292 form_method: "procedure_form",
1293 render_method: "render_procedure",
1294 noun: "procedures",
1295 },
1296 |spec| SqlRenderer::render_procedure(dialect, spec),
1297 findings,
1298 ),
1299 _ => {}
1300 }
1301 check_procedures_are_used(dialect, findings);
1302
1303 match SqlRenderer::scalar_function_form(dialect) {
1304 ScalarFunctionForm::Unsupported(orientation) => {
1305 check_refusal_copy("the scalar-function refusal", orientation, findings);
1306 }
1307 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1308 CustomGrammar {
1309 kind: RoutineKind::ScalarFunction,
1310 grammar,
1311 form_method: "scalar_function_form",
1312 render_method: "render_scalar_function",
1313 noun: "scalar functions",
1314 },
1315 |spec| SqlRenderer::render_scalar_function(dialect, spec),
1316 findings,
1317 ),
1318 _ => {}
1319 }
1320
1321 match SqlRenderer::table_function_form(dialect) {
1322 TableFunctionForm::Unsupported(orientation) => {
1323 check_refusal_copy("the table-function refusal", orientation, findings);
1324 }
1325 TableFunctionForm::Custom(grammar) => check_custom_grammar(
1326 CustomGrammar {
1327 kind: RoutineKind::TableFunction,
1328 grammar,
1329 form_method: "table_function_form",
1330 render_method: "render_table_function",
1331 noun: "table functions",
1332 },
1333 |spec| SqlRenderer::render_table_function(dialect, spec),
1334 findings,
1335 ),
1336 _ => {}
1337 }
1338
1339 check_parameter_type_spelling(dialect, findings);
1340}
1341
1342fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1354 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1355 return;
1356 };
1357 if DialectCapabilities::supports_stored_procedures(dialect) {
1358 return;
1359 }
1360 findings.push(Finding {
1361 severity: Severity::Blocking,
1362 what: format!(
1363 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1364 load is ever written as one"
1365 ),
1366 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1367 it before it writes a load as a procedure, and its default is `false`."
1368 .to_owned(),
1369 });
1370}
1371
1372fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1387 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1388 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1389 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1390 if !spells_its_own {
1391 return;
1392 }
1393 let qualified = DataType::varchar(100);
1396 let column = SqlRenderer::render_data_type(dialect, &qualified);
1397 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1398 if column == parameter && column.contains('(') {
1399 findings.push(Finding {
1400 severity: Severity::Note,
1401 what: format!(
1402 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1403 column of that type"
1404 ),
1405 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1406 a length or a precision in a parameter list — Oracle does, and answers \
1407 PLS-00103 at the opening parenthesis — override \
1408 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1409 declarations keep the length either way."
1410 .to_owned(),
1411 });
1412 }
1413}
1414
1415#[derive(Clone, Copy)]
1417struct CustomGrammar {
1418 kind: RoutineKind,
1420 grammar: &'static str,
1422 form_method: &'static str,
1424 render_method: &'static str,
1426 noun: &'static str,
1428}
1429
1430fn check_custom_grammar(
1447 claim: CustomGrammar,
1448 render: impl Fn(&RoutineSpec) -> Option<String>,
1449 findings: &mut Vec<Finding>,
1450) {
1451 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1452 if grammar.trim().is_empty() {
1455 findings.push(Finding {
1456 severity: Severity::Blocking,
1457 what: format!("`{form_method}` says Custom, and names no grammar"),
1458 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
1459 of a report, and of the comment Razeri writes where a routine could not be \
1460 rendered, is told this engine speaks."
1461 .to_owned(),
1462 });
1463 }
1464
1465 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1466 probe.or_replace = true;
1467 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1468 findings.push(Finding {
1469 severity: Severity::Blocking,
1470 what: format!(
1471 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1472 itself — and `{render_method}` returns nothing"
1473 ),
1474 fix: format!(
1475 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1476 composed — name, parameters with their types already in your spelling, body, \
1477 comment — and returns the whole CREATE statement your engine parses. Razeri \
1478 emits what you return and writes nothing of its own: a near-neighbour grammar \
1479 fails on deploy, and a refusal would say this engine has no {noun} when it \
1480 has them in a grammar of its own."
1481 ),
1482 });
1483 }
1484}
1485
1486fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1488 if copy.trim().is_empty() {
1489 findings.push(Finding {
1490 severity: Severity::Blocking,
1491 what: format!("{subject} has no words in it"),
1492 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1493 next thing to try, and it has to be about their engine."
1494 .to_owned(),
1495 });
1496 return;
1497 }
1498 if copy.split_whitespace().count() < 5 {
1499 findings.push(Finding {
1500 severity: Severity::Note,
1501 what: format!("{subject} is very short"),
1502 fix: "a refusal earns its place by naming the alternative — what this engine has \
1503 instead of the thing it cannot do."
1504 .to_owned(),
1505 });
1506 }
1507}
1508
1509fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1511 let caps: &dyn DialectCapabilities = dialect;
1512 let ren: &dyn SqlRenderer = dialect;
1513 let ddl: &dyn DdlRenderer = dialect;
1514 let mut disagreements: Vec<&'static str> = Vec::new();
1515
1516 macro_rules! pair {
1517 ($m:ident, $renderer_value:expr) => {
1518 if caps.$m() != $renderer_value {
1519 disagreements.push(stringify!($m));
1520 }
1521 };
1522 }
1523 pair!(supports_on_conflict, ren.supports_on_conflict());
1524 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1525 pair!(supports_update_from, ren.supports_update_from());
1526 pair!(supports_named_windows, ren.supports_named_windows());
1527 pair!(supports_compound_assignment, ren.supports_compound_assignment());
1528 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1529 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1530 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1531 pair!(uses_alter_table_update, ren.uses_alter_table_update());
1532 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1533 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1534 pair!(supports_sync_drop, ddl.supports_sync_drop());
1535 pair!(supports_force_drop, ddl.supports_force_drop());
1536
1537 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1546 disagreements.push("supports_table_functions");
1547 }
1548 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1551 disagreements.push("supports_stored_functions");
1552 }
1553
1554 if !disagreements.is_empty() {
1555 findings.push(Finding {
1556 severity: Severity::Blocking,
1557 what: format!(
1558 "this dialect declares {} {} two ways and the two disagree: {}",
1559 disagreements.len(),
1560 if disagreements.len() == 1 { "fact" } else { "facts" },
1561 disagreements.join(", ")
1562 ),
1563 fix: "each of these is declared once on the capability trait and once in the \
1564 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1565 and move the other one to match."
1566 .to_owned(),
1567 });
1568 }
1569}
1570
1571fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1580 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1581 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1582 return;
1583 };
1584 if DialectCapabilities::supports_schemas(dialect) {
1585 findings.push(Finding {
1586 severity: Severity::Blocking,
1587 what: "this dialect folds a schema into an object's name on an engine it says has \
1588 schemas"
1589 .to_owned(),
1590 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1591 schemas, and only there is it asked; or answer \
1592 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1593 .to_owned(),
1594 });
1595 return;
1596 }
1597 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1598 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1599 if qualified != expected {
1600 findings.push(Finding {
1601 severity: Severity::Blocking,
1602 what: format!(
1603 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1604 the same object `{qualified}`"
1605 ),
1606 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1607 schema. Razeri names some objects through it and the rest through the fold, \
1608 so a difference creates a table under one name and reads it under another."
1609 .to_owned(),
1610 });
1611 }
1612}
1613
1614fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1621 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1622 return;
1623 };
1624 if client.command.trim().is_empty() {
1625 findings.push(Finding {
1626 severity: Severity::Blocking,
1627 what: "this dialect describes a deploy client with no command".to_owned(),
1628 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1629 });
1630 }
1631 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1632 .into_iter()
1633 .filter_map(|(field, args)| {
1634 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1635 })
1636 .collect();
1637 if !unnamed.is_empty() {
1638 findings.push(Finding {
1639 severity: Severity::Blocking,
1640 what: format!(
1641 "the deploy client's {} never names the file to run",
1642 unnamed.join(" and ")
1643 ),
1644 fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1645 inside an argument such as `--file={file}`. The script writes the file there \
1646 and nowhere else."
1647 .to_owned(),
1648 });
1649 }
1650 if let Some(terminator) = client.block_terminator
1651 && [terminator.open, terminator.end, terminator.close]
1652 .iter()
1653 .any(|part| part.trim().is_empty())
1654 {
1655 findings.push(Finding {
1656 severity: Severity::Blocking,
1657 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1658 fix: "name the line before a routine, what ends it, and the line after it — \
1659 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1660 the routine open, and the next statement is read into it."
1661 .to_owned(),
1662 });
1663 }
1664}
1665
1666fn declarable_types() -> Vec<DataType> {
1674 vec![
1675 DataType::tinyint(),
1676 DataType::smallint(),
1677 DataType::int(),
1678 DataType::bigint(),
1679 DataType::double(),
1680 DataType::boolean(),
1681 DataType::text(),
1682 DataType::varchar(200),
1683 DataType::uuid(),
1684 DataType::json(),
1685 DataType::date(),
1686 DataType::timestamp(),
1687 DataType::timestamp_tz(),
1688 DataType::decimal(18, 2),
1689 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1690 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1691 DataType::Struct(StructDataType::new(vec![
1692 StructField::new("street", DataType::varchar(200)),
1693 StructField::new("zip", DataType::varchar(10)),
1694 ])),
1695 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1696 StructField::new("key", DataType::varchar(100)),
1697 StructField::new("value", DataType::varchar(1000)),
1698 ])))),
1699 ]
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 use super::*;
1705 use crate::dialect::DialectInfo;
1706 use razeri_plugin_contract::{
1707 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1708 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1709 TaskRenderer, TextCast,
1710 };
1711 use std::marker::PhantomData;
1712
1713 struct BrokenPlugin;
1721
1722 impl SqlRenderer for BrokenPlugin {
1723 fn render_data_type(&self, _dt: &DataType) -> String {
1724 String::new()
1725 }
1726 fn quote_identifier(&self, name: &str) -> String {
1727 name.to_owned()
1728 }
1729 fn render_cast_type(&self, _dt: &DataType) -> String {
1730 String::new()
1731 }
1732 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1733 name.to_owned()
1734 }
1735 fn render_current_timestamp(&self) -> String {
1736 String::new()
1737 }
1738 fn dialect_name(&self) -> &'static str {
1739 "brokendb"
1740 }
1741 fn supports_on_conflict(&self) -> bool {
1744 true
1745 }
1746 }
1747 impl ExpressionRenderer for BrokenPlugin {}
1748 impl DdlRenderer for BrokenPlugin {}
1749 impl DmlRenderer for BrokenPlugin {}
1750 impl ProcedureRenderer for BrokenPlugin {}
1751 impl FormulaRenderer for BrokenPlugin {}
1752 impl TaskRenderer for BrokenPlugin {}
1753 impl ReplicationRenderer for BrokenPlugin {}
1754 impl StorageRenderer for BrokenPlugin {}
1755 impl DialectCapabilities for BrokenPlugin {
1756 fn supports_on_conflict(&self) -> bool {
1757 false
1758 }
1759 }
1760 impl PlatformDialect for BrokenPlugin {
1761 fn info(&self) -> DialectInfo {
1762 DialectInfo {
1763 name: "brokendb",
1764 display_name: "Broken DB",
1765 aliases: &[],
1766 dialect_family: DialectFamily::Custom(""),
1767 family: "Broken",
1768 is_cloud_native: false,
1769 is_olap: false,
1770 is_postgres_compatible: false,
1771 versions: &[],
1772 deploy_script: None,
1773 }
1774 }
1775 }
1776
1777 trait FixtureName: Send + Sync + 'static {
1786 const NAME: &'static str;
1788 const CONCAT: Concat = Concat::Function("CONCAT");
1792 const POWER: Option<&'static str> = Some("^");
1795 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1797 const DROP_LOSES_ITS_COLUMN: bool = false;
1799 const COMPARES_SOMETHING_ELSE: bool = false;
1801 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1804 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1806 }
1807
1808 struct Unlinked;
1811 impl FixtureName for Unlinked {
1812 const NAME: &'static str = "healthydb";
1813 }
1814
1815 struct Outside;
1818 impl FixtureName for Outside {
1819 const NAME: &'static str = "outsidedb";
1820 }
1821
1822 struct BinaryConcat;
1827 impl FixtureName for BinaryConcat {
1828 const NAME: &'static str = "binaryconcatdb";
1829 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1830 }
1831
1832 struct OperatorConcat;
1836 impl FixtureName for OperatorConcat {
1837 const NAME: &'static str = "operatorconcatdb";
1838 const CONCAT: Concat = Concat::Operator("||");
1839 }
1840
1841 struct NamelessConcat;
1843 impl FixtureName for NamelessConcat {
1844 const NAME: &'static str = "namelessconcatdb";
1845 const CONCAT: Concat = Concat::Operator(" ");
1846 }
1847
1848 struct NoPowerOperator;
1851 impl FixtureName for NoPowerOperator {
1852 const NAME: &'static str = "nopowerdb";
1853 const POWER: Option<&'static str> = None;
1854 }
1855
1856 struct BlankPowerOperator;
1858 impl FixtureName for BlankPowerOperator {
1859 const NAME: &'static str = "blankpowerdb";
1860 const POWER: Option<&'static str> = Some(" ");
1861 }
1862
1863 struct ComparesSomethingElse;
1865 impl FixtureName for ComparesSomethingElse {
1866 const NAME: &'static str = "comparesotherdb";
1867 const COMPARES_SOMETHING_ELSE: bool = true;
1868 }
1869
1870 struct DropLosesItsColumn;
1872 impl FixtureName for DropLosesItsColumn {
1873 const NAME: &'static str = "droplosesdb";
1874 const DROP_LOSES_ITS_COLUMN: bool = true;
1875 }
1876
1877 struct BeginWithoutTransactions;
1880 impl FixtureName for BeginWithoutTransactions {
1881 const NAME: &'static str = "beginwithouttxdb";
1882 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1883 }
1884
1885 struct NoShiftOperator;
1887 impl FixtureName for NoShiftOperator {
1888 const NAME: &'static str = "noshiftdb";
1889 const SHIFT_LEFT: Option<&'static str> = None;
1890 }
1891
1892 struct BlankShiftOperator;
1894 impl FixtureName for BlankShiftOperator {
1895 const NAME: &'static str = "blankshiftdb";
1896 const SHIFT_LEFT: Option<&'static str> = Some("");
1897 }
1898
1899 struct DescribedClient;
1901 impl FixtureName for DescribedClient {
1902 const NAME: &'static str = "describedclientdb";
1903 const DEPLOY: Option<crate::dialect::DeployScript> =
1904 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1905 command: "sqlite3",
1906 run_file: &["-bail", "{target}", ".read {file}"],
1907 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1908 block_terminator: None,
1909 }));
1910 }
1911
1912 struct FilelessClient;
1914 impl FixtureName for FilelessClient {
1915 const NAME: &'static str = "filelessclientdb";
1916 const DEPLOY: Option<crate::dialect::DeployScript> =
1917 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1918 command: "sqlite3",
1919 run_file: &["-bail", "{target}"],
1920 read_rows: None,
1921 block_terminator: None,
1922 }));
1923 }
1924
1925 struct BlankBlockEnd;
1927 impl FixtureName for BlankBlockEnd {
1928 const NAME: &'static str = "blankblockenddb";
1929 const DEPLOY: Option<crate::dialect::DeployScript> =
1930 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1931 command: "isql",
1932 run_file: &["-bail", "-input", "{file}", "{target}"],
1933 read_rows: None,
1934 block_terminator: Some(crate::dialect::BlockTerminator {
1935 open: "SET TERM ^ ;",
1936 end: " ",
1937 close: "SET TERM ; ^",
1938 }),
1939 }));
1940 }
1941
1942 struct Answered<N: FixtureName>(PhantomData<N>);
1943
1944 impl<N: FixtureName> Answered<N> {
1945 const fn new() -> Self {
1946 Self(PhantomData)
1947 }
1948 }
1949
1950 type HealthyPlugin = Answered<Unlinked>;
1951 type OutsidePlugin = Answered<Outside>;
1952
1953 inventory::submit! {
1954 crate::builder::DialectRegistration::new(
1955 Outside::NAME,
1956 || std::sync::Arc::new(OutsidePlugin::new()),
1957 )
1958 }
1959
1960 impl<N: FixtureName> SqlRenderer for Answered<N> {
1961 fn render_data_type(&self, dt: &DataType) -> String {
1962 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1963 }
1964 fn quote_identifier(&self, name: &str) -> String {
1965 format!("\"{name}\"")
1966 }
1967 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1968 schema.map_or_else(
1969 || self.quote_identifier(name),
1970 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1971 )
1972 }
1973 fn render_current_timestamp(&self) -> String {
1974 "NOW()".to_owned()
1975 }
1976 fn dialect_name(&self) -> &'static str {
1977 N::NAME
1978 }
1979 fn sid_spelling(&self) -> SidSpelling {
1980 SidSpelling {
1981 platform: N::NAME,
1982 hash: ("answered_xxhash64(", ")"),
1983 cast_type: TextCast::Direct("STRING"),
1984 concat: N::CONCAT,
1985 through_helper: false,
1986 ..razeri_plugin_contract::ANSI_SID_SPELLING
1987 }
1988 }
1989 fn procedure_form(&self) -> ProcedureForm {
1990 ProcedureForm::Unsupported(
1991 "This engine has no stored procedures; write the load as a scheduled query \
1992 instead.",
1993 )
1994 }
1995 fn scalar_function_form(&self) -> ScalarFunctionForm {
1996 ScalarFunctionForm::PostgreSql
1997 }
1998 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1999 Box::new(Self::new())
2000 }
2001 }
2002 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
2003 fn render_comparable(
2004 &self,
2005 value: &str,
2006 _data_type: &DataType,
2007 _held: HeldAs,
2008 ) -> Option<String> {
2009 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
2010 }
2011 }
2012 impl<N: FixtureName> DdlRenderer for Answered<N> {
2013 fn render_drop_column(
2014 &self,
2015 table: WrittenObject<'_>,
2016 column: &str,
2017 only_where_it_stands: bool,
2018 ) -> Option<razeri_plugin_contract::Written> {
2019 if N::DROP_LOSES_ITS_COLUMN {
2020 return Some(razeri_plugin_contract::Written::Statement(format!(
2021 "ALTER TABLE {} DROP COLUMN c",
2022 table.relation
2023 )));
2024 }
2025 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
2026 Some(razeri_plugin_contract::Written::Statement(format!(
2027 "ALTER TABLE {} DROP COLUMN{guard} {column}",
2028 table.relation
2029 )))
2030 }
2031 }
2032 impl<N: FixtureName> DmlRenderer for Answered<N> {}
2033 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
2034 impl<N: FixtureName> FormulaRenderer for Answered<N> {
2035 fn render_power_operator(&self) -> Option<String> {
2036 N::POWER.map(str::to_owned)
2037 }
2038 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
2039 match operator {
2040 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
2041 other => Some(other.standard_spelling().to_owned()),
2042 }
2043 }
2044 }
2045 impl<N: FixtureName> TaskRenderer for Answered<N> {}
2046 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
2047 impl<N: FixtureName> StorageRenderer for Answered<N> {}
2048 impl<N: FixtureName> DialectCapabilities for Answered<N> {
2049 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
2050 N::BEGIN_WITHOUT_TRANSACTIONS
2051 }
2052 fn supports_multi_statement_transactions(&self) -> bool {
2053 !N::BEGIN_WITHOUT_TRANSACTIONS
2054 }
2055 fn merge_shape(&self) -> MergeShape {
2056 MergeShape::Merge(MergeRestrictions::none())
2057 }
2058 }
2059 impl<N: FixtureName> PlatformDialect for Answered<N> {
2060 fn info(&self) -> DialectInfo {
2061 DialectInfo {
2062 name: N::NAME,
2063 display_name: "An Answered Engine",
2064 aliases: &[],
2067 dialect_family: DialectFamily::Custom(N::NAME),
2068 family: "Answered",
2069 is_cloud_native: false,
2070 is_olap: false,
2071 is_postgres_compatible: false,
2072 versions: &[],
2073 deploy_script: N::DEPLOY,
2074 }
2075 }
2076 }
2077
2078 struct Twin;
2084
2085 impl SqlRenderer for Twin {
2086 fn render_data_type(&self, dt: &DataType) -> String {
2087 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2088 }
2089 fn quote_identifier(&self, name: &str) -> String {
2090 format!("\"{name}\"")
2091 }
2092 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2093 self.quote_identifier(name)
2094 }
2095 fn render_current_timestamp(&self) -> String {
2096 "NOW()".to_owned()
2097 }
2098 }
2099 impl ExpressionRenderer for Twin {}
2100 impl DdlRenderer for Twin {}
2101 impl DmlRenderer for Twin {}
2102 impl ProcedureRenderer for Twin {}
2103 impl FormulaRenderer for Twin {}
2104 impl TaskRenderer for Twin {}
2105 impl ReplicationRenderer for Twin {}
2106 impl StorageRenderer for Twin {}
2107 impl DialectCapabilities for Twin {}
2108 impl PlatformDialect for Twin {
2109 fn info(&self) -> DialectInfo {
2110 DialectInfo {
2111 name: "twindb",
2112 display_name: "Twin DB",
2113 aliases: &["healthydb"],
2115 dialect_family: DialectFamily::Custom("twindb"),
2116 family: "Twin",
2117 is_cloud_native: false,
2118 is_olap: false,
2119 is_postgres_compatible: false,
2120 versions: &[],
2121 deploy_script: None,
2122 }
2123 }
2124 }
2125
2126 fn mentions(report: &Report, needle: &str) -> bool {
2128 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2129 }
2130
2131 #[test]
2135 fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2136 let report = check(&BrokenPlugin);
2137 let printed = report.to_string();
2138 let first = printed.lines().next().unwrap_or_default();
2139 assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2140 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2141 assert!(printed.contains("Taken as the contract's default"), "{printed}");
2142 let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2143 assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2144 let view = report
2145 .taken_as_default
2146 .iter()
2147 .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2148 .expect("listed");
2149 assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2150 assert_eq!(view.shows_in, "schema.sql");
2151 assert!(printed.contains(&view.answer), "{printed}");
2152 }
2153
2154 #[test]
2158 fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2159 let report = check(&Answered::<Outside>::new());
2160 let printed = report.to_string();
2161 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2162 assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2163 }
2164
2165 #[test]
2169 fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2170 let report = check(&BrokenPlugin);
2171 let key = report
2172 .findings
2173 .iter()
2174 .find(|finding| finding.what.contains("surrogate-key spelling"))
2175 .expect("the key's finding");
2176 for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2177 assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2178 }
2179 assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2180 }
2181
2182 #[test]
2186 fn the_kit_catches_a_deliberately_broken_plugin() {
2187 let report = check(&BrokenPlugin);
2188 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2189
2190 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2191 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2192 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2193 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2194 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2195 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2196 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2197 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2198 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2199 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2200 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
2201
2202 assert!(
2203 report.count(Severity::Blocking) >= 8,
2204 "the broken plugin owes more than {} blocking fixes:\n{report}",
2205 report.count(Severity::Blocking)
2206 );
2207 }
2208
2209 #[test]
2213 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2214 let report = check(&HealthyPlugin::new());
2215 let unregistered: Vec<&Finding> =
2216 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2217 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2218 assert_eq!(
2219 report.findings.len(),
2220 1,
2221 "a plugin that answered every question owes nothing else:\n{report}"
2222 );
2223 }
2224
2225 #[test]
2230 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2231 let binary = Answered::<BinaryConcat>::new();
2232 let report = check(&binary);
2233 assert_eq!(
2234 report.findings.len(),
2235 check(&HealthyPlugin::new()).findings.len(),
2236 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2237 );
2238 assert!(
2239 !report.to_string().contains("joins"),
2240 "nothing about the join is outstanding:\n{report}"
2241 );
2242
2243 let key = razeri_plugin_contract::sid::sid_expression(
2244 SqlRenderer::sid_spelling(&binary),
2245 razeri_plugin_contract::sid::Backslash::of(&binary),
2246 None,
2247 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2248 );
2249 assert!(
2250 key.contains("CONCAT(CONCAT("),
2251 "a two-argument engine gets one call per pair:\n{key}"
2252 );
2253 }
2254
2255 #[test]
2259 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2260 let operator = Answered::<OperatorConcat>::new();
2261 let report = check(&operator);
2262 assert_eq!(
2263 report.findings.len(),
2264 check(&HealthyPlugin::new()).findings.len(),
2265 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2266 );
2267 let key = razeri_plugin_contract::sid::sid_expression(
2268 SqlRenderer::sid_spelling(&operator),
2269 razeri_plugin_contract::sid::Backslash::of(&operator),
2270 None,
2271 &["a".to_owned(), "b".to_owned()],
2272 );
2273 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2274 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2275 }
2276
2277 #[test]
2280 fn a_join_that_names_nothing_must_be_fixed() {
2281 let report = check(&Answered::<NamelessConcat>::new());
2282 assert!(
2283 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2284 && finding.what.contains("joins the parts of a composite key")),
2285 "{report}"
2286 );
2287 assert!(
2288 !check(&Answered::<OperatorConcat>::new())
2289 .findings
2290 .iter()
2291 .any(|finding| finding.what.contains("joins the parts of a composite key")),
2292 "a named operator is not the finding"
2293 );
2294 }
2295
2296 #[test]
2300 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2301 let none = check(&Answered::<NoPowerOperator>::new());
2302 assert_eq!(
2303 none.findings.len(),
2304 check(&HealthyPlugin::new()).findings.len(),
2305 "saying the engine has no such operator owes nothing:\n{none}"
2306 );
2307 let blank = check(&Answered::<BlankPowerOperator>::new());
2308 assert!(
2309 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2310 && finding.what.contains("render_power_operator")),
2311 "{blank}"
2312 );
2313 }
2314
2315 #[test]
2318 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2319 let none = check(&Answered::<NoShiftOperator>::new());
2320 assert_eq!(
2321 none.findings.len(),
2322 check(&HealthyPlugin::new()).findings.len(),
2323 "saying the engine has no left shift owes nothing:\n{none}"
2324 );
2325 let blank = check(&Answered::<BlankShiftOperator>::new());
2326 assert!(
2327 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2328 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2329 "{blank}"
2330 );
2331 }
2332
2333 #[test]
2336 fn a_drop_that_loses_its_column_must_be_fixed() {
2337 let healthy = check(&HealthyPlugin::new());
2338 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2339 let lost = check(&Answered::<DropLosesItsColumn>::new());
2340 assert!(
2341 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2342 && finding.what.contains("render_drop_column")),
2343 "{lost}"
2344 );
2345 }
2346
2347 #[test]
2350 fn a_comparison_that_loses_its_value_must_be_fixed() {
2351 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2352 let report = check(&Answered::<ComparesSomethingElse>::new());
2353 assert!(
2354 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2355 && finding.what.contains("render_comparable")),
2356 "{report}"
2357 );
2358 }
2359
2360 #[test]
2364 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2365 let report = check(&Answered::<BeginWithoutTransactions>::new());
2366 assert!(
2367 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2368 && finding.what.contains("leaves the commit to the client")),
2369 "{report}"
2370 );
2371 }
2372
2373 #[test]
2375 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2376 let report = check(&Answered::<BlankBlockEnd>::new());
2377 assert!(
2378 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2379 && finding.what.contains("block terminator")),
2380 "{report}"
2381 );
2382 }
2383
2384 #[test]
2387 fn a_deploy_client_has_to_be_told_which_file_to_run() {
2388 let described = check(&Answered::<DescribedClient>::new());
2389 assert_eq!(
2390 described.findings.len(),
2391 check(&HealthyPlugin::new()).findings.len(),
2392 "a client described in full owes nothing:\n{described}"
2393 );
2394 let fileless = check(&Answered::<FilelessClient>::new());
2395 assert!(
2396 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2397 && finding.what.contains("run_file never names the file")),
2398 "{fileless}"
2399 );
2400 }
2401
2402 #[test]
2406 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2407 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2408 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2409 fn render_data_type(&self, dt: &DataType) -> String {
2410 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2411 }
2412 fn quote_identifier(&self, name: &str) -> String {
2413 format!("\"{name}\"")
2414 }
2415 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2416 match schema {
2417 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2418 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2419 None => self.quote_identifier(name),
2420 }
2421 }
2422 fn render_current_timestamp(&self) -> String {
2423 "CURRENT_TIMESTAMP".to_owned()
2424 }
2425 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2426 Some(format!("{schema}_{name}"))
2427 }
2428 }
2429 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2430 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2431 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2432 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2433 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2434 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2435 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2436 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2437 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2438 fn supports_schemas(&self) -> bool {
2439 S
2440 }
2441 }
2442 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2443 fn info(&self) -> DialectInfo {
2444 DialectInfo {
2445 name: "folderdb",
2446 display_name: "Folder DB",
2447 aliases: &[],
2448 dialect_family: DialectFamily::Custom("folderdb"),
2449 family: "Folder",
2450 is_cloud_native: false,
2451 is_olap: false,
2452 is_postgres_compatible: false,
2453 versions: &[],
2454 deploy_script: None,
2455 }
2456 }
2457 }
2458
2459 let fold_findings = |dialect: &dyn PlatformDialect| {
2460 let mut findings = Vec::new();
2461 check_schema_fold(dialect, &mut findings);
2462 findings
2463 };
2464 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2465 let disagrees = fold_findings(&Folder::<false, false>);
2466 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2467 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2468 let has_schemas = fold_findings(&Folder::<true, true>);
2469 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2470 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2471 }
2472
2473 #[test]
2482 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2483 let mut quiet = Vec::new();
2484 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2485 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2486
2487 let mut stated = Vec::new();
2488 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2489 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2490 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2491 assert!(
2492 stated[0].what.contains("literal"),
2493 "the finding names the restriction:\n{}",
2494 stated[0].what
2495 );
2496 assert!(
2497 stated[0].fix.contains("dynamic-execution"),
2498 "and says what Razeri does with the answer:\n{}",
2499 stated[0].fix
2500 );
2501
2502 let mut two_clauses = Vec::new();
2505 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2506 assert!(two_clauses.is_empty(), "one restriction is not the other");
2507 }
2508
2509 #[test]
2515 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2516 assert!(
2517 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2518 "the fixture registers through the registry, which is the whole path being graded"
2519 );
2520 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2524 assert!(
2525 registry
2526 .list()
2527 .iter()
2528 .filter(|info| info.name != Outside::NAME)
2529 .all(|info| !info.answers_to(Outside::NAME)),
2530 "the fixture's name must be one no other platform claims"
2531 );
2532
2533 let report = check(&OutsidePlugin::new());
2534 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2535 assert!(report.is_clean(), "and the report says so:\n{report}");
2536 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2537 assert!(
2538 report.to_string().contains("nothing to fix"),
2539 "a clean dialect is told it is clean:\n{report}"
2540 );
2541 }
2542
2543 #[test]
2549 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2550 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2551 for info in registry.list() {
2552 assert_eq!(
2553 info.spellings().first().copied(),
2554 Some(info.name),
2555 "a platform's own name leads its spellings"
2556 );
2557 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2558 }
2559 }
2560
2561 #[test]
2567 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2568 let mut registry = crate::dialect::PlatformRegistry::new();
2569 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2570 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2571
2572 let report = check_with_registry(&Twin, ®istry);
2573 let collision = report
2574 .findings
2575 .iter()
2576 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2577 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2578 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2579 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2580 assert!(collision.what.contains("twindb"), "{collision:?}");
2581 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2582 assert!(
2583 !mentions(&report, "does not resolve"),
2584 "a collision is not an unregistered platform:\n{report}"
2585 );
2586 }
2587
2588 #[test]
2592 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2593 let mut registry = crate::dialect::PlatformRegistry::new();
2594 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2595
2596 let mut findings = Vec::new();
2597 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2598 assert!(findings.is_empty(), "{findings:?}");
2599
2600 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2601 assert!(
2602 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2603 "'{spelling}' is the same platform"
2604 );
2605 }
2606 assert!(
2607 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2608 );
2609 }
2610
2611 #[test]
2616 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2617 let mut registry = crate::dialect::PlatformRegistry::new();
2618 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2619
2620 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2621 assert!(
2622 report.is_clean(),
2623 "a loaded dialect that answered everything owes nothing:\n{report}"
2624 );
2625 }
2626
2627 #[test]
2631 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2632 let mut collided = crate::dialect::PlatformRegistry::new();
2633 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2634 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2635
2636 let mut fixes: Vec<String> = Vec::new();
2637 for report in [
2638 check(&BrokenPlugin),
2639 check(&HealthyPlugin::new()),
2640 check(&OutsidePlugin::new()),
2641 check_with_registry(&Twin, &collided),
2644 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2645 ] {
2646 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2647 }
2648 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2649 for fix in &fixes {
2650 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2651 assert!(
2652 !fix.contains(path),
2653 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2654 );
2655 }
2656 }
2657 }
2658
2659 #[test]
2666 fn no_finding_holds_a_run_of_spaces() {
2667 struct Declining;
2668 impl SqlRenderer for Declining {
2669 fn render_data_type(&self, dt: &DataType) -> String {
2670 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2671 }
2672 fn quote_identifier(&self, name: &str) -> String {
2673 format!("\"{name}\"")
2674 }
2675 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2676 name.to_owned()
2677 }
2678 fn render_current_timestamp(&self) -> String {
2679 "CURRENT_TIMESTAMP".to_owned()
2680 }
2681 fn procedure_form(&self) -> ProcedureForm {
2682 ProcedureForm::Custom("Declining PSQL")
2683 }
2684 }
2685 impl ExpressionRenderer for Declining {}
2686 impl DdlRenderer for Declining {
2687 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2688 None
2689 }
2690 }
2691 impl DialectCapabilities for Declining {
2692 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2693 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2694 .and_a_nested_field()
2695 }
2696 }
2697 impl DmlRenderer for Declining {}
2698 impl ProcedureRenderer for Declining {}
2699 impl FormulaRenderer for Declining {}
2700 impl TaskRenderer for Declining {}
2701 impl ReplicationRenderer for Declining {}
2702 impl StorageRenderer for Declining {}
2703 impl PlatformDialect for Declining {
2704 fn info(&self) -> DialectInfo {
2705 DialectInfo {
2706 name: "decliningdb",
2707 display_name: "Declining DB",
2708 aliases: &[],
2709 dialect_family: DialectFamily::Custom("decliningdb"),
2710 family: "Declining",
2711 is_cloud_native: false,
2712 is_olap: false,
2713 is_postgres_compatible: false,
2714 versions: &[],
2715 deploy_script: None,
2716 }
2717 }
2718 }
2719
2720 let mut findings = Vec::new();
2721 check_evolution(&Declining, &mut findings);
2722 check_parameter_type_spelling(&Declining, &mut findings);
2723 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2724
2725 for report in [
2726 check(&BrokenPlugin),
2727 check(&HealthyPlugin::new()),
2728 check(&OutsidePlugin::new()),
2729 check(&Declining),
2730 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2731 ] {
2732 findings.extend(report.findings);
2733 }
2734 for finding in &findings {
2735 for text in [&finding.what, &finding.fix] {
2736 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2737 }
2738 }
2739 }
2740
2741 #[test]
2751 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2752 for (kind, form_method, render_method) in [
2753 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2754 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2755 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2756 ] {
2757 let mut promised = Vec::new();
2758 check_custom_grammar(
2759 CustomGrammar {
2760 kind,
2761 grammar: "PL/SQL",
2762 form_method,
2763 render_method,
2764 noun: "routines",
2765 },
2766 |_| None,
2767 &mut promised,
2768 );
2769 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2770 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2771 assert!(promised[0].what.contains(form_method), "{promised:?}");
2772 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2773 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2774
2775 let mut kept = Vec::new();
2777 check_custom_grammar(
2778 CustomGrammar {
2779 kind,
2780 grammar: "PL/SQL",
2781 form_method,
2782 render_method,
2783 noun: "routines",
2784 },
2785 |spec| Some(format!("CREATE {} …", spec.name)),
2786 &mut kept,
2787 );
2788 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2789 }
2790 }
2791
2792 #[test]
2797 fn a_procedure_form_and_the_procedure_capability_must_agree() {
2798 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2799 let mut findings = Vec::new();
2800 check_routine_forms(dialect, &mut findings);
2801 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
2802 }
2803
2804 struct Claims<const CUSTOM: bool, const HAS: bool>;
2807 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2808 fn render_data_type(&self, dt: &DataType) -> String {
2809 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2810 }
2811 fn quote_identifier(&self, name: &str) -> String {
2812 format!("\"{name}\"")
2813 }
2814 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2815 self.quote_identifier(name)
2816 }
2817 fn render_current_timestamp(&self) -> String {
2818 "CURRENT_TIMESTAMP".to_owned()
2819 }
2820 fn procedure_form(&self) -> ProcedureForm {
2821 if CUSTOM {
2822 ProcedureForm::Custom("Firebird PSQL")
2823 } else {
2824 ProcedureForm::Unsupported(
2825 "This engine has no stored procedures; schedule the load as a query.",
2826 )
2827 }
2828 }
2829 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2830 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2831 }
2832 }
2833 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2834 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2835 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2836 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2837 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2838 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2839 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2840 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2841 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2842 fn supports_stored_procedures(&self) -> bool {
2843 HAS
2844 }
2845 }
2846 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2847 fn info(&self) -> DialectInfo {
2848 DialectInfo {
2849 name: "claimsdb",
2850 display_name: "Claims DB",
2851 aliases: &[],
2852 dialect_family: DialectFamily::Custom("claimsdb"),
2853 family: "Claims",
2854 is_cloud_native: false,
2855 is_olap: false,
2856 is_postgres_compatible: false,
2857 versions: &[],
2858 deploy_script: None,
2859 }
2860 }
2861 }
2862
2863 let disagreement = |report: &Report| {
2864 report
2865 .findings
2866 .iter()
2867 .filter(|finding| {
2868 finding.severity == Severity::Blocking
2869 && (finding.what.contains("says its engine has none")
2870 || finding.what.contains("refuses to write one"))
2871 })
2872 .count()
2873 };
2874 let mut findings = Vec::new();
2875 check_routine_forms(&Claims::<true, false>, &mut findings);
2876 let report =
2877 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
2878 assert_eq!(disagreement(&report), 1, "{report}");
2879 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2880
2881 for (agrees, label) in [
2882 (check_forms(&Claims::<true, true>), "spelled and used"),
2883 (check_forms(&Claims::<false, false>), "refused and absent"),
2884 ] {
2885 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2886 }
2887 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2888 }
2889
2890 #[test]
2896 fn a_custom_render_method_that_returns_nothing_is_blocking() {
2897 let mut findings = Vec::new();
2898 check_custom_grammar(
2899 CustomGrammar {
2900 kind: RoutineKind::Procedure,
2901 grammar: "PL/SQL",
2902 form_method: "procedure_form",
2903 render_method: "render_procedure",
2904 noun: "procedures",
2905 },
2906 |_| Some(" \n".to_owned()),
2907 &mut findings,
2908 );
2909 assert_eq!(findings.len(), 1, "{findings:?}");
2910 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2911 }
2912
2913 #[test]
2915 fn a_custom_grammar_with_no_name_is_blocking() {
2916 let mut findings = Vec::new();
2917 check_custom_grammar(
2918 CustomGrammar {
2919 kind: RoutineKind::ScalarFunction,
2920 grammar: " ",
2921 form_method: "scalar_function_form",
2922 render_method: "render_scalar_function",
2923 noun: "scalar functions",
2924 },
2925 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2926 &mut findings,
2927 );
2928 assert_eq!(findings.len(), 1, "{findings:?}");
2929 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2930 }
2931
2932 #[test]
2940 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2941 struct Overstated;
2942 impl SqlRenderer for Overstated {
2943 fn render_data_type(&self, dt: &DataType) -> String {
2944 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2945 }
2946 fn quote_identifier(&self, name: &str) -> String {
2947 format!("\"{name}\"")
2948 }
2949 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2950 name.to_owned()
2951 }
2952 fn render_current_timestamp(&self) -> String {
2953 "CURRENT_TIMESTAMP".to_owned()
2954 }
2955 fn dialect_name(&self) -> &'static str {
2956 "overstateddb"
2957 }
2958 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2959 Some(razeri_plugin_contract::SidHelperBody {
2960 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2961 body: " \n ",
2962 })
2963 }
2964 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2965 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2966 }
2967 }
2968 impl ExpressionRenderer for Overstated {}
2969 impl DdlRenderer for Overstated {}
2970 impl DmlRenderer for Overstated {}
2971 impl ProcedureRenderer for Overstated {}
2972 impl FormulaRenderer for Overstated {}
2973 impl TaskRenderer for Overstated {}
2974 impl ReplicationRenderer for Overstated {}
2975 impl StorageRenderer for Overstated {}
2976 impl DialectCapabilities for Overstated {}
2977 impl PlatformDialect for Overstated {
2978 fn info(&self) -> DialectInfo {
2979 DialectInfo {
2980 name: "overstateddb",
2981 display_name: "Overstated DB",
2982 aliases: &[],
2983 dialect_family: DialectFamily::Custom(""),
2984 family: "Overstated",
2985 is_cloud_native: false,
2986 is_olap: false,
2987 is_postgres_compatible: false,
2988 versions: &[],
2989 deploy_script: None,
2990 }
2991 }
2992 }
2993
2994 let report = check(&Overstated);
2995 for (fragment, why) in [
2996 ("supplies an empty one", "a blank helper body"),
2997 ("does not carry the path", "a file relation that drops its path"),
2998 ] {
2999 let found = report
3000 .findings
3001 .iter()
3002 .find(|f| f.what.contains(fragment))
3003 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
3004 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
3005 }
3006
3007 let quiet = check(&HealthyPlugin::new());
3010 for fragment in ["supplies an empty one", "does not carry the path"] {
3011 assert!(
3012 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
3013 "silence is the default and owes nothing:\n{quiet}"
3014 );
3015 }
3016 }
3017
3018 #[test]
3022 fn a_file_read_that_ignores_the_delimiter_quote_and_header_is_blocking() {
3023 struct Deaf<const OVERRIDES: bool>;
3024 impl<const OVERRIDES: bool> SqlRenderer for Deaf<OVERRIDES> {
3025 fn render_data_type(&self, dt: &DataType) -> String {
3026 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3027 }
3028 fn quote_identifier(&self, name: &str) -> String {
3029 format!("\"{name}\"")
3030 }
3031 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3032 name.to_owned()
3033 }
3034 fn render_current_timestamp(&self) -> String {
3035 "CURRENT_TIMESTAMP".to_owned()
3036 }
3037 fn delimited_file_relation(&self, path_literal: &str) -> Option<String> {
3038 Some(format!("scan({path_literal})"))
3039 }
3040 fn delimited_file_relation_for(&self, file: &DelimitedFile<'_>) -> Option<String> {
3041 if OVERRIDES {
3042 Some(format!("scan({})", file.path_literal))
3043 } else {
3044 file.is_plain().then(|| format!("scan({})", file.path_literal))
3046 }
3047 }
3048 }
3049 impl<const OVERRIDES: bool> ExpressionRenderer for Deaf<OVERRIDES> {}
3050 impl<const OVERRIDES: bool> DdlRenderer for Deaf<OVERRIDES> {}
3051 impl<const OVERRIDES: bool> DmlRenderer for Deaf<OVERRIDES> {}
3052 impl<const OVERRIDES: bool> ProcedureRenderer for Deaf<OVERRIDES> {}
3053 impl<const OVERRIDES: bool> FormulaRenderer for Deaf<OVERRIDES> {}
3054 impl<const OVERRIDES: bool> TaskRenderer for Deaf<OVERRIDES> {}
3055 impl<const OVERRIDES: bool> ReplicationRenderer for Deaf<OVERRIDES> {}
3056 impl<const OVERRIDES: bool> StorageRenderer for Deaf<OVERRIDES> {}
3057 impl<const OVERRIDES: bool> DialectCapabilities for Deaf<OVERRIDES> {}
3058 impl<const OVERRIDES: bool> PlatformDialect for Deaf<OVERRIDES> {
3059 fn info(&self) -> DialectInfo {
3060 DialectInfo {
3061 name: "deafdb",
3062 display_name: "Deaf DB",
3063 aliases: &[],
3064 dialect_family: DialectFamily::Custom(""),
3065 family: "Deaf",
3066 is_cloud_native: false,
3067 is_olap: false,
3068 is_postgres_compatible: false,
3069 versions: &[],
3070 deploy_script: None,
3071 }
3072 }
3073 }
3074
3075 let mut deaf = Vec::new();
3076 check_file_settings(&Deaf::<true>, &mut deaf);
3077 assert_eq!(deaf.len(), 3, "the delimiter, the quote and the header: {deaf:?}");
3078 assert!(deaf.iter().all(|finding| finding.severity == Severity::Blocking), "{deaf:?}");
3079 assert!(deaf[0].what.contains("delimited by `|`"), "{deaf:?}");
3080
3081 let mut default = Vec::new();
3082 check_file_settings(&Deaf::<false>, &mut default);
3083 assert!(default.is_empty(), "`None` is a complete answer: {default:?}");
3084 }
3085
3086 #[test]
3090 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
3091 let mut mute = Vec::new();
3092 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
3093 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
3094 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
3095
3096 let silence = check(&BrokenPlugin);
3097 assert!(
3098 silence
3099 .findings
3100 .iter()
3101 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
3102 "a dialect that never answered is unanswered, not wrong:\n{silence}"
3103 );
3104 assert!(
3105 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
3106 "the fix line names the way to state a measured refusal:\n{silence}"
3107 );
3108 }
3109
3110 #[test]
3114 fn the_report_reads_as_a_summary_then_a_fix_list() {
3115 let text = check(&BrokenPlugin).to_string();
3116 let first = text.lines().next().expect("a summary line");
3117 assert!(first.starts_with("brokendb:"), "{first}");
3118 assert!(first.contains("to fix"), "{first}");
3119 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
3120 }
3121}