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 ProcedureRenderer, ReplicationRenderer, RoutineKind, RoutineSpec,
50 SID_PREREQUISITE_STATEMENT_DELIMITER, ScalarFunctionForm, SidPrerequisiteKind, SliceRef,
51 SliceReplacement, SqlRenderer, StorageRenderer, TableFunctionForm, TableRole, UNKNOWN_PLATFORM,
52 WrittenObject,
53 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
54 storage_strategy::{
55 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
56 },
57};
58use std::fmt;
59use std::fmt::Write as _;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum Severity {
64 Blocking,
67 Unanswered,
70 Note,
72}
73
74impl fmt::Display for Severity {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(match self {
77 Self::Blocking => "must fix",
78 Self::Unanswered => "unanswered",
79 Self::Note => "worth a look",
80 })
81 }
82}
83
84#[derive(Debug, Clone)]
87pub struct Finding {
88 pub severity: Severity,
90 pub what: String,
92 pub fix: String,
94}
95
96#[derive(Debug, Clone)]
98pub struct Report {
99 pub dialect: String,
101 pub findings: Vec<Finding>,
103 pub taken_as_default: Vec<TakenAsDefault>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct TakenAsDefault {
113 pub name: &'static str,
115 pub shows_in: &'static str,
117 pub answer: String,
119}
120
121impl Report {
122 #[must_use]
126 pub fn is_clean(&self) -> bool {
127 !self.findings.iter().any(|f| f.severity != Severity::Note)
128 }
129
130 #[must_use]
132 pub fn count(&self, severity: Severity) -> usize {
133 self.findings.iter().filter(|f| f.severity == severity).count()
134 }
135}
136
137impl Report {
138 #[must_use]
145 pub fn head(&self) -> String {
146 let mut out = String::new();
147 let name = &self.dialect;
148 let blocking = self.count(Severity::Blocking);
149 let summary = if self.findings.is_empty() {
150 "nothing to fix".to_owned()
151 } else {
152 format!(
153 "{blocking} to fix, {} unanswered, {} worth a look",
154 self.count(Severity::Unanswered),
155 self.count(Severity::Note)
156 )
157 };
158 let _ = writeln!(out, "{name}: {summary}, in the answers this dialect states.");
159 let zero = if blocking == 0 {
160 let phrase = if self.findings.is_empty() { "nothing to fix" } else { "0 to fix" };
161 format!(" \"{phrase}\" means no question is open, not that the SQL runs.")
162 } else {
163 String::new()
164 };
165 let _ = writeln!(
166 out,
167 "This report read the dialect's answers and ran nothing on an engine.{zero} Only the \
168 SQL Razeri renders for it, run on your engine, shows that it runs."
169 );
170 for finding in &self.findings {
171 let _ = writeln!(out);
172 let _ = writeln!(out, " [{}] {}", finding.severity, finding.what);
173 let _ = writeln!(out, " -> {}", finding.fix);
174 }
175 out
176 }
177}
178
179impl fmt::Display for Report {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.write_str(&self.head())?;
182 writeln!(f)?;
183 if self.taken_as_default.is_empty() {
184 return writeln!(
185 f,
186 "This dialect answers every question Razeri asks here about the shape of a \
187 statement differently from the contract's default."
188 );
189 }
190 let count = self.taken_as_default.len();
191 writeln!(
192 f,
193 "Taken as the contract's default: this dialect answers {count} {} as the contract's \
194 default does, so Razeri writes the default's SQL, shown beside each. Confirm each \
195 on your engine.",
196 if count == 1 { "question" } else { "questions" }
197 )?;
198 for taken in &self.taken_as_default {
199 writeln!(f)?;
200 writeln!(f, " {} -> {}", taken.name, taken.answer)?;
201 writeln!(f, " in {}", taken.shows_in)?;
202 }
203 Ok(())
204 }
205}
206
207#[derive(Clone, Copy)]
210enum Asked {
211 Sql(fn(&dyn SqlRenderer) -> String),
212 Expression(fn(&dyn ExpressionRenderer) -> String),
213 Ddl(fn(&dyn DdlRenderer) -> String),
214 Procedure(fn(&dyn ProcedureRenderer) -> String),
215 Capabilities(fn(&dyn DialectCapabilities) -> String),
216}
217
218#[derive(Clone, Copy)]
221struct Seam {
222 name: &'static str,
223 shows_in: &'static str,
224 asked: Asked,
225}
226
227impl Seam {
228 fn answer_of(&self, dialect: &dyn PlatformDialect) -> String {
230 match self.asked {
231 Asked::Sql(answer) => answer(dialect),
232 Asked::Expression(answer) => answer(dialect),
233 Asked::Ddl(answer) => answer(dialect),
234 Asked::Procedure(answer) => answer(dialect),
235 Asked::Capabilities(answer) => answer(dialect),
236 }
237 }
238
239 fn default_of(&self, defaults: &ContractDefaults<'_>) -> String {
241 match self.asked {
242 Asked::Sql(answer) => answer(defaults),
243 Asked::Expression(answer) => answer(defaults),
244 Asked::Ddl(answer) => answer(defaults),
245 Asked::Procedure(answer) => answer(defaults),
246 Asked::Capabilities(answer) => answer(defaults),
247 }
248 }
249}
250
251fn statement_seams() -> &'static [Seam] {
259 SEAMS
260}
261
262const SCHEMA: &str = "schema.sql";
263const LOADS: &str = "loads.sql";
264const RECONCILE: &str = "reconcile.sql";
265const EVERY: &str = "every file";
266const fn seam(name: &'static str, shows_in: &'static str, asked: Asked) -> Seam {
267 Seam { name, shows_in, asked }
268}
269const SEAMS: &[Seam] = &[
270 seam(
271 "DdlRenderer::supports_if_not_exists",
272 SCHEMA,
273 Asked::Ddl(|d| said(d.supports_if_not_exists())),
274 ),
275 seam(
276 "DdlRenderer::supports_primary_key_constraint",
277 SCHEMA,
278 Asked::Ddl(|d| said(d.supports_primary_key_constraint())),
279 ),
280 seam(
281 "DdlRenderer::supports_foreign_key_constraint",
282 SCHEMA,
283 Asked::Ddl(|d| said(d.supports_foreign_key_constraint())),
284 ),
285 seam(
286 "DdlRenderer::render_create_view_prefix",
287 SCHEMA,
288 Asked::Ddl(|d| quoted(d.render_create_view_prefix())),
289 ),
290 seam(
291 "DdlRenderer::replaces_a_view_that_is_read",
292 SCHEMA,
293 Asked::Ddl(|d| said(d.replaces_a_view_that_is_read())),
294 ),
295 seam(
296 "DdlRenderer::render_drop_view_prefix",
297 SCHEMA,
298 Asked::Ddl(|d| quoted(&d.render_drop_view_prefix(true))),
299 ),
300 seam(
301 "DdlRenderer::render_drop_table_prefix",
302 SCHEMA,
303 Asked::Ddl(|d| quoted(&d.render_drop_table_prefix(true))),
304 ),
305 seam(
306 "DdlRenderer::render_drop_function",
307 SCHEMA,
308 Asked::Ddl(|d| quoted(&d.render_drop_function("f", &["BIGINT"], true))),
309 ),
310 seam(
311 "DdlRenderer::supports_drop_function_arg_types",
312 SCHEMA,
313 Asked::Ddl(|d| said(d.supports_drop_function_arg_types())),
314 ),
315 seam(
316 "DdlRenderer::render_create_schema_statement",
317 SCHEMA,
318 Asked::Ddl(|d| quoted(&d.render_create_schema_statement("s", true))),
319 ),
320 seam(
321 "DialectCapabilities::supports_schemas",
322 SCHEMA,
323 Asked::Capabilities(|d| said(d.supports_schemas())),
324 ),
325 seam(
326 "SqlRenderer::uses_schema_in_qualified_names",
327 SCHEMA,
328 Asked::Sql(|d| said(d.uses_schema_in_qualified_names())),
329 ),
330 seam(
331 "DdlRenderer::supports_ddl_transactions",
332 SCHEMA,
333 Asked::Ddl(|d| said(d.supports_ddl_transactions())),
334 ),
335 seam(
336 "DdlRenderer::render_begin_transaction",
337 SCHEMA,
338 Asked::Ddl(|d| quoted(d.render_begin_transaction())),
339 ),
340 seam("DdlRenderer::render_commit", SCHEMA, Asked::Ddl(|d| quoted(d.render_commit()))),
341 seam(
342 "SqlRenderer::scalar_function_body_is_expression",
343 SCHEMA,
344 Asked::Sql(|d| said(d.scalar_function_body_is_expression())),
345 ),
346 seam(
347 "DialectCapabilities::max_identifier_length",
348 EVERY,
349 Asked::Capabilities(|d| said(d.max_identifier_length())),
350 ),
351 seam(
352 "DialectCapabilities::begin_transaction_keyword",
353 LOADS,
354 Asked::Capabilities(|d| quoted(d.begin_transaction_keyword())),
355 ),
356 seam(
357 "SqlRenderer::merge_source_requires_inlined_ctes",
358 LOADS,
359 Asked::Sql(|d| said(d.merge_source_requires_inlined_ctes())),
360 ),
361 seam(
362 "SqlRenderer::insert_accepts_leading_ctes",
363 LOADS,
364 Asked::Sql(|d| said(d.insert_accepts_leading_ctes())),
365 ),
366 seam(
367 "SqlRenderer::is_reserved_word",
368 LOADS,
369 Asked::Sql(|d| {
370 if d.is_reserved_word("matched") {
371 "`matched` is reserved: a load writes it quoted".to_owned()
372 } else {
373 "`matched` is free: a load writes `1 AS matched`".to_owned()
374 }
375 }),
376 ),
377 seam(
378 "SqlRenderer::render_empty_table",
379 LOADS,
380 Asked::Sql(|d| quoted(&d.render_empty_table("t"))),
381 ),
382 seam(
383 "SqlRenderer::render_procedure_variable_reference",
384 LOADS,
385 Asked::Sql(|d| quoted(&d.render_procedure_variable_reference("v"))),
386 ),
387 seam(
388 "ExpressionRenderer::render_concat",
389 LOADS,
390 Asked::Expression(|d| quoted(&d.render_concat(&["a".into(), "b".into()]))),
391 ),
392 seam(
393 "ExpressionRenderer::render_decimal_fit",
394 LOADS,
395 Asked::Expression(|d| match d.render_decimal_fit("v", 18, 2) {
396 Some(fit) => quoted(&format!(
397 "plain: {}; too large: {}; more places: {}",
398 fit.plain, fit.past_room, fit.past_scale
399 )),
400 None => "none: a number's fit to its decimal column is asked portably, nine `LIKE` \
401 patterns for each digit's place"
402 .to_owned(),
403 }),
404 ),
405 seam(
406 "ProcedureRenderer::render_procedure_table",
407 LOADS,
408 Asked::Procedure(|d| match d.render_procedure_table("t", "SELECT 1 AS c") {
409 Some(table) => {
410 quoted(&format!("{} read as {}", table.statements.join(" "), table.reference))
411 }
412 None => "none: each statement of a load converts the staged rows itself".to_owned(),
413 }),
414 ),
415 seam("SqlRenderer::render_comment", LOADS, Asked::Sql(|d| quoted(&d.render_comment("a note")))),
416 seam(
417 "SqlRenderer::statement_separator",
418 LOADS,
419 Asked::Sql(|d| quoted(d.statement_separator())),
420 ),
421 seam(
422 "SqlRenderer::render_one_row_source",
423 RECONCILE,
424 Asked::Sql(|d| match d.render_one_row_source() {
425 Some(source) => quoted(&source),
426 None => "none: a `SELECT` with no `FROM`".to_owned(),
427 }),
428 ),
429];
430
431fn said(answer: impl fmt::Display) -> String {
433 format!("`{answer}`")
434}
435
436fn quoted(sql: &str) -> String {
438 format!("`{}`", sql.replace('\n', "\\n"))
439}
440
441#[must_use]
452pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
453 let defaults = ContractDefaults { graded: dialect };
454 statement_seams()
455 .iter()
456 .filter_map(|seam| {
457 let default = seam.default_of(&defaults);
458 (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
459 name: seam.name,
460 shows_in: seam.shows_in,
461 answer: default,
462 })
463 })
464 .collect()
465}
466
467struct ContractDefaults<'a> {
471 graded: &'a dyn PlatformDialect,
472}
473
474impl SqlRenderer for ContractDefaults<'_> {
475 fn render_data_type(&self, dt: &DataType) -> String {
476 self.graded.render_data_type(dt)
477 }
478
479 fn quote_identifier(&self, name: &str) -> String {
480 self.graded.quote_identifier(name)
481 }
482
483 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
484 self.graded.qualify_name(schema, name)
485 }
486
487 fn render_current_timestamp(&self) -> String {
488 self.graded.render_current_timestamp()
489 }
490}
491impl ExpressionRenderer for ContractDefaults<'_> {}
492impl DdlRenderer for ContractDefaults<'_> {}
493impl ProcedureRenderer for ContractDefaults<'_> {}
494impl DialectCapabilities for ContractDefaults<'_> {}
495
496#[must_use]
503pub fn check(dialect: &dyn PlatformDialect) -> Report {
504 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
505}
506
507#[must_use]
510pub fn check_with_registry(
511 dialect: &dyn PlatformDialect,
512 registry: &crate::dialect::PlatformRegistry,
513) -> Report {
514 let info = dialect.info();
515 let mut findings = Vec::new();
516
517 check_registration(&info, registry, &mut findings);
518 check_family(info.dialect_family, &mut findings);
519 check_required_rendering(dialect, &mut findings);
520 check_decisions(dialect, &mut findings);
521 check_formula_renderer(dialect, &mut findings);
522 check_evolution(dialect, &mut findings);
523 check_run_again(dialect, &mut findings);
524 check_transactions(dialect, &mut findings);
525 check_paired_facts(dialect, &mut findings);
526 check_schema_fold(dialect, &mut findings);
527 check_deploy_client(&info, &mut findings);
528
529 findings.sort_by_key(|f| f.severity);
530 Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
531}
532
533fn check_registration(
544 info: &crate::dialect::DialectInfo,
545 registry: &crate::dialect::PlatformRegistry,
546 findings: &mut Vec<Finding>,
547) {
548 let name = info.name;
549 if matches!(
550 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
551 Err(crate::ResolveError::UnknownDialect { .. })
552 ) {
553 findings.push(Finding {
554 severity: Severity::Blocking,
555 what: format!(
556 "the name `{name}` does not resolve; nothing in this program registered it"
557 ),
558 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
559 crate's `lib.rs`, where `register` calls \
560 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
561 dialect one the registry finds, whether razeri loads your library from a \
562 plugin directory or links your crate in."
563 .to_owned(),
564 });
565 return;
566 }
567 for spelling in info.spellings() {
568 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
569 Ok(found) if found.info().name == name => {}
570 Ok(found) => findings.push(Finding {
571 severity: Severity::Blocking,
572 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
573 fix: format!(
574 "the registry holds `{name}` under another key. Register it under its own \
575 name, or drop `{spelling}` from its aliases."
576 ),
577 }),
578 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
579 severity: Severity::Blocking,
580 what: format!(
581 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
582 none of them; Razeri refuses that spelling rather than guessing which one \
583 a design meant",
584 claimed_by.join(" and ")
585 ),
586 fix: format!(
587 "give this dialect a name no other platform answers to. A platform name is \
588 read off the loaded dialects' own declared spellings, so a spelling another \
589 platform already claims is never `{name}`'s."
590 ),
591 }),
592 Err(_) => findings.push(Finding {
593 severity: Severity::Blocking,
594 what: format!(
595 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
596 ),
597 fix: format!(
598 "the `{name}` in the registry is a different build of this dialect and does \
599 not declare `{spelling}`. Register the one you are grading."
600 ),
601 }),
602 }
603 }
604}
605
606fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
609 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
610 findings.push(Finding {
611 severity: Severity::Blocking,
612 what: "this dialect's family is a custom one with no name".to_owned(),
613 fix: "give `DialectFamily::Custom` your engine's own name; it is what a report \
614 prints and what a reader looks up."
615 .to_owned(),
616 });
617 }
618}
619
620fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
622 let declarable = declarable_types();
623 let empty_column: Vec<String> = declarable
624 .iter()
625 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
626 .map(|dt| format!("{dt:?}"))
627 .collect();
628 if !empty_column.is_empty() {
629 findings.push(Finding {
630 severity: Severity::Blocking,
631 what: format!(
632 "`render_data_type` answers nothing for {} of the types a design can declare",
633 empty_column.len()
634 ),
635 fix: "every declarable type needs a column spelling on this engine, even if the \
636 spelling is a wider type that stores it. A blank answer becomes a column \
637 declaration with no type in it."
638 .to_owned(),
639 });
640 }
641 let empty_cast: Vec<String> = declarable
642 .iter()
643 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
644 .map(|dt| format!("{dt:?}"))
645 .collect();
646 if !empty_cast.is_empty() {
647 findings.push(Finding {
648 severity: Severity::Blocking,
649 what: format!(
650 "`render_cast_type` answers nothing for {} declarable types",
651 empty_cast.len()
652 ),
653 fix: "the generated load opens by casting every source column to its declared type, \
654 so a blank answer fails the load on its first statement."
655 .to_owned(),
656 });
657 }
658 let quoted = SqlRenderer::quote_identifier(dialect, "order");
659 if quoted == "order" {
660 findings.push(Finding {
661 severity: Severity::Blocking,
662 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
663 reaches this engine bare"
664 .to_owned(),
665 fix: "wrap the identifier in whatever this engine quotes with: double quotes, \
666 backticks, or brackets."
667 .to_owned(),
668 });
669 }
670 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
671 findings.push(Finding {
672 severity: Severity::Blocking,
673 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
674 a row with it"
675 .to_owned(),
676 fix: "return this engine's own expression for the current moment.".to_owned(),
677 });
678 }
679}
680
681fn check_encoded_file_relation(
693 dialect: &dyn PlatformDialect,
694 probe: &str,
695 findings: &mut Vec<Finding>,
696) {
697 let Some(relation) =
698 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
699 else {
700 return;
701 };
702 if !relation.contains(probe) {
703 findings.push(Finding {
704 severity: Severity::Blocking,
705 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
706 the path it was given"
707 .to_owned(),
708 fix: "put the literal you were handed where your engine's file relation takes a \
709 path. It arrives already quoted and escaped."
710 .to_owned(),
711 });
712 }
713 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
714 findings.push(Finding {
715 severity: Severity::Blocking,
716 what: "this dialect states how to read a file in a named encoding and states no way \
717 to read a file at all"
718 .to_owned(),
719 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
720 first question before the second, a source whose bytes are UTF-8 never \
721 reaches the encoding arm, so an engine that answers only the second has \
722 written a relation nothing will ask it for."
723 .to_owned(),
724 });
725 }
726}
727
728fn check_file_settings(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
741 let columns = ["a".to_owned(), "b".to_owned()];
742 let plain =
743 DelimitedFile::new("'/razeri/probe.csv'", "'/razeri'", "probe.csv").with_columns(&columns);
744 let read = |file: &DelimitedFile<'_>| {
745 SqlRenderer::delimited_file_relation_for(dialect, file)
746 .map(|relation| (relation, SqlRenderer::delimited_file_statements_for(dialect, file)))
747 };
748 let Some(plain_read) = read(&plain) else { return };
749 for (told, said) in [
750 (plain.with_delimiter(FileDelimiter::Pipe), "a file delimited by `|`"),
751 (plain.with_quote('\''), "a file quoted with `'`"),
752 (plain.with_header(false), "a file with no header line"),
753 ] {
754 if read(&told).as_ref() == Some(&plain_read) {
755 findings.push(Finding {
756 severity: Severity::Blocking,
757 what: format!(
758 "`delimited_file_relation_for` reads {said} exactly as it reads a \
759 comma-delimited file with a header"
760 ),
761 fix: "put the file's `delimiter`, `quote` and `header` where your engine's read \
762 takes them, or answer `None` for a file your engine cannot be told about. \
763 Razeri then declines that file by name, rather than loading each line into \
764 one column."
765 .to_owned(),
766 });
767 }
768 }
769}
770
771#[must_use]
787pub fn key_refusal_reason(dialect: &dyn PlatformDialect) -> Option<String> {
788 if ReplicationRenderer::sid_prerequisite_kind(dialect) != SidPrerequisiteKind::Refused {
789 return None;
790 }
791 let script = ReplicationRenderer::sid_prerequisites(dialect);
792 let lines: Vec<&str> = script
793 .lines()
794 .map(str::trim)
795 .filter(|line| *line != SID_PREREQUISITE_STATEMENT_DELIMITER.trim())
796 .map(|line| line.strip_prefix("--").map_or(line, str::trim))
797 .map(|line| line.strip_prefix("/*").map_or(line, str::trim))
798 .map(|line| line.strip_suffix("*/").map_or(line, str::trim))
799 .collect();
800 Some(lines.join("\n").trim().to_owned())
801}
802
803fn first_sentence(prose: &str) -> String {
805 let flat = prose.split_whitespace().collect::<Vec<_>>().join(" ");
806 flat.find(". ").map_or(flat.clone(), |end| flat[..=end].to_owned())
807}
808
809fn check_key_refusal(reason: &str, findings: &mut Vec<Finding>) {
817 if reason.is_empty() {
818 findings.push(Finding {
819 severity: Severity::Blocking,
820 what: "this dialect says its engine cannot compute razeri's surrogate key, and gives \
821 no reason"
822 .to_owned(),
823 fix: "return the reason from `ReplicationRenderer::sid_prerequisites`, as comment \
824 lines: why this engine cannot compute XXH64 with seed 0, and what an operator \
825 can do instead. Razeri prints it where it refuses a design that keys."
826 .to_owned(),
827 });
828 return;
829 }
830 findings.push(Finding {
831 severity: Severity::Note,
832 what: "this dialect says its engine cannot compute razeri's surrogate key, so razeri \
833 renders a design that needs no key and refuses one that keys, with this reason"
834 .to_owned(),
835 fix: format!("its reason: {}", first_sentence(reason)),
836 });
837}
838
839fn check_key_spelling(
843 dialect: &dyn PlatformDialect,
844 spelling: &razeri_plugin_contract::SidSpelling,
845 findings: &mut Vec<Finding>,
846) {
847 let refusal = key_refusal_reason(dialect);
848 if let Some(reason) = &refusal {
849 check_key_refusal(reason, findings);
850 }
851 if spelling.platform == UNKNOWN_PLATFORM && refusal.is_none() {
852 findings.push(Finding {
853 severity: Severity::Blocking,
854 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
855 an `xxhash64` its engine very likely does not have"
856 .to_owned(),
857 fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
858 replacement and a Unicode literal, how it joins strings, how it casts to text, \
859 and how it spells a 64-bit hash. If it has no portable hash, name an artifact \
860 your prerequisites install: never the nearest-looking built-in, which keys a \
861 warehouse to a number nothing else can reproduce. If the engine cannot define \
862 a function at all, answer `ReplicationRenderer::sid_prerequisite_kind` with \
863 `SidPrerequisiteKind::Refused` and say why in `sid_prerequisites`: that is an \
864 answer, and razeri then refuses only the designs that key."
865 .to_owned(),
866 });
867 }
868}
869
870fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
872 let spelling = SqlRenderer::sid_spelling(dialect);
873 check_key_spelling(dialect, &spelling, findings);
874
875 let join = match spelling.concat {
879 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
880 Concat::Plus => "+",
881 };
882 if join.trim().is_empty() {
883 findings.push(Finding {
884 severity: Severity::Blocking,
885 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
886 with a function or operator that has no name"
887 .to_owned(),
888 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
889 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
890 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
891 operator and no function."
892 .to_owned(),
893 });
894 }
895
896 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
901 && supplied.body.trim().is_empty()
902 {
903 findings.push(Finding {
904 severity: Severity::Blocking,
905 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
906 and then supplies an empty one"
907 .to_owned(),
908 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
909 one argument, or answer `None` and let Razeri compose the derivation from \
910 your `sid_spelling`. An empty body deploys without error and keys every row \
911 to nothing."
912 .to_owned(),
913 });
914 }
915
916 let probe = "'/razeri/probe.csv'";
919 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
920 && !relation.contains(probe)
921 {
922 findings.push(Finding {
923 severity: Severity::Blocking,
924 what: "`delimited_file_relation` returns a relation that does not carry the path it \
925 was given"
926 .to_owned(),
927 fix: "put the literal you were handed where your engine's file relation takes a \
928 path. It arrives already quoted and escaped. A relation that drops it reads \
929 one file for every entity the design binds, and the generated fill loads the \
930 wrong rows without failing."
931 .to_owned(),
932 });
933 }
934
935 check_encoded_file_relation(dialect, probe, findings);
936 check_file_settings(dialect, findings);
937 check_routine_forms(dialect, findings);
938 check_slice_replacement(dialect, findings);
939
940 if let ConstraintEnforcement::DeclaredOnly(declared) =
943 DdlRenderer::constraint_enforcement(dialect)
944 && declared.note.split_whitespace().count() < 5
945 {
946 findings.push(Finding {
947 severity: Severity::Blocking,
948 what: "this dialect says its engine accepts constraints it does not enforce, and \
949 the note explaining that says almost nothing"
950 .to_owned(),
951 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
952 guarantee. Say what this engine really does with the declaration, and where \
953 the guarantee has to come from instead."
954 .to_owned(),
955 });
956 }
957
958 match DialectCapabilities::merge_shape(dialect) {
964 MergeShape::None => findings.push(Finding {
965 severity: Severity::Unanswered,
966 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
967 load for it"
968 .to_owned(),
969 fix: "if this engine has a MERGE or an upsert statement, say which in \
970 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
971 `MergeShape::Unsupported` with your engine's own sentence, a refusal an \
972 operator can act on, rather than a silence."
973 .to_owned(),
974 }),
975 MergeShape::Unsupported(orientation) => {
976 check_refusal_copy("the upsert refusal", orientation, findings);
977 }
978 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
979 _ => {}
980 }
981}
982
983fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
998 const VALUE: &str = "$razeri_slice_probe";
999 let plan = StoragePlan {
1000 partition: PartitionStrategy::RangeByTimeDimension {
1001 field: "razeri_probe_day".to_owned(),
1002 granularity: PartitionGranularity::Day,
1003 retention: None,
1004 look_ahead: None,
1005 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
1006 },
1007 compression: CompressionStrategy::None,
1008 index: IndexStrategy::None,
1009 };
1010 let day = DataType::date();
1011 let columns = ["razeri_probe_day"];
1012 let entity = EntityRef {
1013 schema: Some("razeri_probe"),
1014 name: "razeri_probe_table",
1015 role: TableRole::Written,
1016 columns: &columns,
1017 partition_column_type: Some(&day),
1018 };
1019 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
1020 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
1021 else {
1022 return;
1023 };
1024
1025 let carries = match &answer {
1026 SliceReplacement::Emptied(statements) => {
1027 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
1028 }
1029 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
1030 };
1031 if carries {
1037 return;
1038 }
1039 findings.push(Finding {
1040 severity: Severity::Blocking,
1041 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
1042 was handed"
1043 .to_owned(),
1044 fix: "put the value you were given where your statement names the slice, and build any \
1045 partition NAME from it the same way your extend procedure built it. A replacement \
1046 that drops the value clears a slice this call was not given, or every slice, \
1047 and the load reports success over what it emptied."
1048 .to_owned(),
1049 });
1050}
1051
1052fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1069 let answers = DialectCapabilities::evolution_answers(dialect);
1070 let boolean = DataType::boolean();
1071 let long = DataType::varchar(40);
1072
1073 if DdlRenderer::render_column_evolution(
1074 dialect,
1075 &ColumnEvolution::AddColumn {
1076 schema: Some("s"),
1077 table: "t",
1078 column: "c",
1079 data_type: &boolean,
1080 },
1081 )
1082 .is_none()
1083 {
1084 findings.push(Finding {
1085 severity: Severity::Blocking,
1086 what: "this dialect writes no statement for a column that is new, which is the \
1087 commonest change a deployed warehouse ever sees"
1088 .to_owned(),
1089 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
1090 override and take the contract's `ALTER TABLE ... ADD COLUMN ...`, which most \
1091 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
1092 engine that genuinely has no way to add a column is worth saying out loud in \
1093 the crate's own documentation."
1094 .to_owned(),
1095 });
1096 }
1097
1098 if answers.parameterized_string_widening_is_lossless
1099 && DdlRenderer::render_column_evolution(
1100 dialect,
1101 &ColumnEvolution::WidenColumn {
1102 schema: Some("s"),
1103 table: "t",
1104 column: "c",
1105 data_type: &long,
1106 },
1107 )
1108 .is_none()
1109 {
1110 findings.push(Finding {
1111 severity: Severity::Note,
1112 what: "this dialect says its engine widens a string's declared length in place, and \
1113 writes no statement for the change"
1114 .to_owned(),
1115 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
1116 engine's spelling of it. Without it Razeri tells an operator the widening \
1117 costs nothing and then hands them nothing to run, which reads as Razeri not \
1118 having finished rather than as a statement they own."
1119 .to_owned(),
1120 });
1121 }
1122
1123 if answers.nested_field_addition_is_lossless
1124 && DdlRenderer::render_column_evolution(
1125 dialect,
1126 &ColumnEvolution::AddNestedField {
1127 schema: Some("s"),
1128 table: "t",
1129 column: "c",
1130 field: "f",
1131 data_type: &long,
1132 },
1133 )
1134 .is_none()
1135 {
1136 findings.push(Finding {
1137 severity: Severity::Note,
1138 what: "this dialect says its engine keeps every value when a record gains a field, \
1139 and writes no statement for the change"
1140 .to_owned(),
1141 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
1142 engines name the new field; some restate the whole column's type, which is \
1143 why the change carries both the field's path and the column's type with the \
1144 field in it."
1145 .to_owned(),
1146 });
1147 }
1148}
1149
1150fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1159 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
1160 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
1161 let relation = format!("{schema}.{name}");
1162 let table = WrittenObject::new(Some(&schema), &name, &relation);
1163 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
1164 let mut lost: Vec<&str> = Vec::new();
1165
1166 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
1167 Some(drop) if drop.text().contains(&column) => {}
1168 _ => lost.push("render_drop_column"),
1169 }
1170 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
1171 && !guarded.text().contains(&column)
1172 {
1173 lost.push("render_drop_column (only where it stands)");
1174 }
1175 for only_where_it_stands in [false, true] {
1176 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1177 && !drop.text().contains(&name)
1178 {
1179 lost.push("render_drop_view");
1180 }
1181 }
1182 let copy_name = SqlRenderer::quote_identifier(dialect, "razeri_probe_copy");
1184 let copy_relation = format!("{schema}.{copy_name}");
1185 let copy = WrittenObject::new(Some(&schema), ©_name, ©_relation);
1186 let copied = DdlRenderer::render_copy_table(dialect, table, copy);
1187 if !(copied.text().contains(&relation) && copied.text().contains(©_relation)) {
1188 lost.push("render_copy_table");
1189 }
1190 let create = format!("CREATE TABLE {relation} (a INTEGER)");
1191 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1192 && !guarded.text().contains(&create)
1193 {
1194 lost.push("render_create_table_if_absent");
1195 }
1196 lost.dedup();
1197 if !lost.is_empty() {
1198 findings.push(Finding {
1199 severity: Severity::Blocking,
1200 what: format!(
1201 "{} returns a statement that does not carry the object it was handed",
1202 lost.join(", ")
1203 ),
1204 fix: "put the names you were given where your statement names the object, and keep \
1205 a create you guard whole inside the guard. A drop that loses its column drops \
1206 another one or none, and a guard that loses its statement makes nothing: \
1207 both under a run that reports success."
1208 .to_owned(),
1209 });
1210 }
1211
1212 let value = "razeri_probe_value";
1215 let loses_its_value = declarable_types().iter().any(|data_type| {
1216 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1217 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1218 .is_some_and(|compared| !compared.contains(value))
1219 })
1220 });
1221 if loses_its_value {
1222 findings.push(Finding {
1223 severity: Severity::Blocking,
1224 what: "`render_comparable` answers an expression that does not carry the value it \
1225 stands for"
1226 .to_owned(),
1227 fix: "answer the value itself where your engine compares the type directly, an \
1228 expression over it where it compares one only that way, and `None` where it \
1229 cannot compare one at all, then Razeri refuses such a column by name."
1230 .to_owned(),
1231 });
1232 }
1233
1234 if let Some(limits) = DialectCapabilities::text_limits(dialect)
1237 && (limits.longest_varchar == Some(0)
1238 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1239 {
1240 findings.push(Finding {
1241 severity: Severity::Blocking,
1242 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1243 unbounded text cannot be"
1244 .to_owned(),
1245 fix: "state the longest varchar(n) your engine creates, one more is refused at \
1246 CREATE TABLE, and name what it refuses a text column as, or leave it None."
1247 .to_owned(),
1248 });
1249 }
1250
1251 match DialectCapabilities::catalogue_reach(dialect) {
1252 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1253 check_refusal_copy("the reason the schema check is not asked", reason, findings);
1254 }
1255 _ => {}
1256 }
1257}
1258
1259fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1263 if SqlRenderer::render_procedure_log(
1266 dialect,
1267 razeri_plugin_contract::ProcedureLogLevel::Info,
1268 "'razeri'",
1269 )
1270 .is_some_and(|line| line.trim().is_empty())
1271 {
1272 findings.push(Finding {
1273 severity: Severity::Blocking,
1274 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1275 fix: "answer `None` where your engine has no statement that writes a message: the \
1276 line is then left out of the routine."
1277 .to_owned(),
1278 });
1279 }
1280 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1281 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1282 {
1283 findings.push(Finding {
1284 severity: Severity::Blocking,
1285 what: "this dialect says its begin statement leaves the commit to the client, and \
1286 that its engine has no transaction spanning statements"
1287 .to_owned(),
1288 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1289 run in one transaction. Where there is none, the bundle already tells the \
1290 operator the boundary is theirs."
1291 .to_owned(),
1292 });
1293 }
1294}
1295
1296fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1307 if restrictions.slice_pruning_needs_literal {
1308 findings.push(Finding {
1309 severity: Severity::Note,
1310 what: "this dialect says its planner reads one slice of a partitioned table only \
1311 from a literal, so a load scoped to one day cannot simply bind the day"
1312 .to_owned(),
1313 fix: "Razeri will put the day into the statement text through this engine's \
1314 dynamic-execution form rather than binding it to the load procedure's \
1315 parameter. Confirm that is what your engine wants: the statement is composed \
1316 once per call and its text differs per day, which some planners cache and \
1317 some do not. If a bound parameter does prune here, drop the restriction and \
1318 Razeri binds it like everywhere else."
1319 .to_owned(),
1320 });
1321 }
1322}
1323
1324fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1327 let bitwise = BitwiseOperator::ALL.map(|operator| {
1331 (
1332 format!("render_bitwise_operator({operator:?})"),
1333 FormulaRenderer::render_bitwise_operator(dialect, operator),
1334 )
1335 });
1336 let blank: Vec<String> = [
1337 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1338 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1339 (
1340 "render_not_ilike_operator".to_owned(),
1341 FormulaRenderer::render_not_ilike_operator(dialect),
1342 ),
1343 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1344 ]
1345 .into_iter()
1346 .chain(bitwise)
1347 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1348 .map(|(method, _)| method)
1349 .collect();
1350 if !blank.is_empty() {
1351 findings.push(Finding {
1352 severity: Severity::Blocking,
1353 what: format!("{} answers an operator with no text in it", blank.join(", ")),
1354 fix: "answer `None` where your engine has no such operator: a formula that needs it \
1355 is then refused by name, and the reader is told to write the function your \
1356 engine has. An empty answer writes the two operands side by side."
1357 .to_owned(),
1358 });
1359 }
1360
1361 let formula = SqlRenderer::to_formula_renderer(dialect);
1362 let dialect_name = SqlRenderer::dialect_name(dialect);
1363 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1364 findings.push(Finding {
1365 severity: Severity::Blocking,
1366 what: "`to_formula_renderer` was not overridden, so every formula in every design \
1367 renders through Razeri's ANSI defaults instead of this dialect"
1368 .to_owned(),
1369 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1370 formula this engine sees is silently somebody else's."
1371 .to_owned(),
1372 });
1373 }
1374}
1375
1376fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1382 match SqlRenderer::procedure_form(dialect) {
1383 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1387 ProcedureForm::Agnostic => findings.push(Finding {
1388 severity: Severity::Unanswered,
1389 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1390 annotated form, which no engine parses"
1391 .to_owned(),
1392 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1393 or with `ProcedureForm::Unsupported(\"...\")` and a sentence saying what it \
1394 offers instead."
1395 .to_owned(),
1396 }),
1397 ProcedureForm::Unsupported(orientation) => {
1398 check_refusal_copy("the stored-procedure refusal", orientation, findings);
1399 if DialectCapabilities::supports_stored_procedures(dialect) {
1400 findings.push(Finding {
1401 severity: Severity::Blocking,
1402 what: "this dialect says its engine has stored procedures, and its \
1403 procedure form refuses to write one"
1404 .to_owned(),
1405 fix: "decide which is true. If the engine has none, leave \
1406 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1407 has them, answer `procedure_form` with the grammar it speaks."
1408 .to_owned(),
1409 });
1410 }
1411 }
1412 ProcedureForm::Custom(grammar) => check_custom_grammar(
1413 CustomGrammar {
1414 kind: RoutineKind::Procedure,
1415 grammar,
1416 form_method: "procedure_form",
1417 render_method: "render_procedure",
1418 noun: "procedures",
1419 },
1420 |spec| SqlRenderer::render_procedure(dialect, spec),
1421 findings,
1422 ),
1423 _ => {}
1424 }
1425 check_procedures_are_used(dialect, findings);
1426
1427 match SqlRenderer::scalar_function_form(dialect) {
1428 ScalarFunctionForm::Unsupported(orientation) => {
1429 check_refusal_copy("the scalar-function refusal", orientation, findings);
1430 }
1431 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1432 CustomGrammar {
1433 kind: RoutineKind::ScalarFunction,
1434 grammar,
1435 form_method: "scalar_function_form",
1436 render_method: "render_scalar_function",
1437 noun: "scalar functions",
1438 },
1439 |spec| SqlRenderer::render_scalar_function(dialect, spec),
1440 findings,
1441 ),
1442 _ => {}
1443 }
1444
1445 match SqlRenderer::table_function_form(dialect) {
1446 TableFunctionForm::Unsupported(orientation) => {
1447 check_refusal_copy("the table-function refusal", orientation, findings);
1448 }
1449 TableFunctionForm::Custom(grammar) => check_custom_grammar(
1450 CustomGrammar {
1451 kind: RoutineKind::TableFunction,
1452 grammar,
1453 form_method: "table_function_form",
1454 render_method: "render_table_function",
1455 noun: "table functions",
1456 },
1457 |spec| SqlRenderer::render_table_function(dialect, spec),
1458 findings,
1459 ),
1460 _ => {}
1461 }
1462
1463 check_parameter_type_spelling(dialect, findings);
1464}
1465
1466fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1478 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1479 return;
1480 };
1481 if DialectCapabilities::supports_stored_procedures(dialect) {
1482 return;
1483 }
1484 findings.push(Finding {
1485 severity: Severity::Blocking,
1486 what: format!(
1487 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1488 load is ever written as one"
1489 ),
1490 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1491 it before it writes a load as a procedure, and its default is `false`."
1492 .to_owned(),
1493 });
1494}
1495
1496fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1511 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1512 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1513 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1514 if !spells_its_own {
1515 return;
1516 }
1517 let qualified = DataType::varchar(100);
1520 let column = SqlRenderer::render_data_type(dialect, &qualified);
1521 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1522 if column == parameter && column.contains('(') {
1523 findings.push(Finding {
1524 severity: Severity::Note,
1525 what: format!(
1526 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1527 column of that type"
1528 ),
1529 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1530 a length or a precision in a parameter list (Oracle does, and answers \
1531 PLS-00103 at the opening parenthesis) override \
1532 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1533 declarations keep the length either way."
1534 .to_owned(),
1535 });
1536 }
1537}
1538
1539#[derive(Clone, Copy)]
1541struct CustomGrammar {
1542 kind: RoutineKind,
1544 grammar: &'static str,
1546 form_method: &'static str,
1548 render_method: &'static str,
1550 noun: &'static str,
1552}
1553
1554fn check_custom_grammar(
1571 claim: CustomGrammar,
1572 render: impl Fn(&RoutineSpec) -> Option<String>,
1573 findings: &mut Vec<Finding>,
1574) {
1575 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1576 if grammar.trim().is_empty() {
1579 findings.push(Finding {
1580 severity: Severity::Blocking,
1581 what: format!("`{form_method}` says Custom, and names no grammar"),
1582 fix: "carry your grammar's own name: `Custom(\"PL/SQL\")`. It is what a reader \
1583 of a report, and of the comment Razeri writes where a routine could not be \
1584 rendered, is told this engine speaks."
1585 .to_owned(),
1586 });
1587 }
1588
1589 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1590 probe.or_replace = true;
1591 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1592 findings.push(Finding {
1593 severity: Severity::Blocking,
1594 what: format!(
1595 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1596 itself, and `{render_method}` returns nothing"
1597 ),
1598 fix: format!(
1599 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1600 composed (name, parameters with their types already in your spelling, body, \
1601 comment) and returns the whole CREATE statement your engine parses. Razeri \
1602 emits what you return and writes nothing of its own: a near-neighbour grammar \
1603 fails on deploy, and a refusal would say this engine has no {noun} when it \
1604 has them in a grammar of its own."
1605 ),
1606 });
1607 }
1608}
1609
1610fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1612 if copy.trim().is_empty() {
1613 findings.push(Finding {
1614 severity: Severity::Blocking,
1615 what: format!("{subject} has no words in it"),
1616 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1617 next thing to try, and it has to be about their engine."
1618 .to_owned(),
1619 });
1620 return;
1621 }
1622 if copy.split_whitespace().count() < 5 {
1623 findings.push(Finding {
1624 severity: Severity::Note,
1625 what: format!("{subject} is very short"),
1626 fix: "a refusal earns its place by naming the alternative: what this engine has \
1627 instead of the thing it cannot do."
1628 .to_owned(),
1629 });
1630 }
1631}
1632
1633fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1641 let caps: &dyn DialectCapabilities = dialect;
1642 let ren: &dyn SqlRenderer = dialect;
1643 let mut disagreements: Vec<&'static str> = Vec::new();
1644
1645 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1654 disagreements.push("supports_table_functions");
1655 }
1656 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1659 disagreements.push("supports_stored_functions");
1660 }
1661
1662 if !disagreements.is_empty() {
1663 findings.push(Finding {
1664 severity: Severity::Blocking,
1665 what: format!(
1666 "this dialect declares {} {} two ways and the two disagree: {}",
1667 disagreements.len(),
1668 if disagreements.len() == 1 { "fact" } else { "facts" },
1669 disagreements.join(", ")
1670 ),
1671 fix: "each of these is answered as a yes or no on the capability trait and as a \
1672 routine form on the renderer, and Razeri reads both. Decide which is true of \
1673 your engine and move the other one to match."
1674 .to_owned(),
1675 });
1676 }
1677}
1678
1679fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1688 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1689 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1690 return;
1691 };
1692 if DialectCapabilities::supports_schemas(dialect) {
1693 findings.push(Finding {
1694 severity: Severity::Blocking,
1695 what: "this dialect folds a schema into an object's name on an engine it says has \
1696 schemas"
1697 .to_owned(),
1698 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1699 schemas, and only there is it asked; or answer \
1700 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1701 .to_owned(),
1702 });
1703 return;
1704 }
1705 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1706 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1707 if qualified != expected {
1708 findings.push(Finding {
1709 severity: Severity::Blocking,
1710 what: format!(
1711 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1712 the same object `{qualified}`"
1713 ),
1714 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1715 schema. Razeri names some objects through it and the rest through the fold, \
1716 so a difference creates a table under one name and reads it under another."
1717 .to_owned(),
1718 });
1719 }
1720}
1721
1722fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1729 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1730 return;
1731 };
1732 if client.command.trim().is_empty() {
1733 findings.push(Finding {
1734 severity: Severity::Blocking,
1735 what: "this dialect describes a deploy client with no command".to_owned(),
1736 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1737 });
1738 }
1739 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1740 .into_iter()
1741 .filter_map(|(field, args)| {
1742 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1743 })
1744 .collect();
1745 if !unnamed.is_empty() {
1746 findings.push(Finding {
1747 severity: Severity::Blocking,
1748 what: format!(
1749 "the deploy client's {} never names the file to run",
1750 unnamed.join(" and ")
1751 ),
1752 fix: "put `{file}` where your client takes the file of SQL to run, on its own, or \
1753 inside an argument such as `--file={file}`. The script writes the file there \
1754 and nowhere else."
1755 .to_owned(),
1756 });
1757 }
1758 if let Some(terminator) = client.block_terminator
1759 && [terminator.open, terminator.end, terminator.close]
1760 .iter()
1761 .any(|part| part.trim().is_empty())
1762 {
1763 findings.push(Finding {
1764 severity: Severity::Blocking,
1765 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1766 fix: "name the line before a routine, what ends it, and the line after it: \
1767 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1768 the routine open, and the next statement is read into it."
1769 .to_owned(),
1770 });
1771 }
1772}
1773
1774fn declarable_types() -> Vec<DataType> {
1782 vec![
1783 DataType::tinyint(),
1784 DataType::smallint(),
1785 DataType::int(),
1786 DataType::bigint(),
1787 DataType::double(),
1788 DataType::boolean(),
1789 DataType::text(),
1790 DataType::varchar(200),
1791 DataType::uuid(),
1792 DataType::json(),
1793 DataType::date(),
1794 DataType::timestamp(),
1795 DataType::timestamp_tz(),
1796 DataType::decimal(18, 2),
1797 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1798 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1799 DataType::Struct(StructDataType::new(vec![
1800 StructField::new("street", DataType::varchar(200)),
1801 StructField::new("zip", DataType::varchar(10)),
1802 ])),
1803 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1804 StructField::new("key", DataType::varchar(100)),
1805 StructField::new("value", DataType::varchar(1000)),
1806 ])))),
1807 ]
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::*;
1813 use crate::dialect::DialectInfo;
1814 use razeri_plugin_contract::{
1815 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1816 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1817 TaskRenderer, TextCast,
1818 };
1819 use std::marker::PhantomData;
1820
1821 struct BrokenPlugin;
1829
1830 impl SqlRenderer for BrokenPlugin {
1831 fn render_data_type(&self, _dt: &DataType) -> String {
1832 String::new()
1833 }
1834 fn quote_identifier(&self, name: &str) -> String {
1835 name.to_owned()
1836 }
1837 fn render_cast_type(&self, _dt: &DataType) -> String {
1838 String::new()
1839 }
1840 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1841 name.to_owned()
1842 }
1843 fn render_current_timestamp(&self) -> String {
1844 String::new()
1845 }
1846 fn dialect_name(&self) -> &'static str {
1847 "brokendb"
1848 }
1849 }
1851 impl ExpressionRenderer for BrokenPlugin {}
1852 impl DdlRenderer for BrokenPlugin {}
1853 impl DmlRenderer for BrokenPlugin {}
1854 impl ProcedureRenderer for BrokenPlugin {}
1855 impl FormulaRenderer for BrokenPlugin {}
1856 impl TaskRenderer for BrokenPlugin {}
1857 impl ReplicationRenderer for BrokenPlugin {}
1858 impl StorageRenderer for BrokenPlugin {}
1859 impl DialectCapabilities for BrokenPlugin {
1860 fn supports_table_functions(&self) -> bool {
1863 false
1864 }
1865 }
1866 impl PlatformDialect for BrokenPlugin {
1867 fn info(&self) -> DialectInfo {
1868 DialectInfo {
1869 name: "brokendb",
1870 display_name: "Broken DB",
1871 aliases: &[],
1872 dialect_family: DialectFamily::Custom(""),
1873 family: "Broken",
1874 is_cloud_native: false,
1875 is_olap: false,
1876 is_postgres_compatible: false,
1877 versions: &[],
1878 deploy_script: None,
1879 }
1880 }
1881 }
1882
1883 trait FixtureName: Send + Sync + 'static {
1892 const NAME: &'static str;
1894 const CONCAT: Concat = Concat::Function("CONCAT");
1898 const POWER: Option<&'static str> = Some("^");
1901 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1903 const DROP_LOSES_ITS_COLUMN: bool = false;
1905 const COMPARES_SOMETHING_ELSE: bool = false;
1907 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1910 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1912 const KEY_REFUSED: Option<&'static str> = None;
1915 }
1916
1917 struct Unlinked;
1920 impl FixtureName for Unlinked {
1921 const NAME: &'static str = "healthydb";
1922 }
1923
1924 struct Outside;
1927 impl FixtureName for Outside {
1928 const NAME: &'static str = "outsidedb";
1929 }
1930
1931 struct BinaryConcat;
1936 impl FixtureName for BinaryConcat {
1937 const NAME: &'static str = "binaryconcatdb";
1938 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1939 }
1940
1941 struct OperatorConcat;
1945 impl FixtureName for OperatorConcat {
1946 const NAME: &'static str = "operatorconcatdb";
1947 const CONCAT: Concat = Concat::Operator("||");
1948 }
1949
1950 struct NamelessConcat;
1952 impl FixtureName for NamelessConcat {
1953 const NAME: &'static str = "namelessconcatdb";
1954 const CONCAT: Concat = Concat::Operator(" ");
1955 }
1956
1957 struct NoPowerOperator;
1960 impl FixtureName for NoPowerOperator {
1961 const NAME: &'static str = "nopowerdb";
1962 const POWER: Option<&'static str> = None;
1963 }
1964
1965 struct BlankPowerOperator;
1967 impl FixtureName for BlankPowerOperator {
1968 const NAME: &'static str = "blankpowerdb";
1969 const POWER: Option<&'static str> = Some(" ");
1970 }
1971
1972 struct ComparesSomethingElse;
1974 impl FixtureName for ComparesSomethingElse {
1975 const NAME: &'static str = "comparesotherdb";
1976 const COMPARES_SOMETHING_ELSE: bool = true;
1977 }
1978
1979 struct DropLosesItsColumn;
1981 impl FixtureName for DropLosesItsColumn {
1982 const NAME: &'static str = "droplosesdb";
1983 const DROP_LOSES_ITS_COLUMN: bool = true;
1984 }
1985
1986 struct BeginWithoutTransactions;
1989 impl FixtureName for BeginWithoutTransactions {
1990 const NAME: &'static str = "beginwithouttxdb";
1991 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1992 }
1993
1994 struct NoShiftOperator;
1996 impl FixtureName for NoShiftOperator {
1997 const NAME: &'static str = "noshiftdb";
1998 const SHIFT_LEFT: Option<&'static str> = None;
1999 }
2000
2001 struct BlankShiftOperator;
2003 impl FixtureName for BlankShiftOperator {
2004 const NAME: &'static str = "blankshiftdb";
2005 const SHIFT_LEFT: Option<&'static str> = Some("");
2006 }
2007
2008 struct DescribedClient;
2010 impl FixtureName for DescribedClient {
2011 const NAME: &'static str = "describedclientdb";
2012 const DEPLOY: Option<crate::dialect::DeployScript> =
2013 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2014 command: "sqlite3",
2015 run_file: &["-bail", "{target}", ".read {file}"],
2016 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
2017 block_terminator: None,
2018 }));
2019 }
2020
2021 struct FilelessClient;
2023 impl FixtureName for FilelessClient {
2024 const NAME: &'static str = "filelessclientdb";
2025 const DEPLOY: Option<crate::dialect::DeployScript> =
2026 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2027 command: "sqlite3",
2028 run_file: &["-bail", "{target}"],
2029 read_rows: None,
2030 block_terminator: None,
2031 }));
2032 }
2033
2034 struct BlankBlockEnd;
2036 impl FixtureName for BlankBlockEnd {
2037 const NAME: &'static str = "blankblockenddb";
2038 const DEPLOY: Option<crate::dialect::DeployScript> =
2039 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2040 command: "isql",
2041 run_file: &["-bail", "-input", "{file}", "{target}"],
2042 read_rows: None,
2043 block_terminator: Some(crate::dialect::BlockTerminator {
2044 open: "SET TERM ^ ;",
2045 end: " ",
2046 close: "SET TERM ; ^",
2047 }),
2048 }));
2049 }
2050
2051 struct Answered<N: FixtureName>(PhantomData<N>);
2052
2053 impl<N: FixtureName> Answered<N> {
2054 const fn new() -> Self {
2055 Self(PhantomData)
2056 }
2057 }
2058
2059 type HealthyPlugin = Answered<Unlinked>;
2060 type OutsidePlugin = Answered<Outside>;
2061
2062 inventory::submit! {
2063 crate::builder::DialectRegistration::new(
2064 Outside::NAME,
2065 || std::sync::Arc::new(OutsidePlugin::new()),
2066 )
2067 }
2068
2069 struct KeyRefused;
2072 impl FixtureName for KeyRefused {
2073 const NAME: &'static str = "keyrefuseddb";
2074 const KEY_REFUSED: Option<&'static str> = Some(
2075 "-- Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2076 a function.\n--\n-- Load into an engine that computes the key, and read it \
2077 from here.\n",
2078 );
2079 }
2080
2081 struct KeyRefusedSilently;
2083 impl FixtureName for KeyRefusedSilently {
2084 const NAME: &'static str = "keyrefusedsilentlydb";
2085 const KEY_REFUSED: Option<&'static str> = Some("");
2086 }
2087
2088 fn graded_alone<N: FixtureName>() -> Report {
2091 let mut registry = crate::dialect::PlatformRegistry::new();
2092 registry.register(N::NAME.to_owned(), std::sync::Arc::new(Answered::<N>::new()));
2093 check_with_registry(&Answered::<N>::new(), ®istry)
2094 }
2095
2096 impl<N: FixtureName> SqlRenderer for Answered<N> {
2097 fn render_data_type(&self, dt: &DataType) -> String {
2098 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2099 }
2100 fn quote_identifier(&self, name: &str) -> String {
2101 format!("\"{name}\"")
2102 }
2103 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2104 schema.map_or_else(
2105 || self.quote_identifier(name),
2106 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
2107 )
2108 }
2109 fn render_current_timestamp(&self) -> String {
2110 "NOW()".to_owned()
2111 }
2112 fn dialect_name(&self) -> &'static str {
2113 N::NAME
2114 }
2115 fn sid_spelling(&self) -> SidSpelling {
2116 if N::KEY_REFUSED.is_some() {
2117 return SidSpelling {
2118 platform: razeri_plugin_contract::UNKNOWN_PLATFORM,
2119 ..razeri_plugin_contract::ANSI_SID_SPELLING
2120 };
2121 }
2122 SidSpelling {
2123 platform: N::NAME,
2124 hash: ("answered_xxhash64(", ")"),
2125 cast_type: TextCast::Direct("STRING"),
2126 concat: N::CONCAT,
2127 through_helper: false,
2128 ..razeri_plugin_contract::ANSI_SID_SPELLING
2129 }
2130 }
2131 fn procedure_form(&self) -> ProcedureForm {
2132 ProcedureForm::Unsupported(
2133 "This engine has no stored procedures; write the load as a scheduled query \
2134 instead.",
2135 )
2136 }
2137 fn scalar_function_form(&self) -> ScalarFunctionForm {
2138 ScalarFunctionForm::PostgreSql
2139 }
2140 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
2141 Box::new(Self::new())
2142 }
2143 }
2144 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
2145 fn render_comparable(
2146 &self,
2147 value: &str,
2148 _data_type: &DataType,
2149 _held: HeldAs,
2150 ) -> Option<String> {
2151 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
2152 }
2153 }
2154 impl<N: FixtureName> DdlRenderer for Answered<N> {
2155 fn render_drop_column(
2156 &self,
2157 table: WrittenObject<'_>,
2158 column: &str,
2159 only_where_it_stands: bool,
2160 ) -> Option<razeri_plugin_contract::Written> {
2161 if N::DROP_LOSES_ITS_COLUMN {
2162 return Some(razeri_plugin_contract::Written::Statement(format!(
2163 "ALTER TABLE {} DROP COLUMN c",
2164 table.relation
2165 )));
2166 }
2167 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
2168 Some(razeri_plugin_contract::Written::Statement(format!(
2169 "ALTER TABLE {} DROP COLUMN{guard} {column}",
2170 table.relation
2171 )))
2172 }
2173 }
2174 impl<N: FixtureName> DmlRenderer for Answered<N> {}
2175 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
2176 impl<N: FixtureName> FormulaRenderer for Answered<N> {
2177 fn render_power_operator(&self) -> Option<String> {
2178 N::POWER.map(str::to_owned)
2179 }
2180 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
2181 match operator {
2182 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
2183 other => Some(other.standard_spelling().to_owned()),
2184 }
2185 }
2186 }
2187 impl<N: FixtureName> TaskRenderer for Answered<N> {}
2188 impl<N: FixtureName> ReplicationRenderer for Answered<N> {
2189 fn sid_prerequisites(&self) -> String {
2190 N::KEY_REFUSED.unwrap_or_default().to_owned()
2191 }
2192 fn sid_prerequisite_kind(&self) -> SidPrerequisiteKind {
2193 if N::KEY_REFUSED.is_some() {
2194 SidPrerequisiteKind::Refused
2195 } else {
2196 razeri_plugin_contract::kind_of_prerequisite_script(&self.sid_prerequisites())
2197 }
2198 }
2199 }
2200 impl<N: FixtureName> StorageRenderer for Answered<N> {}
2201 impl<N: FixtureName> DialectCapabilities for Answered<N> {
2202 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
2203 N::BEGIN_WITHOUT_TRANSACTIONS
2204 }
2205 fn supports_multi_statement_transactions(&self) -> bool {
2206 !N::BEGIN_WITHOUT_TRANSACTIONS
2207 }
2208 fn merge_shape(&self) -> MergeShape {
2209 MergeShape::Merge(MergeRestrictions::none())
2210 }
2211 }
2212 impl<N: FixtureName> PlatformDialect for Answered<N> {
2213 fn info(&self) -> DialectInfo {
2214 DialectInfo {
2215 name: N::NAME,
2216 display_name: "An Answered Engine",
2217 aliases: &[],
2220 dialect_family: DialectFamily::Custom(N::NAME),
2221 family: "Answered",
2222 is_cloud_native: false,
2223 is_olap: false,
2224 is_postgres_compatible: false,
2225 versions: &[],
2226 deploy_script: N::DEPLOY,
2227 }
2228 }
2229 }
2230
2231 struct Twin;
2237
2238 impl SqlRenderer for Twin {
2239 fn render_data_type(&self, dt: &DataType) -> String {
2240 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2241 }
2242 fn quote_identifier(&self, name: &str) -> String {
2243 format!("\"{name}\"")
2244 }
2245 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2246 self.quote_identifier(name)
2247 }
2248 fn render_current_timestamp(&self) -> String {
2249 "NOW()".to_owned()
2250 }
2251 }
2252 impl ExpressionRenderer for Twin {}
2253 impl DdlRenderer for Twin {}
2254 impl DmlRenderer for Twin {}
2255 impl ProcedureRenderer for Twin {}
2256 impl FormulaRenderer for Twin {}
2257 impl TaskRenderer for Twin {}
2258 impl ReplicationRenderer for Twin {}
2259 impl StorageRenderer for Twin {}
2260 impl DialectCapabilities for Twin {}
2261 impl PlatformDialect for Twin {
2262 fn info(&self) -> DialectInfo {
2263 DialectInfo {
2264 name: "twindb",
2265 display_name: "Twin DB",
2266 aliases: &["healthydb"],
2268 dialect_family: DialectFamily::Custom("twindb"),
2269 family: "Twin",
2270 is_cloud_native: false,
2271 is_olap: false,
2272 is_postgres_compatible: false,
2273 versions: &[],
2274 deploy_script: None,
2275 }
2276 }
2277 }
2278
2279 fn mentions(report: &Report, needle: &str) -> bool {
2281 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2282 }
2283
2284 #[test]
2288 fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2289 let report = check(&BrokenPlugin);
2290 let printed = report.to_string();
2291 let first = printed.lines().next().unwrap_or_default();
2292 assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2293 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2294 assert!(printed.contains("Taken as the contract's default"), "{printed}");
2295 let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2296 assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2297 let view = report
2298 .taken_as_default
2299 .iter()
2300 .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2301 .expect("listed");
2302 assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2303 assert_eq!(view.shows_in, "schema.sql");
2304 assert!(printed.contains(&view.answer), "{printed}");
2305 }
2306
2307 #[test]
2311 fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2312 let report = check(&Answered::<Outside>::new());
2313 let printed = report.to_string();
2314 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2315 assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2316 }
2317
2318 #[test]
2322 fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2323 let report = check(&BrokenPlugin);
2324 let key = report
2325 .findings
2326 .iter()
2327 .find(|finding| finding.what.contains("surrogate-key spelling"))
2328 .expect("the key's finding");
2329 for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2330 assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2331 }
2332 assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2333 }
2334
2335 #[test]
2339 fn the_kit_catches_a_deliberately_broken_plugin() {
2340 let report = check(&BrokenPlugin);
2341 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2342
2343 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2344 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2345 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2346 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2347 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2348 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2349 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2350 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2351 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2352 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2353 assert!(mentions(&report, "supports_table_functions"), "paired fact:\n{report}");
2354
2355 assert!(
2356 report.count(Severity::Blocking) >= 8,
2357 "the broken plugin owes more than {} blocking fixes:\n{report}",
2358 report.count(Severity::Blocking)
2359 );
2360 }
2361
2362 #[test]
2366 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2367 let report = check(&HealthyPlugin::new());
2368 let unregistered: Vec<&Finding> =
2369 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2370 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2371 assert_eq!(
2372 report.findings.len(),
2373 1,
2374 "a plugin that answered every question owes nothing else:\n{report}"
2375 );
2376 }
2377
2378 #[test]
2383 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2384 let binary = Answered::<BinaryConcat>::new();
2385 let report = check(&binary);
2386 assert_eq!(
2387 report.findings.len(),
2388 check(&HealthyPlugin::new()).findings.len(),
2389 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2390 );
2391 assert!(
2392 !report.to_string().contains("joins"),
2393 "nothing about the join is outstanding:\n{report}"
2394 );
2395
2396 let key = razeri_plugin_contract::sid::sid_expression(
2397 SqlRenderer::sid_spelling(&binary),
2398 razeri_plugin_contract::sid::Backslash::of(&binary),
2399 None,
2400 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2401 );
2402 assert!(
2403 key.contains("CONCAT(CONCAT("),
2404 "a two-argument engine gets one call per pair:\n{key}"
2405 );
2406 }
2407
2408 #[test]
2412 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2413 let operator = Answered::<OperatorConcat>::new();
2414 let report = check(&operator);
2415 assert_eq!(
2416 report.findings.len(),
2417 check(&HealthyPlugin::new()).findings.len(),
2418 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2419 );
2420 let key = razeri_plugin_contract::sid::sid_expression(
2421 SqlRenderer::sid_spelling(&operator),
2422 razeri_plugin_contract::sid::Backslash::of(&operator),
2423 None,
2424 &["a".to_owned(), "b".to_owned()],
2425 );
2426 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2427 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2428 }
2429
2430 #[test]
2433 fn a_join_that_names_nothing_must_be_fixed() {
2434 let report = check(&Answered::<NamelessConcat>::new());
2435 assert!(
2436 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2437 && finding.what.contains("joins the parts of a composite key")),
2438 "{report}"
2439 );
2440 assert!(
2441 !check(&Answered::<OperatorConcat>::new())
2442 .findings
2443 .iter()
2444 .any(|finding| finding.what.contains("joins the parts of a composite key")),
2445 "a named operator is not the finding"
2446 );
2447 }
2448
2449 #[test]
2453 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2454 let none = check(&Answered::<NoPowerOperator>::new());
2455 assert_eq!(
2456 none.findings.len(),
2457 check(&HealthyPlugin::new()).findings.len(),
2458 "saying the engine has no such operator owes nothing:\n{none}"
2459 );
2460 let blank = check(&Answered::<BlankPowerOperator>::new());
2461 assert!(
2462 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2463 && finding.what.contains("render_power_operator")),
2464 "{blank}"
2465 );
2466 }
2467
2468 #[test]
2471 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2472 let none = check(&Answered::<NoShiftOperator>::new());
2473 assert_eq!(
2474 none.findings.len(),
2475 check(&HealthyPlugin::new()).findings.len(),
2476 "saying the engine has no left shift owes nothing:\n{none}"
2477 );
2478 let blank = check(&Answered::<BlankShiftOperator>::new());
2479 assert!(
2480 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2481 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2482 "{blank}"
2483 );
2484 }
2485
2486 #[test]
2489 fn a_drop_that_loses_its_column_must_be_fixed() {
2490 let healthy = check(&HealthyPlugin::new());
2491 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2492 let lost = check(&Answered::<DropLosesItsColumn>::new());
2493 assert!(
2494 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2495 && finding.what.contains("render_drop_column")),
2496 "{lost}"
2497 );
2498 }
2499
2500 #[test]
2503 fn a_comparison_that_loses_its_value_must_be_fixed() {
2504 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2505 let report = check(&Answered::<ComparesSomethingElse>::new());
2506 assert!(
2507 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2508 && finding.what.contains("render_comparable")),
2509 "{report}"
2510 );
2511 }
2512
2513 #[test]
2517 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2518 let report = check(&Answered::<BeginWithoutTransactions>::new());
2519 assert!(
2520 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2521 && finding.what.contains("leaves the commit to the client")),
2522 "{report}"
2523 );
2524 }
2525
2526 #[test]
2528 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2529 let report = check(&Answered::<BlankBlockEnd>::new());
2530 assert!(
2531 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2532 && finding.what.contains("block terminator")),
2533 "{report}"
2534 );
2535 }
2536
2537 #[test]
2540 fn a_deploy_client_has_to_be_told_which_file_to_run() {
2541 let described = check(&Answered::<DescribedClient>::new());
2542 assert_eq!(
2543 described.findings.len(),
2544 check(&HealthyPlugin::new()).findings.len(),
2545 "a client described in full owes nothing:\n{described}"
2546 );
2547 let fileless = check(&Answered::<FilelessClient>::new());
2548 assert!(
2549 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2550 && finding.what.contains("run_file never names the file")),
2551 "{fileless}"
2552 );
2553 }
2554
2555 #[test]
2559 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2560 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2561 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2562 fn render_data_type(&self, dt: &DataType) -> String {
2563 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2564 }
2565 fn quote_identifier(&self, name: &str) -> String {
2566 format!("\"{name}\"")
2567 }
2568 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2569 match schema {
2570 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2571 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2572 None => self.quote_identifier(name),
2573 }
2574 }
2575 fn render_current_timestamp(&self) -> String {
2576 "CURRENT_TIMESTAMP".to_owned()
2577 }
2578 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2579 Some(format!("{schema}_{name}"))
2580 }
2581 }
2582 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2583 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2584 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2585 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2586 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2587 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2588 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2589 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2590 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2591 fn supports_schemas(&self) -> bool {
2592 S
2593 }
2594 }
2595 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2596 fn info(&self) -> DialectInfo {
2597 DialectInfo {
2598 name: "folderdb",
2599 display_name: "Folder DB",
2600 aliases: &[],
2601 dialect_family: DialectFamily::Custom("folderdb"),
2602 family: "Folder",
2603 is_cloud_native: false,
2604 is_olap: false,
2605 is_postgres_compatible: false,
2606 versions: &[],
2607 deploy_script: None,
2608 }
2609 }
2610 }
2611
2612 let fold_findings = |dialect: &dyn PlatformDialect| {
2613 let mut findings = Vec::new();
2614 check_schema_fold(dialect, &mut findings);
2615 findings
2616 };
2617 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2618 let disagrees = fold_findings(&Folder::<false, false>);
2619 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2620 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2621 let has_schemas = fold_findings(&Folder::<true, true>);
2622 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2623 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2624 }
2625
2626 #[test]
2635 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2636 let mut quiet = Vec::new();
2637 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2638 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2639
2640 let mut stated = Vec::new();
2641 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2642 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2643 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2644 assert!(
2645 stated[0].what.contains("literal"),
2646 "the finding names the restriction:\n{}",
2647 stated[0].what
2648 );
2649 assert!(
2650 stated[0].fix.contains("dynamic-execution"),
2651 "and says what Razeri does with the answer:\n{}",
2652 stated[0].fix
2653 );
2654
2655 let mut two_clauses = Vec::new();
2658 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2659 assert!(two_clauses.is_empty(), "one restriction is not the other");
2660 }
2661
2662 #[test]
2672 fn a_stated_key_refusal_answers_the_key() {
2673 let report = graded_alone::<KeyRefused>();
2674 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2675 assert_eq!(report.count(Severity::Unanswered), 0, "{report}");
2676 assert!(report.is_clean(), "the dialect is ready:\n{report}");
2677 let listed: Vec<_> = report
2678 .findings
2679 .iter()
2680 .filter(|finding| finding.what.contains("cannot compute razeri's surrogate key"))
2681 .collect();
2682 assert_eq!(listed.len(), 1, "{report}");
2683 assert_eq!(listed[0].severity, Severity::Note, "{report}");
2684 assert_eq!(
2685 listed[0].fix, "its reason: Key Refused DB cannot compute razeri's surrogate key.",
2686 "{report}"
2687 );
2688 assert!(
2689 !report.to_string().contains("states no surrogate-key spelling"),
2690 "a refusing dialect is never told to spell the key:\n{report}"
2691 );
2692 assert_eq!(
2693 key_refusal_reason(&Answered::<KeyRefused>::new()).as_deref(),
2694 Some(
2695 "Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2696 a function.\n\nLoad into an engine that computes the key, and read it from here."
2697 )
2698 );
2699 assert_eq!(key_refusal_reason(&OutsidePlugin::new()), None);
2700 }
2701
2702 #[test]
2705 fn a_key_refusal_with_no_reason_is_to_fix() {
2706 let report = graded_alone::<KeyRefusedSilently>();
2707 let owed: Vec<_> = report
2708 .findings
2709 .iter()
2710 .filter(|finding| finding.severity == Severity::Blocking)
2711 .collect();
2712 assert_eq!(owed.len(), 1, "{report}");
2713 assert!(owed[0].what.contains("gives no reason"), "{report}");
2714 assert!(owed[0].fix.contains("`ReplicationRenderer::sid_prerequisites`"), "{report}");
2715 }
2716
2717 #[test]
2718 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2719 assert!(
2720 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2721 "the fixture registers through the registry, which is the whole path being graded"
2722 );
2723 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2727 assert!(
2728 registry
2729 .list()
2730 .iter()
2731 .filter(|info| info.name != Outside::NAME)
2732 .all(|info| !info.answers_to(Outside::NAME)),
2733 "the fixture's name must be one no other platform claims"
2734 );
2735
2736 let report = check(&OutsidePlugin::new());
2737 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2738 assert!(report.is_clean(), "and the report says so:\n{report}");
2739 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2740 assert!(
2741 report.to_string().contains("nothing to fix"),
2742 "a clean dialect is told it is clean:\n{report}"
2743 );
2744 }
2745
2746 #[test]
2752 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2753 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2754 for info in registry.list() {
2755 assert_eq!(
2756 info.spellings().first().copied(),
2757 Some(info.name),
2758 "a platform's own name leads its spellings"
2759 );
2760 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2761 }
2762 }
2763
2764 #[test]
2770 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2771 let mut registry = crate::dialect::PlatformRegistry::new();
2772 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2773 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2774
2775 let report = check_with_registry(&Twin, ®istry);
2776 let collision = report
2777 .findings
2778 .iter()
2779 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2780 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2781 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2782 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2783 assert!(collision.what.contains("twindb"), "{collision:?}");
2784 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2785 assert!(
2786 !mentions(&report, "does not resolve"),
2787 "a collision is not an unregistered platform:\n{report}"
2788 );
2789 }
2790
2791 #[test]
2795 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2796 let mut registry = crate::dialect::PlatformRegistry::new();
2797 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2798
2799 let mut findings = Vec::new();
2800 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2801 assert!(findings.is_empty(), "{findings:?}");
2802
2803 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2804 assert!(
2805 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2806 "'{spelling}' is the same platform"
2807 );
2808 }
2809 assert!(
2810 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2811 );
2812 }
2813
2814 #[test]
2819 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2820 let mut registry = crate::dialect::PlatformRegistry::new();
2821 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2822
2823 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2824 assert!(
2825 report.is_clean(),
2826 "a loaded dialect that answered everything owes nothing:\n{report}"
2827 );
2828 }
2829
2830 #[test]
2834 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2835 let mut collided = crate::dialect::PlatformRegistry::new();
2836 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2837 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2838
2839 let mut fixes: Vec<String> = Vec::new();
2840 for report in [
2841 check(&BrokenPlugin),
2842 check(&HealthyPlugin::new()),
2843 check(&OutsidePlugin::new()),
2844 check_with_registry(&Twin, &collided),
2847 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2848 ] {
2849 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2850 }
2851 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2852 for fix in &fixes {
2853 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2854 assert!(
2855 !fix.contains(path),
2856 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2857 );
2858 }
2859 }
2860 }
2861
2862 #[test]
2869 fn no_finding_holds_a_run_of_spaces() {
2870 struct Declining;
2871 impl SqlRenderer for Declining {
2872 fn render_data_type(&self, dt: &DataType) -> String {
2873 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2874 }
2875 fn quote_identifier(&self, name: &str) -> String {
2876 format!("\"{name}\"")
2877 }
2878 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2879 name.to_owned()
2880 }
2881 fn render_current_timestamp(&self) -> String {
2882 "CURRENT_TIMESTAMP".to_owned()
2883 }
2884 fn procedure_form(&self) -> ProcedureForm {
2885 ProcedureForm::Custom("Declining PSQL")
2886 }
2887 }
2888 impl ExpressionRenderer for Declining {}
2889 impl DdlRenderer for Declining {
2890 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2891 None
2892 }
2893 }
2894 impl DialectCapabilities for Declining {
2895 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2896 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2897 .and_a_nested_field()
2898 }
2899 }
2900 impl DmlRenderer for Declining {}
2901 impl ProcedureRenderer for Declining {}
2902 impl FormulaRenderer for Declining {}
2903 impl TaskRenderer for Declining {}
2904 impl ReplicationRenderer for Declining {}
2905 impl StorageRenderer for Declining {}
2906 impl PlatformDialect for Declining {
2907 fn info(&self) -> DialectInfo {
2908 DialectInfo {
2909 name: "decliningdb",
2910 display_name: "Declining DB",
2911 aliases: &[],
2912 dialect_family: DialectFamily::Custom("decliningdb"),
2913 family: "Declining",
2914 is_cloud_native: false,
2915 is_olap: false,
2916 is_postgres_compatible: false,
2917 versions: &[],
2918 deploy_script: None,
2919 }
2920 }
2921 }
2922
2923 let mut findings = Vec::new();
2924 check_evolution(&Declining, &mut findings);
2925 check_parameter_type_spelling(&Declining, &mut findings);
2926 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2927
2928 for report in [
2929 check(&BrokenPlugin),
2930 check(&HealthyPlugin::new()),
2931 check(&OutsidePlugin::new()),
2932 check(&Declining),
2933 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2934 ] {
2935 findings.extend(report.findings);
2936 }
2937 for finding in &findings {
2938 for text in [&finding.what, &finding.fix] {
2939 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2940 }
2941 }
2942 }
2943
2944 #[test]
2954 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2955 for (kind, form_method, render_method) in [
2956 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2957 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2958 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2959 ] {
2960 let mut promised = Vec::new();
2961 check_custom_grammar(
2962 CustomGrammar {
2963 kind,
2964 grammar: "PL/SQL",
2965 form_method,
2966 render_method,
2967 noun: "routines",
2968 },
2969 |_| None,
2970 &mut promised,
2971 );
2972 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2973 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2974 assert!(promised[0].what.contains(form_method), "{promised:?}");
2975 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2976 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2977
2978 let mut kept = Vec::new();
2980 check_custom_grammar(
2981 CustomGrammar {
2982 kind,
2983 grammar: "PL/SQL",
2984 form_method,
2985 render_method,
2986 noun: "routines",
2987 },
2988 |spec| Some(format!("CREATE {} ...", spec.name)),
2989 &mut kept,
2990 );
2991 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2992 }
2993 }
2994
2995 #[test]
3000 fn a_procedure_form_and_the_procedure_capability_must_agree() {
3001 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
3002 let mut findings = Vec::new();
3003 check_routine_forms(dialect, &mut findings);
3004 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
3005 }
3006
3007 struct Claims<const CUSTOM: bool, const HAS: bool>;
3010 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
3011 fn render_data_type(&self, dt: &DataType) -> String {
3012 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3013 }
3014 fn quote_identifier(&self, name: &str) -> String {
3015 format!("\"{name}\"")
3016 }
3017 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3018 self.quote_identifier(name)
3019 }
3020 fn render_current_timestamp(&self) -> String {
3021 "CURRENT_TIMESTAMP".to_owned()
3022 }
3023 fn procedure_form(&self) -> ProcedureForm {
3024 if CUSTOM {
3025 ProcedureForm::Custom("Firebird PSQL")
3026 } else {
3027 ProcedureForm::Unsupported(
3028 "This engine has no stored procedures; schedule the load as a query.",
3029 )
3030 }
3031 }
3032 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
3033 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
3034 }
3035 }
3036 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
3037 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
3038 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
3039 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
3040 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
3041 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
3042 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
3043 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
3044 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
3045 fn supports_stored_procedures(&self) -> bool {
3046 HAS
3047 }
3048 }
3049 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
3050 fn info(&self) -> DialectInfo {
3051 DialectInfo {
3052 name: "claimsdb",
3053 display_name: "Claims DB",
3054 aliases: &[],
3055 dialect_family: DialectFamily::Custom("claimsdb"),
3056 family: "Claims",
3057 is_cloud_native: false,
3058 is_olap: false,
3059 is_postgres_compatible: false,
3060 versions: &[],
3061 deploy_script: None,
3062 }
3063 }
3064 }
3065
3066 let disagreement = |report: &Report| {
3067 report
3068 .findings
3069 .iter()
3070 .filter(|finding| {
3071 finding.severity == Severity::Blocking
3072 && (finding.what.contains("says its engine has none")
3073 || finding.what.contains("refuses to write one"))
3074 })
3075 .count()
3076 };
3077 let mut findings = Vec::new();
3078 check_routine_forms(&Claims::<true, false>, &mut findings);
3079 let report =
3080 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
3081 assert_eq!(disagreement(&report), 1, "{report}");
3082 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
3083
3084 for (agrees, label) in [
3085 (check_forms(&Claims::<true, true>), "spelled and used"),
3086 (check_forms(&Claims::<false, false>), "refused and absent"),
3087 ] {
3088 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
3089 }
3090 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
3091 }
3092
3093 #[test]
3099 fn a_custom_render_method_that_returns_nothing_is_blocking() {
3100 let mut findings = Vec::new();
3101 check_custom_grammar(
3102 CustomGrammar {
3103 kind: RoutineKind::Procedure,
3104 grammar: "PL/SQL",
3105 form_method: "procedure_form",
3106 render_method: "render_procedure",
3107 noun: "procedures",
3108 },
3109 |_| Some(" \n".to_owned()),
3110 &mut findings,
3111 );
3112 assert_eq!(findings.len(), 1, "{findings:?}");
3113 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
3114 }
3115
3116 #[test]
3118 fn a_custom_grammar_with_no_name_is_blocking() {
3119 let mut findings = Vec::new();
3120 check_custom_grammar(
3121 CustomGrammar {
3122 kind: RoutineKind::ScalarFunction,
3123 grammar: " ",
3124 form_method: "scalar_function_form",
3125 render_method: "render_scalar_function",
3126 noun: "scalar functions",
3127 },
3128 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
3129 &mut findings,
3130 );
3131 assert_eq!(findings.len(), 1, "{findings:?}");
3132 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
3133 }
3134
3135 #[test]
3143 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
3144 struct Overstated;
3145 impl SqlRenderer for Overstated {
3146 fn render_data_type(&self, dt: &DataType) -> String {
3147 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3148 }
3149 fn quote_identifier(&self, name: &str) -> String {
3150 format!("\"{name}\"")
3151 }
3152 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3153 name.to_owned()
3154 }
3155 fn render_current_timestamp(&self) -> String {
3156 "CURRENT_TIMESTAMP".to_owned()
3157 }
3158 fn dialect_name(&self) -> &'static str {
3159 "overstateddb"
3160 }
3161 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
3162 Some(razeri_plugin_contract::SidHelperBody {
3163 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
3164 body: " \n ",
3165 })
3166 }
3167 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
3168 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
3169 }
3170 }
3171 impl ExpressionRenderer for Overstated {}
3172 impl DdlRenderer for Overstated {}
3173 impl DmlRenderer for Overstated {}
3174 impl ProcedureRenderer for Overstated {}
3175 impl FormulaRenderer for Overstated {}
3176 impl TaskRenderer for Overstated {}
3177 impl ReplicationRenderer for Overstated {}
3178 impl StorageRenderer for Overstated {}
3179 impl DialectCapabilities for Overstated {}
3180 impl PlatformDialect for Overstated {
3181 fn info(&self) -> DialectInfo {
3182 DialectInfo {
3183 name: "overstateddb",
3184 display_name: "Overstated DB",
3185 aliases: &[],
3186 dialect_family: DialectFamily::Custom(""),
3187 family: "Overstated",
3188 is_cloud_native: false,
3189 is_olap: false,
3190 is_postgres_compatible: false,
3191 versions: &[],
3192 deploy_script: None,
3193 }
3194 }
3195 }
3196
3197 let report = check(&Overstated);
3198 for (fragment, why) in [
3199 ("supplies an empty one", "a blank helper body"),
3200 ("does not carry the path", "a file relation that drops its path"),
3201 ] {
3202 let found = report
3203 .findings
3204 .iter()
3205 .find(|f| f.what.contains(fragment))
3206 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
3207 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
3208 }
3209
3210 let quiet = check(&HealthyPlugin::new());
3213 for fragment in ["supplies an empty one", "does not carry the path"] {
3214 assert!(
3215 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
3216 "silence is the default and owes nothing:\n{quiet}"
3217 );
3218 }
3219 }
3220
3221 #[test]
3225 fn a_file_read_that_ignores_the_delimiter_quote_and_header_is_blocking() {
3226 struct Deaf<const OVERRIDES: bool>;
3227 impl<const OVERRIDES: bool> SqlRenderer for Deaf<OVERRIDES> {
3228 fn render_data_type(&self, dt: &DataType) -> String {
3229 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3230 }
3231 fn quote_identifier(&self, name: &str) -> String {
3232 format!("\"{name}\"")
3233 }
3234 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3235 name.to_owned()
3236 }
3237 fn render_current_timestamp(&self) -> String {
3238 "CURRENT_TIMESTAMP".to_owned()
3239 }
3240 fn delimited_file_relation(&self, path_literal: &str) -> Option<String> {
3241 Some(format!("scan({path_literal})"))
3242 }
3243 fn delimited_file_relation_for(&self, file: &DelimitedFile<'_>) -> Option<String> {
3244 if OVERRIDES {
3245 Some(format!("scan({})", file.path_literal))
3246 } else {
3247 file.is_plain().then(|| format!("scan({})", file.path_literal))
3249 }
3250 }
3251 }
3252 impl<const OVERRIDES: bool> ExpressionRenderer for Deaf<OVERRIDES> {}
3253 impl<const OVERRIDES: bool> DdlRenderer for Deaf<OVERRIDES> {}
3254 impl<const OVERRIDES: bool> DmlRenderer for Deaf<OVERRIDES> {}
3255 impl<const OVERRIDES: bool> ProcedureRenderer for Deaf<OVERRIDES> {}
3256 impl<const OVERRIDES: bool> FormulaRenderer for Deaf<OVERRIDES> {}
3257 impl<const OVERRIDES: bool> TaskRenderer for Deaf<OVERRIDES> {}
3258 impl<const OVERRIDES: bool> ReplicationRenderer for Deaf<OVERRIDES> {}
3259 impl<const OVERRIDES: bool> StorageRenderer for Deaf<OVERRIDES> {}
3260 impl<const OVERRIDES: bool> DialectCapabilities for Deaf<OVERRIDES> {}
3261 impl<const OVERRIDES: bool> PlatformDialect for Deaf<OVERRIDES> {
3262 fn info(&self) -> DialectInfo {
3263 DialectInfo {
3264 name: "deafdb",
3265 display_name: "Deaf DB",
3266 aliases: &[],
3267 dialect_family: DialectFamily::Custom(""),
3268 family: "Deaf",
3269 is_cloud_native: false,
3270 is_olap: false,
3271 is_postgres_compatible: false,
3272 versions: &[],
3273 deploy_script: None,
3274 }
3275 }
3276 }
3277
3278 let mut deaf = Vec::new();
3279 check_file_settings(&Deaf::<true>, &mut deaf);
3280 assert_eq!(deaf.len(), 3, "the delimiter, the quote and the header: {deaf:?}");
3281 assert!(deaf.iter().all(|finding| finding.severity == Severity::Blocking), "{deaf:?}");
3282 assert!(deaf[0].what.contains("delimited by `|`"), "{deaf:?}");
3283
3284 let mut default = Vec::new();
3285 check_file_settings(&Deaf::<false>, &mut default);
3286 assert!(default.is_empty(), "`None` is a complete answer: {default:?}");
3287 }
3288
3289 #[test]
3293 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
3294 let mut mute = Vec::new();
3295 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
3296 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
3297 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
3298
3299 let silence = check(&BrokenPlugin);
3300 assert!(
3301 silence
3302 .findings
3303 .iter()
3304 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
3305 "a dialect that never answered is unanswered, not wrong:\n{silence}"
3306 );
3307 assert!(
3308 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
3309 "the fix line names the way to state a measured refusal:\n{silence}"
3310 );
3311 }
3312
3313 #[test]
3317 fn the_report_reads_as_a_summary_then_a_fix_list() {
3318 let text = check(&BrokenPlugin).to_string();
3319 let first = text.lines().next().expect("a summary line");
3320 assert!(first.starts_with("brokendb:"), "{first}");
3321 assert!(first.contains("to fix"), "{first}");
3322 assert!(text.contains(" -> "), "every finding names the fix:\n{text}");
3323 }
3324}