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 seam(
430 "SqlRenderer::render_shared_relation",
431 RECONCILE,
432 Asked::Sql(|d| match d.render_shared_relation("v_once", "s.v") {
433 Some(item) => quoted(&format!("WITH {item}")),
434 None => "none: each place a statement names a view reads it again".to_owned(),
435 }),
436 ),
437];
438
439fn said(answer: impl fmt::Display) -> String {
441 format!("`{answer}`")
442}
443
444fn quoted(sql: &str) -> String {
446 format!("`{}`", sql.replace('\n', "\\n"))
447}
448
449#[must_use]
460pub fn taken_as_default(dialect: &dyn PlatformDialect) -> Vec<TakenAsDefault> {
461 let defaults = ContractDefaults { graded: dialect };
462 statement_seams()
463 .iter()
464 .filter_map(|seam| {
465 let default = seam.default_of(&defaults);
466 (seam.answer_of(dialect) == default).then_some(TakenAsDefault {
467 name: seam.name,
468 shows_in: seam.shows_in,
469 answer: default,
470 })
471 })
472 .collect()
473}
474
475struct ContractDefaults<'a> {
479 graded: &'a dyn PlatformDialect,
480}
481
482impl SqlRenderer for ContractDefaults<'_> {
483 fn render_data_type(&self, dt: &DataType) -> String {
484 self.graded.render_data_type(dt)
485 }
486
487 fn quote_identifier(&self, name: &str) -> String {
488 self.graded.quote_identifier(name)
489 }
490
491 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
492 self.graded.qualify_name(schema, name)
493 }
494
495 fn render_current_timestamp(&self) -> String {
496 self.graded.render_current_timestamp()
497 }
498}
499impl ExpressionRenderer for ContractDefaults<'_> {}
500impl DdlRenderer for ContractDefaults<'_> {}
501impl ProcedureRenderer for ContractDefaults<'_> {}
502impl DialectCapabilities for ContractDefaults<'_> {}
503
504#[must_use]
511pub fn check(dialect: &dyn PlatformDialect) -> Report {
512 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
513}
514
515#[must_use]
518pub fn check_with_registry(
519 dialect: &dyn PlatformDialect,
520 registry: &crate::dialect::PlatformRegistry,
521) -> Report {
522 let info = dialect.info();
523 let mut findings = Vec::new();
524
525 check_registration(&info, registry, &mut findings);
526 check_family(info.dialect_family, &mut findings);
527 check_required_rendering(dialect, &mut findings);
528 check_decisions(dialect, &mut findings);
529 check_formula_renderer(dialect, &mut findings);
530 check_evolution(dialect, &mut findings);
531 check_run_again(dialect, &mut findings);
532 check_transactions(dialect, &mut findings);
533 check_paired_facts(dialect, &mut findings);
534 check_schema_fold(dialect, &mut findings);
535 check_deploy_client(&info, &mut findings);
536
537 findings.sort_by_key(|f| f.severity);
538 Report { dialect: info.name.to_owned(), findings, taken_as_default: taken_as_default(dialect) }
539}
540
541fn check_registration(
552 info: &crate::dialect::DialectInfo,
553 registry: &crate::dialect::PlatformRegistry,
554 findings: &mut Vec<Finding>,
555) {
556 let name = info.name;
557 if matches!(
558 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
559 Err(crate::ResolveError::UnknownDialect { .. })
560 ) {
561 findings.push(Finding {
562 severity: Severity::Blocking,
563 what: format!(
564 "the name `{name}` does not resolve; nothing in this program registered it"
565 ),
566 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
567 crate's `lib.rs`, where `register` calls \
568 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
569 dialect one the registry finds, whether razeri loads your library from a \
570 plugin directory or links your crate in."
571 .to_owned(),
572 });
573 return;
574 }
575 for spelling in info.spellings() {
576 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
577 Ok(found) if found.info().name == name => {}
578 Ok(found) => findings.push(Finding {
579 severity: Severity::Blocking,
580 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
581 fix: format!(
582 "the registry holds `{name}` under another key. Register it under its own \
583 name, or drop `{spelling}` from its aliases."
584 ),
585 }),
586 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
587 severity: Severity::Blocking,
588 what: format!(
589 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
590 none of them; Razeri refuses that spelling rather than guessing which one \
591 a design meant",
592 claimed_by.join(" and ")
593 ),
594 fix: format!(
595 "give this dialect a name no other platform answers to. A platform name is \
596 read off the loaded dialects' own declared spellings, so a spelling another \
597 platform already claims is never `{name}`'s."
598 ),
599 }),
600 Err(_) => findings.push(Finding {
601 severity: Severity::Blocking,
602 what: format!(
603 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
604 ),
605 fix: format!(
606 "the `{name}` in the registry is a different build of this dialect and does \
607 not declare `{spelling}`. Register the one you are grading."
608 ),
609 }),
610 }
611 }
612}
613
614fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
617 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
618 findings.push(Finding {
619 severity: Severity::Blocking,
620 what: "this dialect's family is a custom one with no name".to_owned(),
621 fix: "give `DialectFamily::Custom` your engine's own name; it is what a report \
622 prints and what a reader looks up."
623 .to_owned(),
624 });
625 }
626}
627
628fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
630 let declarable = declarable_types();
631 let empty_column: Vec<String> = declarable
632 .iter()
633 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
634 .map(|dt| format!("{dt:?}"))
635 .collect();
636 if !empty_column.is_empty() {
637 findings.push(Finding {
638 severity: Severity::Blocking,
639 what: format!(
640 "`render_data_type` answers nothing for {} of the types a design can declare",
641 empty_column.len()
642 ),
643 fix: "every declarable type needs a column spelling on this engine, even if the \
644 spelling is a wider type that stores it. A blank answer becomes a column \
645 declaration with no type in it."
646 .to_owned(),
647 });
648 }
649 let empty_cast: Vec<String> = declarable
650 .iter()
651 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
652 .map(|dt| format!("{dt:?}"))
653 .collect();
654 if !empty_cast.is_empty() {
655 findings.push(Finding {
656 severity: Severity::Blocking,
657 what: format!(
658 "`render_cast_type` answers nothing for {} declarable types",
659 empty_cast.len()
660 ),
661 fix: "the generated load opens by casting every source column to its declared type, \
662 so a blank answer fails the load on its first statement."
663 .to_owned(),
664 });
665 }
666 let quoted = SqlRenderer::quote_identifier(dialect, "order");
667 if quoted == "order" {
668 findings.push(Finding {
669 severity: Severity::Blocking,
670 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
671 reaches this engine bare"
672 .to_owned(),
673 fix: "wrap the identifier in whatever this engine quotes with: double quotes, \
674 backticks, or brackets."
675 .to_owned(),
676 });
677 }
678 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
679 findings.push(Finding {
680 severity: Severity::Blocking,
681 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
682 a row with it"
683 .to_owned(),
684 fix: "return this engine's own expression for the current moment.".to_owned(),
685 });
686 }
687}
688
689fn check_encoded_file_relation(
701 dialect: &dyn PlatformDialect,
702 probe: &str,
703 findings: &mut Vec<Finding>,
704) {
705 let Some(relation) =
706 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
707 else {
708 return;
709 };
710 if !relation.contains(probe) {
711 findings.push(Finding {
712 severity: Severity::Blocking,
713 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
714 the path it was given"
715 .to_owned(),
716 fix: "put the literal you were handed where your engine's file relation takes a \
717 path. It arrives already quoted and escaped."
718 .to_owned(),
719 });
720 }
721 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
722 findings.push(Finding {
723 severity: Severity::Blocking,
724 what: "this dialect states how to read a file in a named encoding and states no way \
725 to read a file at all"
726 .to_owned(),
727 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
728 first question before the second, a source whose bytes are UTF-8 never \
729 reaches the encoding arm, so an engine that answers only the second has \
730 written a relation nothing will ask it for."
731 .to_owned(),
732 });
733 }
734}
735
736fn check_file_settings(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
749 let columns = ["a".to_owned(), "b".to_owned()];
750 let plain =
751 DelimitedFile::new("'/razeri/probe.csv'", "'/razeri'", "probe.csv").with_columns(&columns);
752 let read = |file: &DelimitedFile<'_>| {
753 SqlRenderer::delimited_file_relation_for(dialect, file)
754 .map(|relation| (relation, SqlRenderer::delimited_file_statements_for(dialect, file)))
755 };
756 let Some(plain_read) = read(&plain) else { return };
757 for (told, said) in [
758 (plain.with_delimiter(FileDelimiter::Pipe), "a file delimited by `|`"),
759 (plain.with_quote('\''), "a file quoted with `'`"),
760 (plain.with_header(false), "a file with no header line"),
761 ] {
762 if read(&told).as_ref() == Some(&plain_read) {
763 findings.push(Finding {
764 severity: Severity::Blocking,
765 what: format!(
766 "`delimited_file_relation_for` reads {said} exactly as it reads a \
767 comma-delimited file with a header"
768 ),
769 fix: "put the file's `delimiter`, `quote` and `header` where your engine's read \
770 takes them, or answer `None` for a file your engine cannot be told about. \
771 Razeri then declines that file by name, rather than loading each line into \
772 one column."
773 .to_owned(),
774 });
775 }
776 }
777}
778
779#[must_use]
795pub fn key_refusal_reason(dialect: &dyn PlatformDialect) -> Option<String> {
796 if ReplicationRenderer::sid_prerequisite_kind(dialect) != SidPrerequisiteKind::Refused {
797 return None;
798 }
799 let script = ReplicationRenderer::sid_prerequisites(dialect);
800 let lines: Vec<&str> = script
801 .lines()
802 .map(str::trim)
803 .filter(|line| *line != SID_PREREQUISITE_STATEMENT_DELIMITER.trim())
804 .map(|line| line.strip_prefix("--").map_or(line, str::trim))
805 .map(|line| line.strip_prefix("/*").map_or(line, str::trim))
806 .map(|line| line.strip_suffix("*/").map_or(line, str::trim))
807 .collect();
808 Some(lines.join("\n").trim().to_owned())
809}
810
811fn first_sentence(prose: &str) -> String {
813 let flat = prose.split_whitespace().collect::<Vec<_>>().join(" ");
814 flat.find(". ").map_or(flat.clone(), |end| flat[..=end].to_owned())
815}
816
817fn check_key_refusal(reason: &str, findings: &mut Vec<Finding>) {
825 if reason.is_empty() {
826 findings.push(Finding {
827 severity: Severity::Blocking,
828 what: "this dialect says its engine cannot compute razeri's surrogate key, and gives \
829 no reason"
830 .to_owned(),
831 fix: "return the reason from `ReplicationRenderer::sid_prerequisites`, as comment \
832 lines: why this engine cannot compute XXH64 with seed 0, and what an operator \
833 can do instead. Razeri prints it where it refuses a design that keys."
834 .to_owned(),
835 });
836 return;
837 }
838 findings.push(Finding {
839 severity: Severity::Note,
840 what: "this dialect says its engine cannot compute razeri's surrogate key, so razeri \
841 renders a design that needs no key and refuses one that keys, with this reason"
842 .to_owned(),
843 fix: format!("its reason: {}", first_sentence(reason)),
844 });
845}
846
847fn check_key_spelling(
851 dialect: &dyn PlatformDialect,
852 spelling: &razeri_plugin_contract::SidSpelling,
853 findings: &mut Vec<Finding>,
854) {
855 let refusal = key_refusal_reason(dialect);
856 if let Some(reason) = &refusal {
857 check_key_refusal(reason, findings);
858 }
859 if spelling.platform == UNKNOWN_PLATFORM && refusal.is_none() {
860 findings.push(Finding {
861 severity: Severity::Blocking,
862 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
863 an `xxhash64` its engine very likely does not have"
864 .to_owned(),
865 fix: "override `SqlRenderer::sid_spelling`. Say how this engine writes a \
866 replacement and a Unicode literal, how it joins strings, how it casts to text, \
867 and how it spells a 64-bit hash. If it has no portable hash, name an artifact \
868 your prerequisites install: never the nearest-looking built-in, which keys a \
869 warehouse to a number nothing else can reproduce. If the engine cannot define \
870 a function at all, answer `ReplicationRenderer::sid_prerequisite_kind` with \
871 `SidPrerequisiteKind::Refused` and say why in `sid_prerequisites`: that is an \
872 answer, and razeri then refuses only the designs that key."
873 .to_owned(),
874 });
875 }
876}
877
878fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
880 let spelling = SqlRenderer::sid_spelling(dialect);
881 check_key_spelling(dialect, &spelling, findings);
882
883 let join = match spelling.concat {
887 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
888 Concat::Plus => "+",
889 };
890 if join.trim().is_empty() {
891 findings.push(Finding {
892 severity: Severity::Blocking,
893 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
894 with a function or operator that has no name"
895 .to_owned(),
896 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
897 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
898 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
899 operator and no function."
900 .to_owned(),
901 });
902 }
903
904 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
909 && supplied.body.trim().is_empty()
910 {
911 findings.push(Finding {
912 severity: Severity::Blocking,
913 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
914 and then supplies an empty one"
915 .to_owned(),
916 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
917 one argument, or answer `None` and let Razeri compose the derivation from \
918 your `sid_spelling`. An empty body deploys without error and keys every row \
919 to nothing."
920 .to_owned(),
921 });
922 }
923
924 let probe = "'/razeri/probe.csv'";
927 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
928 && !relation.contains(probe)
929 {
930 findings.push(Finding {
931 severity: Severity::Blocking,
932 what: "`delimited_file_relation` returns a relation that does not carry the path it \
933 was given"
934 .to_owned(),
935 fix: "put the literal you were handed where your engine's file relation takes a \
936 path. It arrives already quoted and escaped. A relation that drops it reads \
937 one file for every entity the design binds, and the generated fill loads the \
938 wrong rows without failing."
939 .to_owned(),
940 });
941 }
942
943 check_encoded_file_relation(dialect, probe, findings);
944 check_file_settings(dialect, findings);
945 check_routine_forms(dialect, findings);
946 check_slice_replacement(dialect, findings);
947
948 if let ConstraintEnforcement::DeclaredOnly(declared) =
951 DdlRenderer::constraint_enforcement(dialect)
952 && declared.note.split_whitespace().count() < 5
953 {
954 findings.push(Finding {
955 severity: Severity::Blocking,
956 what: "this dialect says its engine accepts constraints it does not enforce, and \
957 the note explaining that says almost nothing"
958 .to_owned(),
959 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
960 guarantee. Say what this engine really does with the declaration, and where \
961 the guarantee has to come from instead."
962 .to_owned(),
963 });
964 }
965
966 match DialectCapabilities::merge_shape(dialect) {
972 MergeShape::None => findings.push(Finding {
973 severity: Severity::Unanswered,
974 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
975 load for it"
976 .to_owned(),
977 fix: "if this engine has a MERGE or an upsert statement, say which in \
978 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
979 `MergeShape::Unsupported` with your engine's own sentence, a refusal an \
980 operator can act on, rather than a silence."
981 .to_owned(),
982 }),
983 MergeShape::Unsupported(orientation) => {
984 check_refusal_copy("the upsert refusal", orientation, findings);
985 }
986 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
987 _ => {}
988 }
989}
990
991fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1006 const VALUE: &str = "$razeri_slice_probe";
1007 let plan = StoragePlan {
1008 partition: PartitionStrategy::RangeByTimeDimension {
1009 field: "razeri_probe_day".to_owned(),
1010 granularity: PartitionGranularity::Day,
1011 retention: None,
1012 look_ahead: None,
1013 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
1014 },
1015 compression: CompressionStrategy::None,
1016 index: IndexStrategy::None,
1017 };
1018 let day = DataType::date();
1019 let columns = ["razeri_probe_day"];
1020 let entity = EntityRef {
1021 schema: Some("razeri_probe"),
1022 name: "razeri_probe_table",
1023 role: TableRole::Written,
1024 columns: &columns,
1025 partition_column_type: Some(&day),
1026 };
1027 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
1028 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
1029 else {
1030 return;
1031 };
1032
1033 let carries = match &answer {
1034 SliceReplacement::Emptied(statements) => {
1035 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
1036 }
1037 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
1038 };
1039 if carries {
1045 return;
1046 }
1047 findings.push(Finding {
1048 severity: Severity::Blocking,
1049 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
1050 was handed"
1051 .to_owned(),
1052 fix: "put the value you were given where your statement names the slice, and build any \
1053 partition NAME from it the same way your extend procedure built it. A replacement \
1054 that drops the value clears a slice this call was not given, or every slice, \
1055 and the load reports success over what it emptied."
1056 .to_owned(),
1057 });
1058}
1059
1060fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1077 let answers = DialectCapabilities::evolution_answers(dialect);
1078 let boolean = DataType::boolean();
1079 let long = DataType::varchar(40);
1080
1081 if DdlRenderer::render_column_evolution(
1082 dialect,
1083 &ColumnEvolution::AddColumn {
1084 schema: Some("s"),
1085 table: "t",
1086 column: "c",
1087 data_type: &boolean,
1088 },
1089 )
1090 .is_none()
1091 {
1092 findings.push(Finding {
1093 severity: Severity::Blocking,
1094 what: "this dialect writes no statement for a column that is new, which is the \
1095 commonest change a deployed warehouse ever sees"
1096 .to_owned(),
1097 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
1098 override and take the contract's `ALTER TABLE ... ADD COLUMN ...`, which most \
1099 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
1100 engine that genuinely has no way to add a column is worth saying out loud in \
1101 the crate's own documentation."
1102 .to_owned(),
1103 });
1104 }
1105
1106 if answers.parameterized_string_widening_is_lossless
1107 && DdlRenderer::render_column_evolution(
1108 dialect,
1109 &ColumnEvolution::WidenColumn {
1110 schema: Some("s"),
1111 table: "t",
1112 column: "c",
1113 data_type: &long,
1114 },
1115 )
1116 .is_none()
1117 {
1118 findings.push(Finding {
1119 severity: Severity::Note,
1120 what: "this dialect says its engine widens a string's declared length in place, and \
1121 writes no statement for the change"
1122 .to_owned(),
1123 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
1124 engine's spelling of it. Without it Razeri tells an operator the widening \
1125 costs nothing and then hands them nothing to run, which reads as Razeri not \
1126 having finished rather than as a statement they own."
1127 .to_owned(),
1128 });
1129 }
1130
1131 if answers.nested_field_addition_is_lossless
1132 && DdlRenderer::render_column_evolution(
1133 dialect,
1134 &ColumnEvolution::AddNestedField {
1135 schema: Some("s"),
1136 table: "t",
1137 column: "c",
1138 field: "f",
1139 data_type: &long,
1140 },
1141 )
1142 .is_none()
1143 {
1144 findings.push(Finding {
1145 severity: Severity::Note,
1146 what: "this dialect says its engine keeps every value when a record gains a field, \
1147 and writes no statement for the change"
1148 .to_owned(),
1149 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
1150 engines name the new field; some restate the whole column's type, which is \
1151 why the change carries both the field's path and the column's type with the \
1152 field in it."
1153 .to_owned(),
1154 });
1155 }
1156}
1157
1158fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1167 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
1168 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
1169 let relation = format!("{schema}.{name}");
1170 let table = WrittenObject::new(Some(&schema), &name, &relation);
1171 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
1172 let mut lost: Vec<&str> = Vec::new();
1173
1174 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
1175 Some(drop) if drop.text().contains(&column) => {}
1176 _ => lost.push("render_drop_column"),
1177 }
1178 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
1179 && !guarded.text().contains(&column)
1180 {
1181 lost.push("render_drop_column (only where it stands)");
1182 }
1183 for only_where_it_stands in [false, true] {
1184 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
1185 && !drop.text().contains(&name)
1186 {
1187 lost.push("render_drop_view");
1188 }
1189 }
1190 let copy_name = SqlRenderer::quote_identifier(dialect, "razeri_probe_copy");
1192 let copy_relation = format!("{schema}.{copy_name}");
1193 let copy = WrittenObject::new(Some(&schema), ©_name, ©_relation);
1194 let copied = DdlRenderer::render_copy_table(dialect, table, copy);
1195 if !(copied.text().contains(&relation) && copied.text().contains(©_relation)) {
1196 lost.push("render_copy_table");
1197 }
1198 let create = format!("CREATE TABLE {relation} (a INTEGER)");
1199 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
1200 && !guarded.text().contains(&create)
1201 {
1202 lost.push("render_create_table_if_absent");
1203 }
1204 lost.dedup();
1205 if !lost.is_empty() {
1206 findings.push(Finding {
1207 severity: Severity::Blocking,
1208 what: format!(
1209 "{} returns a statement that does not carry the object it was handed",
1210 lost.join(", ")
1211 ),
1212 fix: "put the names you were given where your statement names the object, and keep \
1213 a create you guard whole inside the guard. A drop that loses its column drops \
1214 another one or none, and a guard that loses its statement makes nothing: \
1215 both under a run that reports success."
1216 .to_owned(),
1217 });
1218 }
1219
1220 let value = "razeri_probe_value";
1223 let loses_its_value = declarable_types().iter().any(|data_type| {
1224 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
1225 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
1226 .is_some_and(|compared| !compared.contains(value))
1227 })
1228 });
1229 if loses_its_value {
1230 findings.push(Finding {
1231 severity: Severity::Blocking,
1232 what: "`render_comparable` answers an expression that does not carry the value it \
1233 stands for"
1234 .to_owned(),
1235 fix: "answer the value itself where your engine compares the type directly, an \
1236 expression over it where it compares one only that way, and `None` where it \
1237 cannot compare one at all, then Razeri refuses such a column by name."
1238 .to_owned(),
1239 });
1240 }
1241
1242 if let Some(limits) = DialectCapabilities::text_limits(dialect)
1245 && (limits.longest_varchar == Some(0)
1246 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
1247 {
1248 findings.push(Finding {
1249 severity: Severity::Blocking,
1250 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
1251 unbounded text cannot be"
1252 .to_owned(),
1253 fix: "state the longest varchar(n) your engine creates, one more is refused at \
1254 CREATE TABLE, and name what it refuses a text column as, or leave it None."
1255 .to_owned(),
1256 });
1257 }
1258
1259 match DialectCapabilities::catalogue_reach(dialect) {
1260 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
1261 check_refusal_copy("the reason the schema check is not asked", reason, findings);
1262 }
1263 _ => {}
1264 }
1265}
1266
1267fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1271 if SqlRenderer::render_procedure_log(
1274 dialect,
1275 razeri_plugin_contract::ProcedureLogLevel::Info,
1276 "'razeri'",
1277 )
1278 .is_some_and(|line| line.trim().is_empty())
1279 {
1280 findings.push(Finding {
1281 severity: Severity::Blocking,
1282 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
1283 fix: "answer `None` where your engine has no statement that writes a message: the \
1284 line is then left out of the routine."
1285 .to_owned(),
1286 });
1287 }
1288 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
1289 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
1290 {
1291 findings.push(Finding {
1292 severity: Severity::Blocking,
1293 what: "this dialect says its begin statement leaves the commit to the client, and \
1294 that its engine has no transaction spanning statements"
1295 .to_owned(),
1296 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
1297 run in one transaction. Where there is none, the bundle already tells the \
1298 operator the boundary is theirs."
1299 .to_owned(),
1300 });
1301 }
1302}
1303
1304fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
1315 if restrictions.slice_pruning_needs_literal {
1316 findings.push(Finding {
1317 severity: Severity::Note,
1318 what: "this dialect says its planner reads one slice of a partitioned table only \
1319 from a literal, so a load scoped to one day cannot simply bind the day"
1320 .to_owned(),
1321 fix: "Razeri will put the day into the statement text through this engine's \
1322 dynamic-execution form rather than binding it to the load procedure's \
1323 parameter. Confirm that is what your engine wants: the statement is composed \
1324 once per call and its text differs per day, which some planners cache and \
1325 some do not. If a bound parameter does prune here, drop the restriction and \
1326 Razeri binds it like everywhere else."
1327 .to_owned(),
1328 });
1329 }
1330}
1331
1332fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1335 let bitwise = BitwiseOperator::ALL.map(|operator| {
1339 (
1340 format!("render_bitwise_operator({operator:?})"),
1341 FormulaRenderer::render_bitwise_operator(dialect, operator),
1342 )
1343 });
1344 let blank: Vec<String> = [
1345 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
1346 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
1347 (
1348 "render_not_ilike_operator".to_owned(),
1349 FormulaRenderer::render_not_ilike_operator(dialect),
1350 ),
1351 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
1352 ]
1353 .into_iter()
1354 .chain(bitwise)
1355 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
1356 .map(|(method, _)| method)
1357 .collect();
1358 if !blank.is_empty() {
1359 findings.push(Finding {
1360 severity: Severity::Blocking,
1361 what: format!("{} answers an operator with no text in it", blank.join(", ")),
1362 fix: "answer `None` where your engine has no such operator: a formula that needs it \
1363 is then refused by name, and the reader is told to write the function your \
1364 engine has. An empty answer writes the two operands side by side."
1365 .to_owned(),
1366 });
1367 }
1368
1369 let formula = SqlRenderer::to_formula_renderer(dialect);
1370 let dialect_name = SqlRenderer::dialect_name(dialect);
1371 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
1372 findings.push(Finding {
1373 severity: Severity::Blocking,
1374 what: "`to_formula_renderer` was not overridden, so every formula in every design \
1375 renders through Razeri's ANSI defaults instead of this dialect"
1376 .to_owned(),
1377 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
1378 formula this engine sees is silently somebody else's."
1379 .to_owned(),
1380 });
1381 }
1382}
1383
1384fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1390 match SqlRenderer::procedure_form(dialect) {
1391 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
1395 ProcedureForm::Agnostic => findings.push(Finding {
1396 severity: Severity::Unanswered,
1397 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
1398 annotated form, which no engine parses"
1399 .to_owned(),
1400 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
1401 or with `ProcedureForm::Unsupported(\"...\")` and a sentence saying what it \
1402 offers instead."
1403 .to_owned(),
1404 }),
1405 ProcedureForm::Unsupported(orientation) => {
1406 check_refusal_copy("the stored-procedure refusal", orientation, findings);
1407 if DialectCapabilities::supports_stored_procedures(dialect) {
1408 findings.push(Finding {
1409 severity: Severity::Blocking,
1410 what: "this dialect says its engine has stored procedures, and its \
1411 procedure form refuses to write one"
1412 .to_owned(),
1413 fix: "decide which is true. If the engine has none, leave \
1414 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
1415 has them, answer `procedure_form` with the grammar it speaks."
1416 .to_owned(),
1417 });
1418 }
1419 }
1420 ProcedureForm::Custom(grammar) => check_custom_grammar(
1421 CustomGrammar {
1422 kind: RoutineKind::Procedure,
1423 grammar,
1424 form_method: "procedure_form",
1425 render_method: "render_procedure",
1426 noun: "procedures",
1427 },
1428 |spec| SqlRenderer::render_procedure(dialect, spec),
1429 findings,
1430 ),
1431 _ => {}
1432 }
1433 check_procedures_are_used(dialect, findings);
1434
1435 match SqlRenderer::scalar_function_form(dialect) {
1436 ScalarFunctionForm::Unsupported(orientation) => {
1437 check_refusal_copy("the scalar-function refusal", orientation, findings);
1438 }
1439 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
1440 CustomGrammar {
1441 kind: RoutineKind::ScalarFunction,
1442 grammar,
1443 form_method: "scalar_function_form",
1444 render_method: "render_scalar_function",
1445 noun: "scalar functions",
1446 },
1447 |spec| SqlRenderer::render_scalar_function(dialect, spec),
1448 findings,
1449 ),
1450 _ => {}
1451 }
1452
1453 match SqlRenderer::table_function_form(dialect) {
1454 TableFunctionForm::Unsupported(orientation) => {
1455 check_refusal_copy("the table-function refusal", orientation, findings);
1456 }
1457 TableFunctionForm::Custom(grammar) => check_custom_grammar(
1458 CustomGrammar {
1459 kind: RoutineKind::TableFunction,
1460 grammar,
1461 form_method: "table_function_form",
1462 render_method: "render_table_function",
1463 noun: "table functions",
1464 },
1465 |spec| SqlRenderer::render_table_function(dialect, spec),
1466 findings,
1467 ),
1468 _ => {}
1469 }
1470
1471 check_parameter_type_spelling(dialect, findings);
1472}
1473
1474fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1486 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
1487 return;
1488 };
1489 if DialectCapabilities::supports_stored_procedures(dialect) {
1490 return;
1491 }
1492 findings.push(Finding {
1493 severity: Severity::Blocking,
1494 what: format!(
1495 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
1496 load is ever written as one"
1497 ),
1498 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
1499 it before it writes a load as a procedure, and its default is `false`."
1500 .to_owned(),
1501 });
1502}
1503
1504fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1519 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1520 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1521 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1522 if !spells_its_own {
1523 return;
1524 }
1525 let qualified = DataType::varchar(100);
1528 let column = SqlRenderer::render_data_type(dialect, &qualified);
1529 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1530 if column == parameter && column.contains('(') {
1531 findings.push(Finding {
1532 severity: Severity::Note,
1533 what: format!(
1534 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1535 column of that type"
1536 ),
1537 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1538 a length or a precision in a parameter list (Oracle does, and answers \
1539 PLS-00103 at the opening parenthesis) override \
1540 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1541 declarations keep the length either way."
1542 .to_owned(),
1543 });
1544 }
1545}
1546
1547#[derive(Clone, Copy)]
1549struct CustomGrammar {
1550 kind: RoutineKind,
1552 grammar: &'static str,
1554 form_method: &'static str,
1556 render_method: &'static str,
1558 noun: &'static str,
1560}
1561
1562fn check_custom_grammar(
1579 claim: CustomGrammar,
1580 render: impl Fn(&RoutineSpec) -> Option<String>,
1581 findings: &mut Vec<Finding>,
1582) {
1583 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1584 if grammar.trim().is_empty() {
1587 findings.push(Finding {
1588 severity: Severity::Blocking,
1589 what: format!("`{form_method}` says Custom, and names no grammar"),
1590 fix: "carry your grammar's own name: `Custom(\"PL/SQL\")`. It is what a reader \
1591 of a report, and of the comment Razeri writes where a routine could not be \
1592 rendered, is told this engine speaks."
1593 .to_owned(),
1594 });
1595 }
1596
1597 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1598 probe.or_replace = true;
1599 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1600 findings.push(Finding {
1601 severity: Severity::Blocking,
1602 what: format!(
1603 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1604 itself, and `{render_method}` returns nothing"
1605 ),
1606 fix: format!(
1607 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1608 composed (name, parameters with their types already in your spelling, body, \
1609 comment) and returns the whole CREATE statement your engine parses. Razeri \
1610 emits what you return and writes nothing of its own: a near-neighbour grammar \
1611 fails on deploy, and a refusal would say this engine has no {noun} when it \
1612 has them in a grammar of its own."
1613 ),
1614 });
1615 }
1616}
1617
1618fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1620 if copy.trim().is_empty() {
1621 findings.push(Finding {
1622 severity: Severity::Blocking,
1623 what: format!("{subject} has no words in it"),
1624 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1625 next thing to try, and it has to be about their engine."
1626 .to_owned(),
1627 });
1628 return;
1629 }
1630 if copy.split_whitespace().count() < 5 {
1631 findings.push(Finding {
1632 severity: Severity::Note,
1633 what: format!("{subject} is very short"),
1634 fix: "a refusal earns its place by naming the alternative: what this engine has \
1635 instead of the thing it cannot do."
1636 .to_owned(),
1637 });
1638 }
1639}
1640
1641fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1649 let caps: &dyn DialectCapabilities = dialect;
1650 let ren: &dyn SqlRenderer = dialect;
1651 let mut disagreements: Vec<&'static str> = Vec::new();
1652
1653 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1662 disagreements.push("supports_table_functions");
1663 }
1664 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1667 disagreements.push("supports_stored_functions");
1668 }
1669
1670 if !disagreements.is_empty() {
1671 findings.push(Finding {
1672 severity: Severity::Blocking,
1673 what: format!(
1674 "this dialect declares {} {} two ways and the two disagree: {}",
1675 disagreements.len(),
1676 if disagreements.len() == 1 { "fact" } else { "facts" },
1677 disagreements.join(", ")
1678 ),
1679 fix: "each of these is answered as a yes or no on the capability trait and as a \
1680 routine form on the renderer, and Razeri reads both. Decide which is true of \
1681 your engine and move the other one to match."
1682 .to_owned(),
1683 });
1684 }
1685}
1686
1687fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1696 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1697 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1698 return;
1699 };
1700 if DialectCapabilities::supports_schemas(dialect) {
1701 findings.push(Finding {
1702 severity: Severity::Blocking,
1703 what: "this dialect folds a schema into an object's name on an engine it says has \
1704 schemas"
1705 .to_owned(),
1706 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1707 schemas, and only there is it asked; or answer \
1708 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1709 .to_owned(),
1710 });
1711 return;
1712 }
1713 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1714 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1715 if qualified != expected {
1716 findings.push(Finding {
1717 severity: Severity::Blocking,
1718 what: format!(
1719 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1720 the same object `{qualified}`"
1721 ),
1722 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1723 schema. Razeri names some objects through it and the rest through the fold, \
1724 so a difference creates a table under one name and reads it under another."
1725 .to_owned(),
1726 });
1727 }
1728}
1729
1730fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1737 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1738 return;
1739 };
1740 if client.command.trim().is_empty() {
1741 findings.push(Finding {
1742 severity: Severity::Blocking,
1743 what: "this dialect describes a deploy client with no command".to_owned(),
1744 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1745 });
1746 }
1747 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1748 .into_iter()
1749 .filter_map(|(field, args)| {
1750 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1751 })
1752 .collect();
1753 if !unnamed.is_empty() {
1754 findings.push(Finding {
1755 severity: Severity::Blocking,
1756 what: format!(
1757 "the deploy client's {} never names the file to run",
1758 unnamed.join(" and ")
1759 ),
1760 fix: "put `{file}` where your client takes the file of SQL to run, on its own, or \
1761 inside an argument such as `--file={file}`. The script writes the file there \
1762 and nowhere else."
1763 .to_owned(),
1764 });
1765 }
1766 if let Some(terminator) = client.block_terminator
1767 && [terminator.open, terminator.end, terminator.close]
1768 .iter()
1769 .any(|part| part.trim().is_empty())
1770 {
1771 findings.push(Finding {
1772 severity: Severity::Blocking,
1773 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1774 fix: "name the line before a routine, what ends it, and the line after it: \
1775 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1776 the routine open, and the next statement is read into it."
1777 .to_owned(),
1778 });
1779 }
1780}
1781
1782fn declarable_types() -> Vec<DataType> {
1790 vec![
1791 DataType::tinyint(),
1792 DataType::smallint(),
1793 DataType::int(),
1794 DataType::bigint(),
1795 DataType::double(),
1796 DataType::boolean(),
1797 DataType::text(),
1798 DataType::varchar(200),
1799 DataType::uuid(),
1800 DataType::json(),
1801 DataType::date(),
1802 DataType::timestamp(),
1803 DataType::timestamp_tz(),
1804 DataType::decimal(18, 2),
1805 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1806 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1807 DataType::Struct(StructDataType::new(vec![
1808 StructField::new("street", DataType::varchar(200)),
1809 StructField::new("zip", DataType::varchar(10)),
1810 ])),
1811 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1812 StructField::new("key", DataType::varchar(100)),
1813 StructField::new("value", DataType::varchar(1000)),
1814 ])))),
1815 ]
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820 use super::*;
1821 use crate::dialect::DialectInfo;
1822 use razeri_plugin_contract::{
1823 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1824 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1825 TaskRenderer, TextCast,
1826 };
1827 use std::marker::PhantomData;
1828
1829 struct BrokenPlugin;
1837
1838 impl SqlRenderer for BrokenPlugin {
1839 fn render_data_type(&self, _dt: &DataType) -> String {
1840 String::new()
1841 }
1842 fn quote_identifier(&self, name: &str) -> String {
1843 name.to_owned()
1844 }
1845 fn render_cast_type(&self, _dt: &DataType) -> String {
1846 String::new()
1847 }
1848 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1849 name.to_owned()
1850 }
1851 fn render_current_timestamp(&self) -> String {
1852 String::new()
1853 }
1854 fn dialect_name(&self) -> &'static str {
1855 "brokendb"
1856 }
1857 }
1859 impl ExpressionRenderer for BrokenPlugin {}
1860 impl DdlRenderer for BrokenPlugin {}
1861 impl DmlRenderer for BrokenPlugin {}
1862 impl ProcedureRenderer for BrokenPlugin {}
1863 impl FormulaRenderer for BrokenPlugin {}
1864 impl TaskRenderer for BrokenPlugin {}
1865 impl ReplicationRenderer for BrokenPlugin {}
1866 impl StorageRenderer for BrokenPlugin {}
1867 impl DialectCapabilities for BrokenPlugin {
1868 fn supports_table_functions(&self) -> bool {
1871 false
1872 }
1873 }
1874 impl PlatformDialect for BrokenPlugin {
1875 fn info(&self) -> DialectInfo {
1876 DialectInfo {
1877 name: "brokendb",
1878 display_name: "Broken DB",
1879 aliases: &[],
1880 dialect_family: DialectFamily::Custom(""),
1881 family: "Broken",
1882 is_cloud_native: false,
1883 is_olap: false,
1884 is_postgres_compatible: false,
1885 versions: &[],
1886 deploy_script: None,
1887 }
1888 }
1889 }
1890
1891 trait FixtureName: Send + Sync + 'static {
1900 const NAME: &'static str;
1902 const CONCAT: Concat = Concat::Function("CONCAT");
1906 const POWER: Option<&'static str> = Some("^");
1909 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1911 const DROP_LOSES_ITS_COLUMN: bool = false;
1913 const COMPARES_SOMETHING_ELSE: bool = false;
1915 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1918 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1920 const KEY_REFUSED: Option<&'static str> = None;
1923 }
1924
1925 struct Unlinked;
1928 impl FixtureName for Unlinked {
1929 const NAME: &'static str = "healthydb";
1930 }
1931
1932 struct Outside;
1935 impl FixtureName for Outside {
1936 const NAME: &'static str = "outsidedb";
1937 }
1938
1939 struct BinaryConcat;
1944 impl FixtureName for BinaryConcat {
1945 const NAME: &'static str = "binaryconcatdb";
1946 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1947 }
1948
1949 struct OperatorConcat;
1953 impl FixtureName for OperatorConcat {
1954 const NAME: &'static str = "operatorconcatdb";
1955 const CONCAT: Concat = Concat::Operator("||");
1956 }
1957
1958 struct NamelessConcat;
1960 impl FixtureName for NamelessConcat {
1961 const NAME: &'static str = "namelessconcatdb";
1962 const CONCAT: Concat = Concat::Operator(" ");
1963 }
1964
1965 struct NoPowerOperator;
1968 impl FixtureName for NoPowerOperator {
1969 const NAME: &'static str = "nopowerdb";
1970 const POWER: Option<&'static str> = None;
1971 }
1972
1973 struct BlankPowerOperator;
1975 impl FixtureName for BlankPowerOperator {
1976 const NAME: &'static str = "blankpowerdb";
1977 const POWER: Option<&'static str> = Some(" ");
1978 }
1979
1980 struct ComparesSomethingElse;
1982 impl FixtureName for ComparesSomethingElse {
1983 const NAME: &'static str = "comparesotherdb";
1984 const COMPARES_SOMETHING_ELSE: bool = true;
1985 }
1986
1987 struct DropLosesItsColumn;
1989 impl FixtureName for DropLosesItsColumn {
1990 const NAME: &'static str = "droplosesdb";
1991 const DROP_LOSES_ITS_COLUMN: bool = true;
1992 }
1993
1994 struct BeginWithoutTransactions;
1997 impl FixtureName for BeginWithoutTransactions {
1998 const NAME: &'static str = "beginwithouttxdb";
1999 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
2000 }
2001
2002 struct NoShiftOperator;
2004 impl FixtureName for NoShiftOperator {
2005 const NAME: &'static str = "noshiftdb";
2006 const SHIFT_LEFT: Option<&'static str> = None;
2007 }
2008
2009 struct BlankShiftOperator;
2011 impl FixtureName for BlankShiftOperator {
2012 const NAME: &'static str = "blankshiftdb";
2013 const SHIFT_LEFT: Option<&'static str> = Some("");
2014 }
2015
2016 struct DescribedClient;
2018 impl FixtureName for DescribedClient {
2019 const NAME: &'static str = "describedclientdb";
2020 const DEPLOY: Option<crate::dialect::DeployScript> =
2021 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2022 command: "sqlite3",
2023 run_file: &["-bail", "{target}", ".read {file}"],
2024 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
2025 block_terminator: None,
2026 }));
2027 }
2028
2029 struct FilelessClient;
2031 impl FixtureName for FilelessClient {
2032 const NAME: &'static str = "filelessclientdb";
2033 const DEPLOY: Option<crate::dialect::DeployScript> =
2034 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2035 command: "sqlite3",
2036 run_file: &["-bail", "{target}"],
2037 read_rows: None,
2038 block_terminator: None,
2039 }));
2040 }
2041
2042 struct BlankBlockEnd;
2044 impl FixtureName for BlankBlockEnd {
2045 const NAME: &'static str = "blankblockenddb";
2046 const DEPLOY: Option<crate::dialect::DeployScript> =
2047 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
2048 command: "isql",
2049 run_file: &["-bail", "-input", "{file}", "{target}"],
2050 read_rows: None,
2051 block_terminator: Some(crate::dialect::BlockTerminator {
2052 open: "SET TERM ^ ;",
2053 end: " ",
2054 close: "SET TERM ; ^",
2055 }),
2056 }));
2057 }
2058
2059 struct Answered<N: FixtureName>(PhantomData<N>);
2060
2061 impl<N: FixtureName> Answered<N> {
2062 const fn new() -> Self {
2063 Self(PhantomData)
2064 }
2065 }
2066
2067 type HealthyPlugin = Answered<Unlinked>;
2068 type OutsidePlugin = Answered<Outside>;
2069
2070 inventory::submit! {
2071 crate::builder::DialectRegistration::new(
2072 Outside::NAME,
2073 || std::sync::Arc::new(OutsidePlugin::new()),
2074 )
2075 }
2076
2077 struct KeyRefused;
2080 impl FixtureName for KeyRefused {
2081 const NAME: &'static str = "keyrefuseddb";
2082 const KEY_REFUSED: Option<&'static str> = Some(
2083 "-- Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2084 a function.\n--\n-- Load into an engine that computes the key, and read it \
2085 from here.\n",
2086 );
2087 }
2088
2089 struct KeyRefusedSilently;
2091 impl FixtureName for KeyRefusedSilently {
2092 const NAME: &'static str = "keyrefusedsilentlydb";
2093 const KEY_REFUSED: Option<&'static str> = Some("");
2094 }
2095
2096 fn graded_alone<N: FixtureName>() -> Report {
2099 let mut registry = crate::dialect::PlatformRegistry::new();
2100 registry.register(N::NAME.to_owned(), std::sync::Arc::new(Answered::<N>::new()));
2101 check_with_registry(&Answered::<N>::new(), ®istry)
2102 }
2103
2104 impl<N: FixtureName> SqlRenderer for Answered<N> {
2105 fn render_data_type(&self, dt: &DataType) -> String {
2106 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2107 }
2108 fn quote_identifier(&self, name: &str) -> String {
2109 format!("\"{name}\"")
2110 }
2111 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2112 schema.map_or_else(
2113 || self.quote_identifier(name),
2114 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
2115 )
2116 }
2117 fn render_current_timestamp(&self) -> String {
2118 "NOW()".to_owned()
2119 }
2120 fn dialect_name(&self) -> &'static str {
2121 N::NAME
2122 }
2123 fn sid_spelling(&self) -> SidSpelling {
2124 if N::KEY_REFUSED.is_some() {
2125 return SidSpelling {
2126 platform: razeri_plugin_contract::UNKNOWN_PLATFORM,
2127 ..razeri_plugin_contract::ANSI_SID_SPELLING
2128 };
2129 }
2130 SidSpelling {
2131 platform: N::NAME,
2132 hash: ("answered_xxhash64(", ")"),
2133 cast_type: TextCast::Direct("STRING"),
2134 concat: N::CONCAT,
2135 through_helper: false,
2136 ..razeri_plugin_contract::ANSI_SID_SPELLING
2137 }
2138 }
2139 fn procedure_form(&self) -> ProcedureForm {
2140 ProcedureForm::Unsupported(
2141 "This engine has no stored procedures; write the load as a scheduled query \
2142 instead.",
2143 )
2144 }
2145 fn scalar_function_form(&self) -> ScalarFunctionForm {
2146 ScalarFunctionForm::PostgreSql
2147 }
2148 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
2149 Box::new(Self::new())
2150 }
2151 }
2152 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
2153 fn render_comparable(
2154 &self,
2155 value: &str,
2156 _data_type: &DataType,
2157 _held: HeldAs,
2158 ) -> Option<String> {
2159 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
2160 }
2161 }
2162 impl<N: FixtureName> DdlRenderer for Answered<N> {
2163 fn render_drop_column(
2164 &self,
2165 table: WrittenObject<'_>,
2166 column: &str,
2167 only_where_it_stands: bool,
2168 ) -> Option<razeri_plugin_contract::Written> {
2169 if N::DROP_LOSES_ITS_COLUMN {
2170 return Some(razeri_plugin_contract::Written::Statement(format!(
2171 "ALTER TABLE {} DROP COLUMN c",
2172 table.relation
2173 )));
2174 }
2175 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
2176 Some(razeri_plugin_contract::Written::Statement(format!(
2177 "ALTER TABLE {} DROP COLUMN{guard} {column}",
2178 table.relation
2179 )))
2180 }
2181 }
2182 impl<N: FixtureName> DmlRenderer for Answered<N> {}
2183 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
2184 impl<N: FixtureName> FormulaRenderer for Answered<N> {
2185 fn render_power_operator(&self) -> Option<String> {
2186 N::POWER.map(str::to_owned)
2187 }
2188 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
2189 match operator {
2190 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
2191 other => Some(other.standard_spelling().to_owned()),
2192 }
2193 }
2194 }
2195 impl<N: FixtureName> TaskRenderer for Answered<N> {}
2196 impl<N: FixtureName> ReplicationRenderer for Answered<N> {
2197 fn sid_prerequisites(&self) -> String {
2198 N::KEY_REFUSED.unwrap_or_default().to_owned()
2199 }
2200 fn sid_prerequisite_kind(&self) -> SidPrerequisiteKind {
2201 if N::KEY_REFUSED.is_some() {
2202 SidPrerequisiteKind::Refused
2203 } else {
2204 razeri_plugin_contract::kind_of_prerequisite_script(&self.sid_prerequisites())
2205 }
2206 }
2207 }
2208 impl<N: FixtureName> StorageRenderer for Answered<N> {}
2209 impl<N: FixtureName> DialectCapabilities for Answered<N> {
2210 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
2211 N::BEGIN_WITHOUT_TRANSACTIONS
2212 }
2213 fn supports_multi_statement_transactions(&self) -> bool {
2214 !N::BEGIN_WITHOUT_TRANSACTIONS
2215 }
2216 fn merge_shape(&self) -> MergeShape {
2217 MergeShape::Merge(MergeRestrictions::none())
2218 }
2219 }
2220 impl<N: FixtureName> PlatformDialect for Answered<N> {
2221 fn info(&self) -> DialectInfo {
2222 DialectInfo {
2223 name: N::NAME,
2224 display_name: "An Answered Engine",
2225 aliases: &[],
2228 dialect_family: DialectFamily::Custom(N::NAME),
2229 family: "Answered",
2230 is_cloud_native: false,
2231 is_olap: false,
2232 is_postgres_compatible: false,
2233 versions: &[],
2234 deploy_script: N::DEPLOY,
2235 }
2236 }
2237 }
2238
2239 struct Twin;
2245
2246 impl SqlRenderer for Twin {
2247 fn render_data_type(&self, dt: &DataType) -> String {
2248 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2249 }
2250 fn quote_identifier(&self, name: &str) -> String {
2251 format!("\"{name}\"")
2252 }
2253 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2254 self.quote_identifier(name)
2255 }
2256 fn render_current_timestamp(&self) -> String {
2257 "NOW()".to_owned()
2258 }
2259 }
2260 impl ExpressionRenderer for Twin {}
2261 impl DdlRenderer for Twin {}
2262 impl DmlRenderer for Twin {}
2263 impl ProcedureRenderer for Twin {}
2264 impl FormulaRenderer for Twin {}
2265 impl TaskRenderer for Twin {}
2266 impl ReplicationRenderer for Twin {}
2267 impl StorageRenderer for Twin {}
2268 impl DialectCapabilities for Twin {}
2269 impl PlatformDialect for Twin {
2270 fn info(&self) -> DialectInfo {
2271 DialectInfo {
2272 name: "twindb",
2273 display_name: "Twin DB",
2274 aliases: &["healthydb"],
2276 dialect_family: DialectFamily::Custom("twindb"),
2277 family: "Twin",
2278 is_cloud_native: false,
2279 is_olap: false,
2280 is_postgres_compatible: false,
2281 versions: &[],
2282 deploy_script: None,
2283 }
2284 }
2285 }
2286
2287 fn mentions(report: &Report, needle: &str) -> bool {
2289 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
2290 }
2291
2292 #[test]
2296 fn the_report_says_what_it_read_and_what_it_took_as_the_default() {
2297 let report = check(&BrokenPlugin);
2298 let printed = report.to_string();
2299 let first = printed.lines().next().unwrap_or_default();
2300 assert!(first.ends_with("in the answers this dialect states."), "{printed}");
2301 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2302 assert!(printed.contains("Taken as the contract's default"), "{printed}");
2303 let names: Vec<&str> = report.taken_as_default.iter().map(|taken| taken.name).collect();
2304 assert!(names.contains(&"DdlRenderer::supports_if_not_exists"), "{names:?}");
2305 let view = report
2306 .taken_as_default
2307 .iter()
2308 .find(|taken| taken.name == "DdlRenderer::render_create_view_prefix")
2309 .expect("listed");
2310 assert!(view.answer.contains("CREATE OR REPLACE VIEW"), "{view:?}");
2311 assert_eq!(view.shows_in, "schema.sql");
2312 assert!(printed.contains(&view.answer), "{printed}");
2313 }
2314
2315 #[test]
2319 fn a_report_with_nothing_to_fix_still_says_it_ran_nothing() {
2320 let report = check(&Answered::<Outside>::new());
2321 let printed = report.to_string();
2322 assert!(printed.contains("ran nothing on an engine"), "{printed}");
2323 assert!(!printed.lines().next().unwrap_or_default().ends_with("0 to fix."), "{printed}");
2324 }
2325
2326 #[test]
2330 fn a_dialect_with_no_key_spelling_is_asked_for_what_the_key_runs() {
2331 let report = check(&BrokenPlugin);
2332 let key = report
2333 .findings
2334 .iter()
2335 .find(|finding| finding.what.contains("surrogate-key spelling"))
2336 .expect("the key's finding");
2337 for words in ["a replacement and a Unicode literal", "joins strings", "casts to text"] {
2338 assert!(key.fix.contains(words), "no {words}: {}", key.fix);
2339 }
2340 assert!(!key.fix.contains("LOWER") && !key.fix.contains("TRIM"), "{}", key.fix);
2341 }
2342
2343 #[test]
2347 fn the_kit_catches_a_deliberately_broken_plugin() {
2348 let report = check(&BrokenPlugin);
2349 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
2350
2351 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
2352 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
2353 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
2354 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
2355 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
2356 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
2357 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
2358 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
2359 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
2360 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
2361 assert!(mentions(&report, "supports_table_functions"), "paired fact:\n{report}");
2362
2363 assert!(
2364 report.count(Severity::Blocking) >= 8,
2365 "the broken plugin owes more than {} blocking fixes:\n{report}",
2366 report.count(Severity::Blocking)
2367 );
2368 }
2369
2370 #[test]
2374 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
2375 let report = check(&HealthyPlugin::new());
2376 let unregistered: Vec<&Finding> =
2377 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
2378 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
2379 assert_eq!(
2380 report.findings.len(),
2381 1,
2382 "a plugin that answered every question owes nothing else:\n{report}"
2383 );
2384 }
2385
2386 #[test]
2391 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
2392 let binary = Answered::<BinaryConcat>::new();
2393 let report = check(&binary);
2394 assert_eq!(
2395 report.findings.len(),
2396 check(&HealthyPlugin::new()).findings.len(),
2397 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2398 );
2399 assert!(
2400 !report.to_string().contains("joins"),
2401 "nothing about the join is outstanding:\n{report}"
2402 );
2403
2404 let key = razeri_plugin_contract::sid::sid_expression(
2405 SqlRenderer::sid_spelling(&binary),
2406 razeri_plugin_contract::sid::Backslash::of(&binary),
2407 None,
2408 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
2409 );
2410 assert!(
2411 key.contains("CONCAT(CONCAT("),
2412 "a two-argument engine gets one call per pair:\n{key}"
2413 );
2414 }
2415
2416 #[test]
2420 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
2421 let operator = Answered::<OperatorConcat>::new();
2422 let report = check(&operator);
2423 assert_eq!(
2424 report.findings.len(),
2425 check(&HealthyPlugin::new()).findings.len(),
2426 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
2427 );
2428 let key = razeri_plugin_contract::sid::sid_expression(
2429 SqlRenderer::sid_spelling(&operator),
2430 razeri_plugin_contract::sid::Backslash::of(&operator),
2431 None,
2432 &["a".to_owned(), "b".to_owned()],
2433 );
2434 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
2435 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
2436 }
2437
2438 #[test]
2441 fn a_join_that_names_nothing_must_be_fixed() {
2442 let report = check(&Answered::<NamelessConcat>::new());
2443 assert!(
2444 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2445 && finding.what.contains("joins the parts of a composite key")),
2446 "{report}"
2447 );
2448 assert!(
2449 !check(&Answered::<OperatorConcat>::new())
2450 .findings
2451 .iter()
2452 .any(|finding| finding.what.contains("joins the parts of a composite key")),
2453 "a named operator is not the finding"
2454 );
2455 }
2456
2457 #[test]
2461 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2462 let none = check(&Answered::<NoPowerOperator>::new());
2463 assert_eq!(
2464 none.findings.len(),
2465 check(&HealthyPlugin::new()).findings.len(),
2466 "saying the engine has no such operator owes nothing:\n{none}"
2467 );
2468 let blank = check(&Answered::<BlankPowerOperator>::new());
2469 assert!(
2470 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2471 && finding.what.contains("render_power_operator")),
2472 "{blank}"
2473 );
2474 }
2475
2476 #[test]
2479 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
2480 let none = check(&Answered::<NoShiftOperator>::new());
2481 assert_eq!(
2482 none.findings.len(),
2483 check(&HealthyPlugin::new()).findings.len(),
2484 "saying the engine has no left shift owes nothing:\n{none}"
2485 );
2486 let blank = check(&Answered::<BlankShiftOperator>::new());
2487 assert!(
2488 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
2489 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
2490 "{blank}"
2491 );
2492 }
2493
2494 #[test]
2497 fn a_drop_that_loses_its_column_must_be_fixed() {
2498 let healthy = check(&HealthyPlugin::new());
2499 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
2500 let lost = check(&Answered::<DropLosesItsColumn>::new());
2501 assert!(
2502 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
2503 && finding.what.contains("render_drop_column")),
2504 "{lost}"
2505 );
2506 }
2507
2508 #[test]
2511 fn a_comparison_that_loses_its_value_must_be_fixed() {
2512 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
2513 let report = check(&Answered::<ComparesSomethingElse>::new());
2514 assert!(
2515 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2516 && finding.what.contains("render_comparable")),
2517 "{report}"
2518 );
2519 }
2520
2521 #[test]
2525 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
2526 let report = check(&Answered::<BeginWithoutTransactions>::new());
2527 assert!(
2528 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2529 && finding.what.contains("leaves the commit to the client")),
2530 "{report}"
2531 );
2532 }
2533
2534 #[test]
2536 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
2537 let report = check(&Answered::<BlankBlockEnd>::new());
2538 assert!(
2539 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
2540 && finding.what.contains("block terminator")),
2541 "{report}"
2542 );
2543 }
2544
2545 #[test]
2548 fn a_deploy_client_has_to_be_told_which_file_to_run() {
2549 let described = check(&Answered::<DescribedClient>::new());
2550 assert_eq!(
2551 described.findings.len(),
2552 check(&HealthyPlugin::new()).findings.len(),
2553 "a client described in full owes nothing:\n{described}"
2554 );
2555 let fileless = check(&Answered::<FilelessClient>::new());
2556 assert!(
2557 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
2558 && finding.what.contains("run_file never names the file")),
2559 "{fileless}"
2560 );
2561 }
2562
2563 #[test]
2567 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
2568 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
2569 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
2570 fn render_data_type(&self, dt: &DataType) -> String {
2571 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2572 }
2573 fn quote_identifier(&self, name: &str) -> String {
2574 format!("\"{name}\"")
2575 }
2576 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
2577 match schema {
2578 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
2579 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
2580 None => self.quote_identifier(name),
2581 }
2582 }
2583 fn render_current_timestamp(&self) -> String {
2584 "CURRENT_TIMESTAMP".to_owned()
2585 }
2586 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2587 Some(format!("{schema}_{name}"))
2588 }
2589 }
2590 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2591 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2592 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2593 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2594 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2595 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2596 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2597 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2598 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2599 fn supports_schemas(&self) -> bool {
2600 S
2601 }
2602 }
2603 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2604 fn info(&self) -> DialectInfo {
2605 DialectInfo {
2606 name: "folderdb",
2607 display_name: "Folder DB",
2608 aliases: &[],
2609 dialect_family: DialectFamily::Custom("folderdb"),
2610 family: "Folder",
2611 is_cloud_native: false,
2612 is_olap: false,
2613 is_postgres_compatible: false,
2614 versions: &[],
2615 deploy_script: None,
2616 }
2617 }
2618 }
2619
2620 let fold_findings = |dialect: &dyn PlatformDialect| {
2621 let mut findings = Vec::new();
2622 check_schema_fold(dialect, &mut findings);
2623 findings
2624 };
2625 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2626 let disagrees = fold_findings(&Folder::<false, false>);
2627 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2628 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2629 let has_schemas = fold_findings(&Folder::<true, true>);
2630 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2631 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2632 }
2633
2634 #[test]
2643 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2644 let mut quiet = Vec::new();
2645 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2646 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2647
2648 let mut stated = Vec::new();
2649 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2650 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2651 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2652 assert!(
2653 stated[0].what.contains("literal"),
2654 "the finding names the restriction:\n{}",
2655 stated[0].what
2656 );
2657 assert!(
2658 stated[0].fix.contains("dynamic-execution"),
2659 "and says what Razeri does with the answer:\n{}",
2660 stated[0].fix
2661 );
2662
2663 let mut two_clauses = Vec::new();
2666 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2667 assert!(two_clauses.is_empty(), "one restriction is not the other");
2668 }
2669
2670 #[test]
2680 fn a_stated_key_refusal_answers_the_key() {
2681 let report = graded_alone::<KeyRefused>();
2682 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2683 assert_eq!(report.count(Severity::Unanswered), 0, "{report}");
2684 assert!(report.is_clean(), "the dialect is ready:\n{report}");
2685 let listed: Vec<_> = report
2686 .findings
2687 .iter()
2688 .filter(|finding| finding.what.contains("cannot compute razeri's surrogate key"))
2689 .collect();
2690 assert_eq!(listed.len(), 1, "{report}");
2691 assert_eq!(listed[0].severity, Severity::Note, "{report}");
2692 assert_eq!(
2693 listed[0].fix, "its reason: Key Refused DB cannot compute razeri's surrogate key.",
2694 "{report}"
2695 );
2696 assert!(
2697 !report.to_string().contains("states no surrogate-key spelling"),
2698 "a refusing dialect is never told to spell the key:\n{report}"
2699 );
2700 assert_eq!(
2701 key_refusal_reason(&Answered::<KeyRefused>::new()).as_deref(),
2702 Some(
2703 "Key Refused DB cannot compute razeri's surrogate key. It has no way to define \
2704 a function.\n\nLoad into an engine that computes the key, and read it from here."
2705 )
2706 );
2707 assert_eq!(key_refusal_reason(&OutsidePlugin::new()), None);
2708 }
2709
2710 #[test]
2713 fn a_key_refusal_with_no_reason_is_to_fix() {
2714 let report = graded_alone::<KeyRefusedSilently>();
2715 let owed: Vec<_> = report
2716 .findings
2717 .iter()
2718 .filter(|finding| finding.severity == Severity::Blocking)
2719 .collect();
2720 assert_eq!(owed.len(), 1, "{report}");
2721 assert!(owed[0].what.contains("gives no reason"), "{report}");
2722 assert!(owed[0].fix.contains("`ReplicationRenderer::sid_prerequisites`"), "{report}");
2723 }
2724
2725 #[test]
2726 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2727 assert!(
2728 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2729 "the fixture registers through the registry, which is the whole path being graded"
2730 );
2731 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2735 assert!(
2736 registry
2737 .list()
2738 .iter()
2739 .filter(|info| info.name != Outside::NAME)
2740 .all(|info| !info.answers_to(Outside::NAME)),
2741 "the fixture's name must be one no other platform claims"
2742 );
2743
2744 let report = check(&OutsidePlugin::new());
2745 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2746 assert!(report.is_clean(), "and the report says so:\n{report}");
2747 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2748 assert!(
2749 report.to_string().contains("nothing to fix"),
2750 "a clean dialect is told it is clean:\n{report}"
2751 );
2752 }
2753
2754 #[test]
2760 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2761 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2762 for info in registry.list() {
2763 assert_eq!(
2764 info.spellings().first().copied(),
2765 Some(info.name),
2766 "a platform's own name leads its spellings"
2767 );
2768 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2769 }
2770 }
2771
2772 #[test]
2778 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2779 let mut registry = crate::dialect::PlatformRegistry::new();
2780 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2781 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2782
2783 let report = check_with_registry(&Twin, ®istry);
2784 let collision = report
2785 .findings
2786 .iter()
2787 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2788 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2789 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2790 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2791 assert!(collision.what.contains("twindb"), "{collision:?}");
2792 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2793 assert!(
2794 !mentions(&report, "does not resolve"),
2795 "a collision is not an unregistered platform:\n{report}"
2796 );
2797 }
2798
2799 #[test]
2803 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2804 let mut registry = crate::dialect::PlatformRegistry::new();
2805 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2806
2807 let mut findings = Vec::new();
2808 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2809 assert!(findings.is_empty(), "{findings:?}");
2810
2811 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2812 assert!(
2813 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2814 "'{spelling}' is the same platform"
2815 );
2816 }
2817 assert!(
2818 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2819 );
2820 }
2821
2822 #[test]
2827 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2828 let mut registry = crate::dialect::PlatformRegistry::new();
2829 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2830
2831 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2832 assert!(
2833 report.is_clean(),
2834 "a loaded dialect that answered everything owes nothing:\n{report}"
2835 );
2836 }
2837
2838 #[test]
2842 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2843 let mut collided = crate::dialect::PlatformRegistry::new();
2844 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2845 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2846
2847 let mut fixes: Vec<String> = Vec::new();
2848 for report in [
2849 check(&BrokenPlugin),
2850 check(&HealthyPlugin::new()),
2851 check(&OutsidePlugin::new()),
2852 check_with_registry(&Twin, &collided),
2855 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2856 ] {
2857 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2858 }
2859 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2860 for fix in &fixes {
2861 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2862 assert!(
2863 !fix.contains(path),
2864 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2865 );
2866 }
2867 }
2868 }
2869
2870 #[test]
2877 fn no_finding_holds_a_run_of_spaces() {
2878 struct Declining;
2879 impl SqlRenderer for Declining {
2880 fn render_data_type(&self, dt: &DataType) -> String {
2881 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2882 }
2883 fn quote_identifier(&self, name: &str) -> String {
2884 format!("\"{name}\"")
2885 }
2886 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2887 name.to_owned()
2888 }
2889 fn render_current_timestamp(&self) -> String {
2890 "CURRENT_TIMESTAMP".to_owned()
2891 }
2892 fn procedure_form(&self) -> ProcedureForm {
2893 ProcedureForm::Custom("Declining PSQL")
2894 }
2895 }
2896 impl ExpressionRenderer for Declining {}
2897 impl DdlRenderer for Declining {
2898 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2899 None
2900 }
2901 }
2902 impl DialectCapabilities for Declining {
2903 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2904 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2905 .and_a_nested_field()
2906 }
2907 }
2908 impl DmlRenderer for Declining {}
2909 impl ProcedureRenderer for Declining {}
2910 impl FormulaRenderer for Declining {}
2911 impl TaskRenderer for Declining {}
2912 impl ReplicationRenderer for Declining {}
2913 impl StorageRenderer for Declining {}
2914 impl PlatformDialect for Declining {
2915 fn info(&self) -> DialectInfo {
2916 DialectInfo {
2917 name: "decliningdb",
2918 display_name: "Declining DB",
2919 aliases: &[],
2920 dialect_family: DialectFamily::Custom("decliningdb"),
2921 family: "Declining",
2922 is_cloud_native: false,
2923 is_olap: false,
2924 is_postgres_compatible: false,
2925 versions: &[],
2926 deploy_script: None,
2927 }
2928 }
2929 }
2930
2931 let mut findings = Vec::new();
2932 check_evolution(&Declining, &mut findings);
2933 check_parameter_type_spelling(&Declining, &mut findings);
2934 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2935
2936 for report in [
2937 check(&BrokenPlugin),
2938 check(&HealthyPlugin::new()),
2939 check(&OutsidePlugin::new()),
2940 check(&Declining),
2941 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2942 ] {
2943 findings.extend(report.findings);
2944 }
2945 for finding in &findings {
2946 for text in [&finding.what, &finding.fix] {
2947 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2948 }
2949 }
2950 }
2951
2952 #[test]
2962 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2963 for (kind, form_method, render_method) in [
2964 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2965 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2966 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2967 ] {
2968 let mut promised = Vec::new();
2969 check_custom_grammar(
2970 CustomGrammar {
2971 kind,
2972 grammar: "PL/SQL",
2973 form_method,
2974 render_method,
2975 noun: "routines",
2976 },
2977 |_| None,
2978 &mut promised,
2979 );
2980 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2981 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2982 assert!(promised[0].what.contains(form_method), "{promised:?}");
2983 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2984 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2985
2986 let mut kept = Vec::new();
2988 check_custom_grammar(
2989 CustomGrammar {
2990 kind,
2991 grammar: "PL/SQL",
2992 form_method,
2993 render_method,
2994 noun: "routines",
2995 },
2996 |spec| Some(format!("CREATE {} ...", spec.name)),
2997 &mut kept,
2998 );
2999 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
3000 }
3001 }
3002
3003 #[test]
3008 fn a_procedure_form_and_the_procedure_capability_must_agree() {
3009 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
3010 let mut findings = Vec::new();
3011 check_routine_forms(dialect, &mut findings);
3012 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() }
3013 }
3014
3015 struct Claims<const CUSTOM: bool, const HAS: bool>;
3018 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
3019 fn render_data_type(&self, dt: &DataType) -> String {
3020 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3021 }
3022 fn quote_identifier(&self, name: &str) -> String {
3023 format!("\"{name}\"")
3024 }
3025 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3026 self.quote_identifier(name)
3027 }
3028 fn render_current_timestamp(&self) -> String {
3029 "CURRENT_TIMESTAMP".to_owned()
3030 }
3031 fn procedure_form(&self) -> ProcedureForm {
3032 if CUSTOM {
3033 ProcedureForm::Custom("Firebird PSQL")
3034 } else {
3035 ProcedureForm::Unsupported(
3036 "This engine has no stored procedures; schedule the load as a query.",
3037 )
3038 }
3039 }
3040 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
3041 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
3042 }
3043 }
3044 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
3045 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
3046 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
3047 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
3048 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
3049 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
3050 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
3051 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
3052 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
3053 fn supports_stored_procedures(&self) -> bool {
3054 HAS
3055 }
3056 }
3057 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
3058 fn info(&self) -> DialectInfo {
3059 DialectInfo {
3060 name: "claimsdb",
3061 display_name: "Claims DB",
3062 aliases: &[],
3063 dialect_family: DialectFamily::Custom("claimsdb"),
3064 family: "Claims",
3065 is_cloud_native: false,
3066 is_olap: false,
3067 is_postgres_compatible: false,
3068 versions: &[],
3069 deploy_script: None,
3070 }
3071 }
3072 }
3073
3074 let disagreement = |report: &Report| {
3075 report
3076 .findings
3077 .iter()
3078 .filter(|finding| {
3079 finding.severity == Severity::Blocking
3080 && (finding.what.contains("says its engine has none")
3081 || finding.what.contains("refuses to write one"))
3082 })
3083 .count()
3084 };
3085 let mut findings = Vec::new();
3086 check_routine_forms(&Claims::<true, false>, &mut findings);
3087 let report =
3088 Report { dialect: "claimsdb".to_owned(), findings, taken_as_default: Vec::new() };
3089 assert_eq!(disagreement(&report), 1, "{report}");
3090 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
3091
3092 for (agrees, label) in [
3093 (check_forms(&Claims::<true, true>), "spelled and used"),
3094 (check_forms(&Claims::<false, false>), "refused and absent"),
3095 ] {
3096 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
3097 }
3098 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
3099 }
3100
3101 #[test]
3107 fn a_custom_render_method_that_returns_nothing_is_blocking() {
3108 let mut findings = Vec::new();
3109 check_custom_grammar(
3110 CustomGrammar {
3111 kind: RoutineKind::Procedure,
3112 grammar: "PL/SQL",
3113 form_method: "procedure_form",
3114 render_method: "render_procedure",
3115 noun: "procedures",
3116 },
3117 |_| Some(" \n".to_owned()),
3118 &mut findings,
3119 );
3120 assert_eq!(findings.len(), 1, "{findings:?}");
3121 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
3122 }
3123
3124 #[test]
3126 fn a_custom_grammar_with_no_name_is_blocking() {
3127 let mut findings = Vec::new();
3128 check_custom_grammar(
3129 CustomGrammar {
3130 kind: RoutineKind::ScalarFunction,
3131 grammar: " ",
3132 form_method: "scalar_function_form",
3133 render_method: "render_scalar_function",
3134 noun: "scalar functions",
3135 },
3136 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
3137 &mut findings,
3138 );
3139 assert_eq!(findings.len(), 1, "{findings:?}");
3140 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
3141 }
3142
3143 #[test]
3151 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
3152 struct Overstated;
3153 impl SqlRenderer for Overstated {
3154 fn render_data_type(&self, dt: &DataType) -> String {
3155 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3156 }
3157 fn quote_identifier(&self, name: &str) -> String {
3158 format!("\"{name}\"")
3159 }
3160 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3161 name.to_owned()
3162 }
3163 fn render_current_timestamp(&self) -> String {
3164 "CURRENT_TIMESTAMP".to_owned()
3165 }
3166 fn dialect_name(&self) -> &'static str {
3167 "overstateddb"
3168 }
3169 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
3170 Some(razeri_plugin_contract::SidHelperBody {
3171 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
3172 body: " \n ",
3173 })
3174 }
3175 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
3176 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
3177 }
3178 }
3179 impl ExpressionRenderer for Overstated {}
3180 impl DdlRenderer for Overstated {}
3181 impl DmlRenderer for Overstated {}
3182 impl ProcedureRenderer for Overstated {}
3183 impl FormulaRenderer for Overstated {}
3184 impl TaskRenderer for Overstated {}
3185 impl ReplicationRenderer for Overstated {}
3186 impl StorageRenderer for Overstated {}
3187 impl DialectCapabilities for Overstated {}
3188 impl PlatformDialect for Overstated {
3189 fn info(&self) -> DialectInfo {
3190 DialectInfo {
3191 name: "overstateddb",
3192 display_name: "Overstated DB",
3193 aliases: &[],
3194 dialect_family: DialectFamily::Custom(""),
3195 family: "Overstated",
3196 is_cloud_native: false,
3197 is_olap: false,
3198 is_postgres_compatible: false,
3199 versions: &[],
3200 deploy_script: None,
3201 }
3202 }
3203 }
3204
3205 let report = check(&Overstated);
3206 for (fragment, why) in [
3207 ("supplies an empty one", "a blank helper body"),
3208 ("does not carry the path", "a file relation that drops its path"),
3209 ] {
3210 let found = report
3211 .findings
3212 .iter()
3213 .find(|f| f.what.contains(fragment))
3214 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
3215 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
3216 }
3217
3218 let quiet = check(&HealthyPlugin::new());
3221 for fragment in ["supplies an empty one", "does not carry the path"] {
3222 assert!(
3223 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
3224 "silence is the default and owes nothing:\n{quiet}"
3225 );
3226 }
3227 }
3228
3229 #[test]
3233 fn a_file_read_that_ignores_the_delimiter_quote_and_header_is_blocking() {
3234 struct Deaf<const OVERRIDES: bool>;
3235 impl<const OVERRIDES: bool> SqlRenderer for Deaf<OVERRIDES> {
3236 fn render_data_type(&self, dt: &DataType) -> String {
3237 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
3238 }
3239 fn quote_identifier(&self, name: &str) -> String {
3240 format!("\"{name}\"")
3241 }
3242 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
3243 name.to_owned()
3244 }
3245 fn render_current_timestamp(&self) -> String {
3246 "CURRENT_TIMESTAMP".to_owned()
3247 }
3248 fn delimited_file_relation(&self, path_literal: &str) -> Option<String> {
3249 Some(format!("scan({path_literal})"))
3250 }
3251 fn delimited_file_relation_for(&self, file: &DelimitedFile<'_>) -> Option<String> {
3252 if OVERRIDES {
3253 Some(format!("scan({})", file.path_literal))
3254 } else {
3255 file.is_plain().then(|| format!("scan({})", file.path_literal))
3257 }
3258 }
3259 }
3260 impl<const OVERRIDES: bool> ExpressionRenderer for Deaf<OVERRIDES> {}
3261 impl<const OVERRIDES: bool> DdlRenderer for Deaf<OVERRIDES> {}
3262 impl<const OVERRIDES: bool> DmlRenderer for Deaf<OVERRIDES> {}
3263 impl<const OVERRIDES: bool> ProcedureRenderer for Deaf<OVERRIDES> {}
3264 impl<const OVERRIDES: bool> FormulaRenderer for Deaf<OVERRIDES> {}
3265 impl<const OVERRIDES: bool> TaskRenderer for Deaf<OVERRIDES> {}
3266 impl<const OVERRIDES: bool> ReplicationRenderer for Deaf<OVERRIDES> {}
3267 impl<const OVERRIDES: bool> StorageRenderer for Deaf<OVERRIDES> {}
3268 impl<const OVERRIDES: bool> DialectCapabilities for Deaf<OVERRIDES> {}
3269 impl<const OVERRIDES: bool> PlatformDialect for Deaf<OVERRIDES> {
3270 fn info(&self) -> DialectInfo {
3271 DialectInfo {
3272 name: "deafdb",
3273 display_name: "Deaf DB",
3274 aliases: &[],
3275 dialect_family: DialectFamily::Custom(""),
3276 family: "Deaf",
3277 is_cloud_native: false,
3278 is_olap: false,
3279 is_postgres_compatible: false,
3280 versions: &[],
3281 deploy_script: None,
3282 }
3283 }
3284 }
3285
3286 let mut deaf = Vec::new();
3287 check_file_settings(&Deaf::<true>, &mut deaf);
3288 assert_eq!(deaf.len(), 3, "the delimiter, the quote and the header: {deaf:?}");
3289 assert!(deaf.iter().all(|finding| finding.severity == Severity::Blocking), "{deaf:?}");
3290 assert!(deaf[0].what.contains("delimited by `|`"), "{deaf:?}");
3291
3292 let mut default = Vec::new();
3293 check_file_settings(&Deaf::<false>, &mut default);
3294 assert!(default.is_empty(), "`None` is a complete answer: {default:?}");
3295 }
3296
3297 #[test]
3301 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
3302 let mut mute = Vec::new();
3303 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
3304 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
3305 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
3306
3307 let silence = check(&BrokenPlugin);
3308 assert!(
3309 silence
3310 .findings
3311 .iter()
3312 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
3313 "a dialect that never answered is unanswered, not wrong:\n{silence}"
3314 );
3315 assert!(
3316 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
3317 "the fix line names the way to state a measured refusal:\n{silence}"
3318 );
3319 }
3320
3321 #[test]
3325 fn the_report_reads_as_a_summary_then_a_fix_list() {
3326 let text = check(&BrokenPlugin).to_string();
3327 let first = text.lines().next().expect("a summary line");
3328 assert!(first.starts_with("brokendb:"), "{first}");
3329 assert!(first.contains("to fix"), "{first}");
3330 assert!(text.contains(" -> "), "every finding names the fix:\n{text}");
3331 }
3332}