1use crate::PlatformDialect;
45use razeri_plugin_contract::{
46 BitwiseOperator, CatalogueReach, ColumnEvolution, Concat, ConstraintEnforcement, DataType,
47 DdlRenderer, DialectCapabilities, DialectFamily, EntityRef, ExpressionRenderer,
48 FormulaRenderer, HeldAs, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
49 RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
50 TableFunctionForm, UNKNOWN_PLATFORM, WrittenObject,
51 data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
52 storage_strategy::{
53 CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
54 },
55};
56use std::fmt;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
60pub enum Severity {
61 Blocking,
64 Unanswered,
67 Note,
69}
70
71impl fmt::Display for Severity {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(match self {
74 Self::Blocking => "must fix",
75 Self::Unanswered => "unanswered",
76 Self::Note => "worth a look",
77 })
78 }
79}
80
81#[derive(Debug, Clone)]
84pub struct Finding {
85 pub severity: Severity,
87 pub what: String,
89 pub fix: String,
91}
92
93#[derive(Debug, Clone)]
95pub struct Report {
96 pub dialect: String,
98 pub findings: Vec<Finding>,
100}
101
102impl Report {
103 #[must_use]
107 pub fn is_clean(&self) -> bool {
108 !self.findings.iter().any(|f| f.severity != Severity::Note)
109 }
110
111 #[must_use]
113 pub fn count(&self, severity: Severity) -> usize {
114 self.findings.iter().filter(|f| f.severity == severity).count()
115 }
116}
117
118impl fmt::Display for Report {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 let name = &self.dialect;
121 if self.findings.is_empty() {
122 return writeln!(
123 f,
124 "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
125 );
126 }
127 let blocking = self.count(Severity::Blocking);
128 let unanswered = self.count(Severity::Unanswered);
129 let notes = self.count(Severity::Note);
130 writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
131 for finding in &self.findings {
132 writeln!(f)?;
133 writeln!(f, " [{}] {}", finding.severity, finding.what)?;
134 writeln!(f, " → {}", finding.fix)?;
135 }
136 Ok(())
137 }
138}
139
140#[must_use]
147pub fn check(dialect: &dyn PlatformDialect) -> Report {
148 check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
149}
150
151#[must_use]
154pub fn check_with_registry(
155 dialect: &dyn PlatformDialect,
156 registry: &crate::dialect::PlatformRegistry,
157) -> Report {
158 let info = dialect.info();
159 let mut findings = Vec::new();
160
161 check_registration(&info, registry, &mut findings);
162 check_family(info.dialect_family, &mut findings);
163 check_required_rendering(dialect, &mut findings);
164 check_decisions(dialect, &mut findings);
165 check_formula_renderer(dialect, &mut findings);
166 check_evolution(dialect, &mut findings);
167 check_run_again(dialect, &mut findings);
168 check_transactions(dialect, &mut findings);
169 check_paired_facts(dialect, &mut findings);
170 check_schema_fold(dialect, &mut findings);
171 check_deploy_client(&info, &mut findings);
172
173 findings.sort_by_key(|f| f.severity);
174 Report { dialect: info.name.to_owned(), findings }
175}
176
177fn check_registration(
188 info: &crate::dialect::DialectInfo,
189 registry: &crate::dialect::PlatformRegistry,
190 findings: &mut Vec<Finding>,
191) {
192 let name = info.name;
193 if matches!(
194 crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
195 Err(crate::ResolveError::UnknownDialect { .. })
196 ) {
197 findings.push(Finding {
198 severity: Severity::Blocking,
199 what: format!(
200 "the name `{name}` does not resolve — nothing in this program registered it"
201 ),
202 fix: "add `razeri_plugin_api::export_plugin!(register);` at the top of your \
203 crate's `lib.rs`, where `register` calls \
204 `builder.register_dialect(Arc::new(MyDialect))`. The one line makes the \
205 dialect one the registry finds, whether razeri loads your library from a \
206 plugin directory or links your crate in."
207 .to_owned(),
208 });
209 return;
210 }
211 for spelling in info.spellings() {
212 match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
213 Ok(found) if found.info().name == name => {}
214 Ok(found) => findings.push(Finding {
215 severity: Severity::Blocking,
216 what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
217 fix: format!(
218 "the registry holds `{name}` under another key. Register it under its own \
219 name, or drop `{spelling}` from its aliases."
220 ),
221 }),
222 Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
223 severity: Severity::Blocking,
224 what: format!(
225 "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
226 none of them — Razeri refuses that spelling rather than guessing which one \
227 a design meant",
228 claimed_by.join(" and ")
229 ),
230 fix: format!(
231 "give this dialect a name no other platform answers to. A platform name is \
232 read off the loaded dialects' own declared spellings, so a spelling another \
233 platform already claims is never `{name}`'s."
234 ),
235 }),
236 Err(_) => findings.push(Finding {
237 severity: Severity::Blocking,
238 what: format!(
239 "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
240 ),
241 fix: format!(
242 "the `{name}` in the registry is a different build of this dialect and does \
243 not declare `{spelling}`. Register the one you are grading."
244 ),
245 }),
246 }
247 }
248}
249
250fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
253 if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
254 findings.push(Finding {
255 severity: Severity::Blocking,
256 what: "this dialect's family is a custom one with no name".to_owned(),
257 fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
258 prints and what a reader looks up."
259 .to_owned(),
260 });
261 }
262}
263
264fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
266 let declarable = declarable_types();
267 let empty_column: Vec<String> = declarable
268 .iter()
269 .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
270 .map(|dt| format!("{dt:?}"))
271 .collect();
272 if !empty_column.is_empty() {
273 findings.push(Finding {
274 severity: Severity::Blocking,
275 what: format!(
276 "`render_data_type` answers nothing for {} of the types a design can declare",
277 empty_column.len()
278 ),
279 fix: "every declarable type needs a column spelling on this engine, even if the \
280 spelling is a wider type that stores it. A blank answer becomes a column \
281 declaration with no type in it."
282 .to_owned(),
283 });
284 }
285 let empty_cast: Vec<String> = declarable
286 .iter()
287 .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
288 .map(|dt| format!("{dt:?}"))
289 .collect();
290 if !empty_cast.is_empty() {
291 findings.push(Finding {
292 severity: Severity::Blocking,
293 what: format!(
294 "`render_cast_type` answers nothing for {} declarable types",
295 empty_cast.len()
296 ),
297 fix: "the generated load opens by casting every source column to its declared type, \
298 so a blank answer fails the load on its first statement."
299 .to_owned(),
300 });
301 }
302 let quoted = SqlRenderer::quote_identifier(dialect, "order");
303 if quoted == "order" {
304 findings.push(Finding {
305 severity: Severity::Blocking,
306 what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
307 reaches this engine bare"
308 .to_owned(),
309 fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
310 backticks, or brackets."
311 .to_owned(),
312 });
313 }
314 if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
315 findings.push(Finding {
316 severity: Severity::Blocking,
317 what: "`render_current_timestamp` answers nothing, and every generated load stamps \
318 a row with it"
319 .to_owned(),
320 fix: "return this engine's own expression for the current moment.".to_owned(),
321 });
322 }
323}
324
325fn check_encoded_file_relation(
337 dialect: &dyn PlatformDialect,
338 probe: &str,
339 findings: &mut Vec<Finding>,
340) {
341 let Some(relation) =
342 SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
343 else {
344 return;
345 };
346 if !relation.contains(probe) {
347 findings.push(Finding {
348 severity: Severity::Blocking,
349 what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
350 the path it was given"
351 .to_owned(),
352 fix: "put the literal you were handed where your engine's file relation takes a \
353 path. It arrives already quoted and escaped."
354 .to_owned(),
355 });
356 }
357 if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
358 findings.push(Finding {
359 severity: Severity::Blocking,
360 what: "this dialect states how to read a file in a named encoding and states no way \
361 to read a file at all"
362 .to_owned(),
363 fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
364 first question before the second — a source whose bytes are UTF-8 never \
365 reaches the encoding arm — so an engine that answers only the second has \
366 written a relation nothing will ask it for."
367 .to_owned(),
368 });
369 }
370}
371
372fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
374 let spelling = SqlRenderer::sid_spelling(dialect);
375 if spelling.platform == UNKNOWN_PLATFORM {
376 findings.push(Finding {
377 severity: Severity::Blocking,
378 what: "this dialect states no surrogate-key spelling, so every key it renders calls \
379 an `xxhash64` its engine very likely does not have"
380 .to_owned(),
381 fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
382 TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
383 hash. If it has no portable hash, name an artifact your prerequisites install \
384 — never the nearest-looking built-in, which keys a warehouse to a number \
385 nothing else can reproduce."
386 .to_owned(),
387 });
388 }
389
390 let join = match spelling.concat {
394 Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
395 Concat::Plus => "+",
396 };
397 if join.trim().is_empty() {
398 findings.push(Finding {
399 severity: Severity::Blocking,
400 what: "this dialect's surrogate-key spelling joins the parts of a composite key \
401 with a function or operator that has no name"
402 .to_owned(),
403 fix: "name what your engine joins strings with in `SidSpelling::concat`: \
404 `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
405 takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
406 operator and no function."
407 .to_owned(),
408 });
409 }
410
411 if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
416 && supplied.body.trim().is_empty()
417 {
418 findings.push(Finding {
419 severity: Severity::Blocking,
420 what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
421 and then supplies an empty one"
422 .to_owned(),
423 fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
424 one argument, or answer `None` and let Razeri compose the derivation from \
425 your `sid_spelling`. An empty body deploys without error and keys every row \
426 to nothing."
427 .to_owned(),
428 });
429 }
430
431 let probe = "'/razeri/probe.csv'";
434 if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
435 && !relation.contains(probe)
436 {
437 findings.push(Finding {
438 severity: Severity::Blocking,
439 what: "`delimited_file_relation` returns a relation that does not carry the path it \
440 was given"
441 .to_owned(),
442 fix: "put the literal you were handed where your engine's file relation takes a \
443 path. It arrives already quoted and escaped. A relation that drops it reads \
444 one file for every entity the design binds, and the generated fill loads the \
445 wrong rows without failing."
446 .to_owned(),
447 });
448 }
449
450 check_encoded_file_relation(dialect, probe, findings);
451 check_routine_forms(dialect, findings);
452 check_slice_replacement(dialect, findings);
453
454 if let ConstraintEnforcement::DeclaredOnly(declared) =
457 DdlRenderer::constraint_enforcement(dialect)
458 && declared.note.split_whitespace().count() < 5
459 {
460 findings.push(Finding {
461 severity: Severity::Blocking,
462 what: "this dialect says its engine accepts constraints it does not enforce, and \
463 the note explaining that says almost nothing"
464 .to_owned(),
465 fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
466 guarantee. Say what this engine really does with the declaration, and where \
467 the guarantee has to come from instead."
468 .to_owned(),
469 });
470 }
471
472 match DialectCapabilities::merge_shape(dialect) {
478 MergeShape::None => findings.push(Finding {
479 severity: Severity::Unanswered,
480 what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
481 load for it"
482 .to_owned(),
483 fix: "if this engine has a MERGE or an upsert statement, say which in \
484 `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
485 `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
486 operator can act on, rather than a silence."
487 .to_owned(),
488 }),
489 MergeShape::Unsupported(orientation) => {
490 check_refusal_copy("the upsert refusal", orientation, findings);
491 }
492 MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
493 _ => {}
494 }
495}
496
497fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
512 const VALUE: &str = "$razeri_slice_probe";
513 let plan = StoragePlan {
514 partition: PartitionStrategy::RangeByTimeDimension {
515 field: "razeri_probe_day".to_owned(),
516 granularity: PartitionGranularity::Day,
517 retention: None,
518 look_ahead: None,
519 window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
520 },
521 compression: CompressionStrategy::None,
522 index: IndexStrategy::None,
523 };
524 let day = DataType::date();
525 let columns = ["razeri_probe_day"];
526 let entity = EntityRef {
527 schema: Some("razeri_probe"),
528 name: "razeri_probe_table",
529 is_identity_table: false,
530 columns: &columns,
531 partition_column_type: Some(&day),
532 };
533 let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
534 let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
535 else {
536 return;
537 };
538
539 let carries = match &answer {
540 SliceReplacement::Emptied(statements) => {
541 !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
542 }
543 SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
544 };
545 if carries {
551 return;
552 }
553 findings.push(Finding {
554 severity: Severity::Blocking,
555 what: "`render_slice_replacement` returns a statement that does not carry the slice it \
556 was handed"
557 .to_owned(),
558 fix: "put the value you were given where your statement names the slice, and build any \
559 partition NAME from it the same way your extend procedure built it. A replacement \
560 that drops the value clears a slice this call was not given — or every slice — \
561 and the load reports success over what it emptied."
562 .to_owned(),
563 });
564}
565
566fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
583 let answers = DialectCapabilities::evolution_answers(dialect);
584 let boolean = DataType::boolean();
585 let long = DataType::varchar(40);
586
587 if DdlRenderer::render_column_evolution(
588 dialect,
589 &ColumnEvolution::AddColumn {
590 schema: Some("s"),
591 table: "t",
592 column: "c",
593 data_type: &boolean,
594 },
595 )
596 .is_none()
597 {
598 findings.push(Finding {
599 severity: Severity::Blocking,
600 what: "this dialect writes no statement for a column that is new, which is the \
601 commonest change a deployed warehouse ever sees"
602 .to_owned(),
603 fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
604 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
605 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
606 engine that genuinely has no way to add a column is worth saying out loud in \
607 the crate's own documentation."
608 .to_owned(),
609 });
610 }
611
612 if answers.parameterized_string_widening_is_lossless
613 && DdlRenderer::render_column_evolution(
614 dialect,
615 &ColumnEvolution::WidenColumn {
616 schema: Some("s"),
617 table: "t",
618 column: "c",
619 data_type: &long,
620 },
621 )
622 .is_none()
623 {
624 findings.push(Finding {
625 severity: Severity::Note,
626 what: "this dialect says its engine widens a string's declared length in place, and \
627 writes no statement for the change"
628 .to_owned(),
629 fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
630 engine's spelling of it. Without it Razeri tells an operator the widening \
631 costs nothing and then hands them nothing to run, which reads as Razeri not \
632 having finished rather than as a statement they own."
633 .to_owned(),
634 });
635 }
636
637 if answers.nested_field_addition_is_lossless
638 && DdlRenderer::render_column_evolution(
639 dialect,
640 &ColumnEvolution::AddNestedField {
641 schema: Some("s"),
642 table: "t",
643 column: "c",
644 field: "f",
645 data_type: &long,
646 },
647 )
648 .is_none()
649 {
650 findings.push(Finding {
651 severity: Severity::Note,
652 what: "this dialect says its engine keeps every value when a record gains a field, \
653 and writes no statement for the change"
654 .to_owned(),
655 fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
656 engines name the new field; some restate the whole column's type, which is \
657 why the change carries both the field's path and the column's type with the \
658 field in it."
659 .to_owned(),
660 });
661 }
662}
663
664fn check_run_again(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
673 let schema = SqlRenderer::quote_identifier(dialect, "razeri_probe_schema");
674 let name = SqlRenderer::quote_identifier(dialect, "razeri_probe_table");
675 let relation = format!("{schema}.{name}");
676 let table = WrittenObject::new(Some(&schema), &name, &relation);
677 let column = SqlRenderer::quote_identifier(dialect, "razeri_probe_column");
678 let mut lost: Vec<&str> = Vec::new();
679
680 match DdlRenderer::render_drop_column(dialect, table, &column, false) {
681 Some(drop) if drop.text().contains(&column) => {}
682 _ => lost.push("render_drop_column"),
683 }
684 if let Some(guarded) = DdlRenderer::render_drop_column(dialect, table, &column, true)
685 && !guarded.text().contains(&column)
686 {
687 lost.push("render_drop_column (only where it stands)");
688 }
689 for only_where_it_stands in [false, true] {
690 if let Some(drop) = DdlRenderer::render_drop_view(dialect, table, only_where_it_stands)
691 && !drop.text().contains(&name)
692 {
693 lost.push("render_drop_view");
694 }
695 }
696 let create = format!("CREATE TABLE {relation} (a INTEGER)");
697 if let Some(guarded) = DdlRenderer::render_create_table_if_absent(dialect, table, &create)
698 && !guarded.text().contains(&create)
699 {
700 lost.push("render_create_table_if_absent");
701 }
702 lost.dedup();
703 if !lost.is_empty() {
704 findings.push(Finding {
705 severity: Severity::Blocking,
706 what: format!(
707 "{} returns a statement that does not carry the object it was handed",
708 lost.join(", ")
709 ),
710 fix: "put the names you were given where your statement names the object, and keep \
711 a create you guard whole inside the guard. A drop that loses its column drops \
712 another one or none, and a guard that loses its statement makes nothing — \
713 both under a run that reports success."
714 .to_owned(),
715 });
716 }
717
718 let value = "razeri_probe_value";
721 let loses_its_value = declarable_types().iter().any(|data_type| {
722 [HeldAs::Declared, HeldAs::Converted].into_iter().any(|held| {
723 ExpressionRenderer::render_comparable(dialect, value, data_type, held)
724 .is_some_and(|compared| !compared.contains(value))
725 })
726 });
727 if loses_its_value {
728 findings.push(Finding {
729 severity: Severity::Blocking,
730 what: "`render_comparable` answers an expression that does not carry the value it \
731 stands for"
732 .to_owned(),
733 fix: "answer the value itself where your engine compares the type directly, an \
734 expression over it where it compares one only that way, and `None` where it \
735 cannot compare one at all — then Razeri refuses such a column by name."
736 .to_owned(),
737 });
738 }
739
740 if let Some(limits) = DialectCapabilities::text_limits(dialect)
743 && (limits.longest_varchar == Some(0)
744 || limits.text_is_not.is_some_and(|what| what.trim().is_empty()))
745 {
746 findings.push(Finding {
747 severity: Severity::Blocking,
748 what: "`text_limits` states a longest varchar of 0, or a blank word for what an \
749 unbounded text cannot be"
750 .to_owned(),
751 fix: "state the longest varchar(n) your engine creates — one more is refused at \
752 CREATE TABLE — and name what it refuses a text column as, or leave it None."
753 .to_owned(),
754 });
755 }
756
757 match DialectCapabilities::catalogue_reach(dialect) {
758 CatalogueReach::HeldColumnsOnly(reason) | CatalogueReach::Nothing(reason) => {
759 check_refusal_copy("the reason the schema check is not asked", reason, findings);
760 }
761 _ => {}
762 }
763}
764
765fn check_transactions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
769 if SqlRenderer::render_procedure_log(
772 dialect,
773 razeri_plugin_contract::ProcedureLogLevel::Info,
774 "'razeri'",
775 )
776 .is_some_and(|line| line.trim().is_empty())
777 {
778 findings.push(Finding {
779 severity: Severity::Blocking,
780 what: "`render_procedure_log` answers a log line with no text in it".to_owned(),
781 fix: "answer `None` where your engine has no statement that writes a message: the \
782 line is then left out of the routine."
783 .to_owned(),
784 });
785 }
786 if DialectCapabilities::begin_leaves_the_commit_to_the_client(dialect)
787 && !DialectCapabilities::supports_multi_statement_transactions(dialect)
788 {
789 findings.push(Finding {
790 severity: Severity::Blocking,
791 what: "this dialect says its begin statement leaves the commit to the client, and \
792 that its engine has no transaction spanning statements"
793 .to_owned(),
794 fix: "answer `begin_leaves_the_commit_to_the_client` only for an engine whose loads \
795 run in one transaction. Where there is none, the bundle already tells the \
796 operator the boundary is theirs."
797 .to_owned(),
798 });
799 }
800}
801
802fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
813 if restrictions.slice_pruning_needs_literal {
814 findings.push(Finding {
815 severity: Severity::Note,
816 what: "this dialect says its planner reads one slice of a partitioned table only \
817 from a literal, so a load scoped to one day cannot simply bind the day"
818 .to_owned(),
819 fix: "Razeri will put the day into the statement text through this engine's \
820 dynamic-execution form rather than binding it to the load procedure's \
821 parameter. Confirm that is what your engine wants: the statement is composed \
822 once per call and its text differs per day, which some planners cache and \
823 some do not. If a bound parameter does prune here, drop the restriction and \
824 Razeri binds it like everywhere else."
825 .to_owned(),
826 });
827 }
828}
829
830fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
833 let bitwise = BitwiseOperator::ALL.map(|operator| {
837 (
838 format!("render_bitwise_operator({operator:?})"),
839 FormulaRenderer::render_bitwise_operator(dialect, operator),
840 )
841 });
842 let blank: Vec<String> = [
843 ("render_power_operator".to_owned(), FormulaRenderer::render_power_operator(dialect)),
844 ("render_ilike_operator".to_owned(), FormulaRenderer::render_ilike_operator(dialect)),
845 (
846 "render_not_ilike_operator".to_owned(),
847 FormulaRenderer::render_not_ilike_operator(dialect),
848 ),
849 ("render_concat_operator".to_owned(), FormulaRenderer::render_concat_operator(dialect)),
850 ]
851 .into_iter()
852 .chain(bitwise)
853 .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
854 .map(|(method, _)| method)
855 .collect();
856 if !blank.is_empty() {
857 findings.push(Finding {
858 severity: Severity::Blocking,
859 what: format!("{} answers an operator with no text in it", blank.join(", ")),
860 fix: "answer `None` where your engine has no such operator: a formula that needs it \
861 is then refused by name, and the reader is told to write the function your \
862 engine has. An empty answer writes the two operands side by side."
863 .to_owned(),
864 });
865 }
866
867 let formula = SqlRenderer::to_formula_renderer(dialect);
868 let dialect_name = SqlRenderer::dialect_name(dialect);
869 if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
870 findings.push(Finding {
871 severity: Severity::Blocking,
872 what: "`to_formula_renderer` was not overridden, so every formula in every design \
873 renders through Razeri's ANSI defaults instead of this dialect"
874 .to_owned(),
875 fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
876 formula this engine sees is silently somebody else's."
877 .to_owned(),
878 });
879 }
880}
881
882fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
888 match SqlRenderer::procedure_form(dialect) {
889 ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
893 ProcedureForm::Agnostic => findings.push(Finding {
894 severity: Severity::Unanswered,
895 what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
896 annotated form — which no engine parses"
897 .to_owned(),
898 fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
899 or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
900 offers instead."
901 .to_owned(),
902 }),
903 ProcedureForm::Unsupported(orientation) => {
904 check_refusal_copy("the stored-procedure refusal", orientation, findings);
905 if DialectCapabilities::supports_stored_procedures(dialect) {
906 findings.push(Finding {
907 severity: Severity::Blocking,
908 what: "this dialect says its engine has stored procedures, and its \
909 procedure form refuses to write one"
910 .to_owned(),
911 fix: "decide which is true. If the engine has none, leave \
912 `DialectCapabilities::supports_stored_procedures` at `false`; if it \
913 has them, answer `procedure_form` with the grammar it speaks."
914 .to_owned(),
915 });
916 }
917 }
918 ProcedureForm::Custom(grammar) => check_custom_grammar(
919 CustomGrammar {
920 kind: RoutineKind::Procedure,
921 grammar,
922 form_method: "procedure_form",
923 render_method: "render_procedure",
924 noun: "procedures",
925 },
926 |spec| SqlRenderer::render_procedure(dialect, spec),
927 findings,
928 ),
929 _ => {}
930 }
931 check_procedures_are_used(dialect, findings);
932
933 match SqlRenderer::scalar_function_form(dialect) {
934 ScalarFunctionForm::Unsupported(orientation) => {
935 check_refusal_copy("the scalar-function refusal", orientation, findings);
936 }
937 ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
938 CustomGrammar {
939 kind: RoutineKind::ScalarFunction,
940 grammar,
941 form_method: "scalar_function_form",
942 render_method: "render_scalar_function",
943 noun: "scalar functions",
944 },
945 |spec| SqlRenderer::render_scalar_function(dialect, spec),
946 findings,
947 ),
948 _ => {}
949 }
950
951 match SqlRenderer::table_function_form(dialect) {
952 TableFunctionForm::Unsupported(orientation) => {
953 check_refusal_copy("the table-function refusal", orientation, findings);
954 }
955 TableFunctionForm::Custom(grammar) => check_custom_grammar(
956 CustomGrammar {
957 kind: RoutineKind::TableFunction,
958 grammar,
959 form_method: "table_function_form",
960 render_method: "render_table_function",
961 noun: "table functions",
962 },
963 |spec| SqlRenderer::render_table_function(dialect, spec),
964 findings,
965 ),
966 _ => {}
967 }
968
969 check_parameter_type_spelling(dialect, findings);
970}
971
972fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
984 let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
985 return;
986 };
987 if DialectCapabilities::supports_stored_procedures(dialect) {
988 return;
989 }
990 findings.push(Finding {
991 severity: Severity::Blocking,
992 what: format!(
993 "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
994 load is ever written as one"
995 ),
996 fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
997 it before it writes a load as a procedure, and its default is `false`."
998 .to_owned(),
999 });
1000}
1001
1002fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1017 let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
1018 || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
1019 || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
1020 if !spells_its_own {
1021 return;
1022 }
1023 let qualified = DataType::varchar(100);
1026 let column = SqlRenderer::render_data_type(dialect, &qualified);
1027 let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
1028 if column == parameter && column.contains('(') {
1029 findings.push(Finding {
1030 severity: Severity::Note,
1031 what: format!(
1032 "a parameter of this routine's would be declared `{parameter}`, the same as a \
1033 column of that type"
1034 ),
1035 fix: "that is right for most engines, so this is only worth a look. If yours refuses \
1036 a length or a precision in a parameter list — Oracle does, and answers \
1037 PLS-00103 at the opening parenthesis — override \
1038 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
1039 declarations keep the length either way."
1040 .to_owned(),
1041 });
1042 }
1043}
1044
1045#[derive(Clone, Copy)]
1047struct CustomGrammar {
1048 kind: RoutineKind,
1050 grammar: &'static str,
1052 form_method: &'static str,
1054 render_method: &'static str,
1056 noun: &'static str,
1058}
1059
1060fn check_custom_grammar(
1077 claim: CustomGrammar,
1078 render: impl Fn(&RoutineSpec) -> Option<String>,
1079 findings: &mut Vec<Finding>,
1080) {
1081 let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
1082 if grammar.trim().is_empty() {
1085 findings.push(Finding {
1086 severity: Severity::Blocking,
1087 what: format!("`{form_method}` says Custom, and names no grammar"),
1088 fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
1089 of a report, and of the comment Razeri writes where a routine could not be \
1090 rendered, is told this engine speaks."
1091 .to_owned(),
1092 });
1093 }
1094
1095 let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
1096 probe.or_replace = true;
1097 if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
1098 findings.push(Finding {
1099 severity: Severity::Blocking,
1100 what: format!(
1101 "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
1102 itself — and `{render_method}` returns nothing"
1103 ),
1104 fix: format!(
1105 "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
1106 composed — name, parameters with their types already in your spelling, body, \
1107 comment — and returns the whole CREATE statement your engine parses. Razeri \
1108 emits what you return and writes nothing of its own: a near-neighbour grammar \
1109 fails on deploy, and a refusal would say this engine has no {noun} when it \
1110 has them in a grammar of its own."
1111 ),
1112 });
1113 }
1114}
1115
1116fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
1118 if copy.trim().is_empty() {
1119 findings.push(Finding {
1120 severity: Severity::Blocking,
1121 what: format!("{subject} has no words in it"),
1122 fix: "say what this engine offers instead. A reader who hits a refusal needs the \
1123 next thing to try, and it has to be about their engine."
1124 .to_owned(),
1125 });
1126 return;
1127 }
1128 if copy.split_whitespace().count() < 5 {
1129 findings.push(Finding {
1130 severity: Severity::Note,
1131 what: format!("{subject} is very short"),
1132 fix: "a refusal earns its place by naming the alternative — what this engine has \
1133 instead of the thing it cannot do."
1134 .to_owned(),
1135 });
1136 }
1137}
1138
1139fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1141 let caps: &dyn DialectCapabilities = dialect;
1142 let ren: &dyn SqlRenderer = dialect;
1143 let ddl: &dyn DdlRenderer = dialect;
1144 let mut disagreements: Vec<&'static str> = Vec::new();
1145
1146 macro_rules! pair {
1147 ($m:ident, $renderer_value:expr) => {
1148 if caps.$m() != $renderer_value {
1149 disagreements.push(stringify!($m));
1150 }
1151 };
1152 }
1153 pair!(supports_on_conflict, ren.supports_on_conflict());
1154 pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1155 pair!(supports_update_from, ren.supports_update_from());
1156 pair!(supports_named_windows, ren.supports_named_windows());
1157 pair!(supports_compound_assignment, ren.supports_compound_assignment());
1158 pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1159 pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1160 pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1161 pair!(uses_alter_table_update, ren.uses_alter_table_update());
1162 pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1163 pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1164 pair!(supports_sync_drop, ddl.supports_sync_drop());
1165 pair!(supports_force_drop, ddl.supports_force_drop());
1166
1167 if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1176 disagreements.push("supports_table_functions");
1177 }
1178 if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1181 disagreements.push("supports_stored_functions");
1182 }
1183
1184 if !disagreements.is_empty() {
1185 findings.push(Finding {
1186 severity: Severity::Blocking,
1187 what: format!(
1188 "this dialect declares {} {} two ways and the two disagree: {}",
1189 disagreements.len(),
1190 if disagreements.len() == 1 { "fact" } else { "facts" },
1191 disagreements.join(", ")
1192 ),
1193 fix: "each of these is declared once on the capability trait and once in the \
1194 renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1195 and move the other one to match."
1196 .to_owned(),
1197 });
1198 }
1199}
1200
1201fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1210 let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1211 let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1212 return;
1213 };
1214 if DialectCapabilities::supports_schemas(dialect) {
1215 findings.push(Finding {
1216 severity: Severity::Blocking,
1217 what: "this dialect folds a schema into an object's name on an engine it says has \
1218 schemas"
1219 .to_owned(),
1220 fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1221 schemas, and only there is it asked; or answer \
1222 `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1223 .to_owned(),
1224 });
1225 return;
1226 }
1227 let expected = SqlRenderer::quote_identifier(dialect, &folded);
1228 let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1229 if qualified != expected {
1230 findings.push(Finding {
1231 severity: Severity::Blocking,
1232 what: format!(
1233 "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1234 the same object `{qualified}`"
1235 ),
1236 fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1237 schema. Razeri names some objects through it and the rest through the fold, \
1238 so a difference creates a table under one name and reads it under another."
1239 .to_owned(),
1240 });
1241 }
1242}
1243
1244fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1251 let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1252 return;
1253 };
1254 if client.command.trim().is_empty() {
1255 findings.push(Finding {
1256 severity: Severity::Blocking,
1257 what: "this dialect describes a deploy client with no command".to_owned(),
1258 fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1259 });
1260 }
1261 let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1262 .into_iter()
1263 .filter_map(|(field, args)| {
1264 args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1265 })
1266 .collect();
1267 if !unnamed.is_empty() {
1268 findings.push(Finding {
1269 severity: Severity::Blocking,
1270 what: format!(
1271 "the deploy client's {} never names the file to run",
1272 unnamed.join(" and ")
1273 ),
1274 fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1275 inside an argument such as `--file={file}`. The script writes the file there \
1276 and nowhere else."
1277 .to_owned(),
1278 });
1279 }
1280 if let Some(terminator) = client.block_terminator
1281 && [terminator.open, terminator.end, terminator.close]
1282 .iter()
1283 .any(|part| part.trim().is_empty())
1284 {
1285 findings.push(Finding {
1286 severity: Severity::Blocking,
1287 what: "the deploy client's block terminator leaves a part blank".to_owned(),
1288 fix: "name the line before a routine, what ends it, and the line after it — \
1289 `SET TERM ^ ;`, `^` and `SET TERM ; ^` for Firebird's isql. A blank end leaves \
1290 the routine open, and the next statement is read into it."
1291 .to_owned(),
1292 });
1293 }
1294}
1295
1296fn declarable_types() -> Vec<DataType> {
1304 vec![
1305 DataType::tinyint(),
1306 DataType::smallint(),
1307 DataType::int(),
1308 DataType::bigint(),
1309 DataType::double(),
1310 DataType::boolean(),
1311 DataType::text(),
1312 DataType::varchar(200),
1313 DataType::uuid(),
1314 DataType::json(),
1315 DataType::date(),
1316 DataType::timestamp(),
1317 DataType::timestamp_tz(),
1318 DataType::decimal(18, 2),
1319 DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1320 DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1321 DataType::Struct(StructDataType::new(vec![
1322 StructField::new("street", DataType::varchar(200)),
1323 StructField::new("zip", DataType::varchar(10)),
1324 ])),
1325 DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1326 StructField::new("key", DataType::varchar(100)),
1327 StructField::new("value", DataType::varchar(1000)),
1328 ])))),
1329 ]
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335 use crate::dialect::DialectInfo;
1336 use razeri_plugin_contract::{
1337 Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1338 ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1339 TaskRenderer, TextCast,
1340 };
1341 use std::marker::PhantomData;
1342
1343 struct BrokenPlugin;
1351
1352 impl SqlRenderer for BrokenPlugin {
1353 fn render_data_type(&self, _dt: &DataType) -> String {
1354 String::new()
1355 }
1356 fn quote_identifier(&self, name: &str) -> String {
1357 name.to_owned()
1358 }
1359 fn render_cast_type(&self, _dt: &DataType) -> String {
1360 String::new()
1361 }
1362 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1363 name.to_owned()
1364 }
1365 fn render_current_timestamp(&self) -> String {
1366 String::new()
1367 }
1368 fn dialect_name(&self) -> &'static str {
1369 "brokendb"
1370 }
1371 fn supports_on_conflict(&self) -> bool {
1374 true
1375 }
1376 }
1377 impl ExpressionRenderer for BrokenPlugin {}
1378 impl DdlRenderer for BrokenPlugin {}
1379 impl DmlRenderer for BrokenPlugin {}
1380 impl ProcedureRenderer for BrokenPlugin {}
1381 impl FormulaRenderer for BrokenPlugin {}
1382 impl TaskRenderer for BrokenPlugin {}
1383 impl ReplicationRenderer for BrokenPlugin {}
1384 impl StorageRenderer for BrokenPlugin {}
1385 impl DialectCapabilities for BrokenPlugin {
1386 fn supports_on_conflict(&self) -> bool {
1387 false
1388 }
1389 }
1390 impl PlatformDialect for BrokenPlugin {
1391 fn info(&self) -> DialectInfo {
1392 DialectInfo {
1393 name: "brokendb",
1394 display_name: "Broken DB",
1395 aliases: &[],
1396 dialect_family: DialectFamily::Custom(""),
1397 family: "Broken",
1398 is_cloud_native: false,
1399 is_olap: false,
1400 is_postgres_compatible: false,
1401 versions: &[],
1402 deploy_script: None,
1403 }
1404 }
1405 }
1406
1407 trait FixtureName: Send + Sync + 'static {
1416 const NAME: &'static str;
1418 const CONCAT: Concat = Concat::Function("CONCAT");
1422 const POWER: Option<&'static str> = Some("^");
1425 const SHIFT_LEFT: Option<&'static str> = Some("<<");
1427 const DROP_LOSES_ITS_COLUMN: bool = false;
1429 const COMPARES_SOMETHING_ELSE: bool = false;
1431 const BEGIN_WITHOUT_TRANSACTIONS: bool = false;
1434 const DEPLOY: Option<crate::dialect::DeployScript> = None;
1436 }
1437
1438 struct Unlinked;
1441 impl FixtureName for Unlinked {
1442 const NAME: &'static str = "healthydb";
1443 }
1444
1445 struct Outside;
1448 impl FixtureName for Outside {
1449 const NAME: &'static str = "outsidedb";
1450 }
1451
1452 struct BinaryConcat;
1457 impl FixtureName for BinaryConcat {
1458 const NAME: &'static str = "binaryconcatdb";
1459 const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1460 }
1461
1462 struct OperatorConcat;
1466 impl FixtureName for OperatorConcat {
1467 const NAME: &'static str = "operatorconcatdb";
1468 const CONCAT: Concat = Concat::Operator("||");
1469 }
1470
1471 struct NamelessConcat;
1473 impl FixtureName for NamelessConcat {
1474 const NAME: &'static str = "namelessconcatdb";
1475 const CONCAT: Concat = Concat::Operator(" ");
1476 }
1477
1478 struct NoPowerOperator;
1481 impl FixtureName for NoPowerOperator {
1482 const NAME: &'static str = "nopowerdb";
1483 const POWER: Option<&'static str> = None;
1484 }
1485
1486 struct BlankPowerOperator;
1488 impl FixtureName for BlankPowerOperator {
1489 const NAME: &'static str = "blankpowerdb";
1490 const POWER: Option<&'static str> = Some(" ");
1491 }
1492
1493 struct ComparesSomethingElse;
1495 impl FixtureName for ComparesSomethingElse {
1496 const NAME: &'static str = "comparesotherdb";
1497 const COMPARES_SOMETHING_ELSE: bool = true;
1498 }
1499
1500 struct DropLosesItsColumn;
1502 impl FixtureName for DropLosesItsColumn {
1503 const NAME: &'static str = "droplosesdb";
1504 const DROP_LOSES_ITS_COLUMN: bool = true;
1505 }
1506
1507 struct BeginWithoutTransactions;
1510 impl FixtureName for BeginWithoutTransactions {
1511 const NAME: &'static str = "beginwithouttxdb";
1512 const BEGIN_WITHOUT_TRANSACTIONS: bool = true;
1513 }
1514
1515 struct NoShiftOperator;
1517 impl FixtureName for NoShiftOperator {
1518 const NAME: &'static str = "noshiftdb";
1519 const SHIFT_LEFT: Option<&'static str> = None;
1520 }
1521
1522 struct BlankShiftOperator;
1524 impl FixtureName for BlankShiftOperator {
1525 const NAME: &'static str = "blankshiftdb";
1526 const SHIFT_LEFT: Option<&'static str> = Some("");
1527 }
1528
1529 struct DescribedClient;
1531 impl FixtureName for DescribedClient {
1532 const NAME: &'static str = "describedclientdb";
1533 const DEPLOY: Option<crate::dialect::DeployScript> =
1534 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1535 command: "sqlite3",
1536 run_file: &["-bail", "{target}", ".read {file}"],
1537 read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1538 block_terminator: None,
1539 }));
1540 }
1541
1542 struct FilelessClient;
1544 impl FixtureName for FilelessClient {
1545 const NAME: &'static str = "filelessclientdb";
1546 const DEPLOY: Option<crate::dialect::DeployScript> =
1547 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1548 command: "sqlite3",
1549 run_file: &["-bail", "{target}"],
1550 read_rows: None,
1551 block_terminator: None,
1552 }));
1553 }
1554
1555 struct BlankBlockEnd;
1557 impl FixtureName for BlankBlockEnd {
1558 const NAME: &'static str = "blankblockenddb";
1559 const DEPLOY: Option<crate::dialect::DeployScript> =
1560 Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1561 command: "isql",
1562 run_file: &["-bail", "-input", "{file}", "{target}"],
1563 read_rows: None,
1564 block_terminator: Some(crate::dialect::BlockTerminator {
1565 open: "SET TERM ^ ;",
1566 end: " ",
1567 close: "SET TERM ; ^",
1568 }),
1569 }));
1570 }
1571
1572 struct Answered<N: FixtureName>(PhantomData<N>);
1573
1574 impl<N: FixtureName> Answered<N> {
1575 const fn new() -> Self {
1576 Self(PhantomData)
1577 }
1578 }
1579
1580 type HealthyPlugin = Answered<Unlinked>;
1581 type OutsidePlugin = Answered<Outside>;
1582
1583 inventory::submit! {
1584 crate::builder::DialectRegistration::new(
1585 Outside::NAME,
1586 || std::sync::Arc::new(OutsidePlugin::new()),
1587 )
1588 }
1589
1590 impl<N: FixtureName> SqlRenderer for Answered<N> {
1591 fn render_data_type(&self, dt: &DataType) -> String {
1592 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1593 }
1594 fn quote_identifier(&self, name: &str) -> String {
1595 format!("\"{name}\"")
1596 }
1597 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1598 schema.map_or_else(
1599 || self.quote_identifier(name),
1600 |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1601 )
1602 }
1603 fn render_current_timestamp(&self) -> String {
1604 "NOW()".to_owned()
1605 }
1606 fn dialect_name(&self) -> &'static str {
1607 N::NAME
1608 }
1609 fn sid_spelling(&self) -> SidSpelling {
1610 SidSpelling {
1611 platform: N::NAME,
1612 hash: ("answered_xxhash64(", ")"),
1613 cast_type: TextCast::Direct("STRING"),
1614 concat: N::CONCAT,
1615 through_helper: false,
1616 ..razeri_plugin_contract::ANSI_SID_SPELLING
1617 }
1618 }
1619 fn procedure_form(&self) -> ProcedureForm {
1620 ProcedureForm::Unsupported(
1621 "This engine has no stored procedures; write the load as a scheduled query \
1622 instead.",
1623 )
1624 }
1625 fn scalar_function_form(&self) -> ScalarFunctionForm {
1626 ScalarFunctionForm::PostgreSql
1627 }
1628 fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1629 Box::new(Self::new())
1630 }
1631 }
1632 impl<N: FixtureName> ExpressionRenderer for Answered<N> {
1633 fn render_comparable(
1634 &self,
1635 value: &str,
1636 _data_type: &DataType,
1637 _held: HeldAs,
1638 ) -> Option<String> {
1639 Some(if N::COMPARES_SOMETHING_ELSE { "1".to_owned() } else { value.to_owned() })
1640 }
1641 }
1642 impl<N: FixtureName> DdlRenderer for Answered<N> {
1643 fn render_drop_column(
1644 &self,
1645 table: WrittenObject<'_>,
1646 column: &str,
1647 only_where_it_stands: bool,
1648 ) -> Option<razeri_plugin_contract::Written> {
1649 if N::DROP_LOSES_ITS_COLUMN {
1650 return Some(razeri_plugin_contract::Written::Statement(format!(
1651 "ALTER TABLE {} DROP COLUMN c",
1652 table.relation
1653 )));
1654 }
1655 let guard = if only_where_it_stands { " IF EXISTS" } else { "" };
1656 Some(razeri_plugin_contract::Written::Statement(format!(
1657 "ALTER TABLE {} DROP COLUMN{guard} {column}",
1658 table.relation
1659 )))
1660 }
1661 }
1662 impl<N: FixtureName> DmlRenderer for Answered<N> {}
1663 impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1664 impl<N: FixtureName> FormulaRenderer for Answered<N> {
1665 fn render_power_operator(&self) -> Option<String> {
1666 N::POWER.map(str::to_owned)
1667 }
1668 fn render_bitwise_operator(&self, operator: BitwiseOperator) -> Option<String> {
1669 match operator {
1670 BitwiseOperator::ShiftLeft => N::SHIFT_LEFT.map(str::to_owned),
1671 other => Some(other.standard_spelling().to_owned()),
1672 }
1673 }
1674 }
1675 impl<N: FixtureName> TaskRenderer for Answered<N> {}
1676 impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1677 impl<N: FixtureName> StorageRenderer for Answered<N> {}
1678 impl<N: FixtureName> DialectCapabilities for Answered<N> {
1679 fn begin_leaves_the_commit_to_the_client(&self) -> bool {
1680 N::BEGIN_WITHOUT_TRANSACTIONS
1681 }
1682 fn supports_multi_statement_transactions(&self) -> bool {
1683 !N::BEGIN_WITHOUT_TRANSACTIONS
1684 }
1685 fn merge_shape(&self) -> MergeShape {
1686 MergeShape::Merge(MergeRestrictions::none())
1687 }
1688 }
1689 impl<N: FixtureName> PlatformDialect for Answered<N> {
1690 fn info(&self) -> DialectInfo {
1691 DialectInfo {
1692 name: N::NAME,
1693 display_name: "An Answered Engine",
1694 aliases: &[],
1697 dialect_family: DialectFamily::Custom(N::NAME),
1698 family: "Answered",
1699 is_cloud_native: false,
1700 is_olap: false,
1701 is_postgres_compatible: false,
1702 versions: &[],
1703 deploy_script: N::DEPLOY,
1704 }
1705 }
1706 }
1707
1708 struct Twin;
1714
1715 impl SqlRenderer for Twin {
1716 fn render_data_type(&self, dt: &DataType) -> String {
1717 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1718 }
1719 fn quote_identifier(&self, name: &str) -> String {
1720 format!("\"{name}\"")
1721 }
1722 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1723 self.quote_identifier(name)
1724 }
1725 fn render_current_timestamp(&self) -> String {
1726 "NOW()".to_owned()
1727 }
1728 }
1729 impl ExpressionRenderer for Twin {}
1730 impl DdlRenderer for Twin {}
1731 impl DmlRenderer for Twin {}
1732 impl ProcedureRenderer for Twin {}
1733 impl FormulaRenderer for Twin {}
1734 impl TaskRenderer for Twin {}
1735 impl ReplicationRenderer for Twin {}
1736 impl StorageRenderer for Twin {}
1737 impl DialectCapabilities for Twin {}
1738 impl PlatformDialect for Twin {
1739 fn info(&self) -> DialectInfo {
1740 DialectInfo {
1741 name: "twindb",
1742 display_name: "Twin DB",
1743 aliases: &["healthydb"],
1745 dialect_family: DialectFamily::Custom("twindb"),
1746 family: "Twin",
1747 is_cloud_native: false,
1748 is_olap: false,
1749 is_postgres_compatible: false,
1750 versions: &[],
1751 deploy_script: None,
1752 }
1753 }
1754 }
1755
1756 fn mentions(report: &Report, needle: &str) -> bool {
1758 report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1759 }
1760
1761 #[test]
1765 fn the_kit_catches_a_deliberately_broken_plugin() {
1766 let report = check(&BrokenPlugin);
1767 assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1768
1769 assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1770 assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1771 assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1772 assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1773 assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1774 assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1775 assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1776 assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1777 assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1778 assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1779 assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1780
1781 assert!(
1782 report.count(Severity::Blocking) >= 8,
1783 "the broken plugin owes more than {} blocking fixes:\n{report}",
1784 report.count(Severity::Blocking)
1785 );
1786 }
1787
1788 #[test]
1792 fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1793 let report = check(&HealthyPlugin::new());
1794 let unregistered: Vec<&Finding> =
1795 report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1796 assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1797 assert_eq!(
1798 report.findings.len(),
1799 1,
1800 "a plugin that answered every question owes nothing else:\n{report}"
1801 );
1802 }
1803
1804 #[test]
1809 fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1810 let binary = Answered::<BinaryConcat>::new();
1811 let report = check(&binary);
1812 assert_eq!(
1813 report.findings.len(),
1814 check(&HealthyPlugin::new()).findings.len(),
1815 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1816 );
1817 assert!(
1818 !report.to_string().contains("joins"),
1819 "nothing about the join is outstanding:\n{report}"
1820 );
1821
1822 let key = razeri_plugin_contract::sid::sid_expression(
1823 SqlRenderer::sid_spelling(&binary),
1824 razeri_plugin_contract::sid::Backslash::of(&binary),
1825 None,
1826 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1827 );
1828 assert!(
1829 key.contains("CONCAT(CONCAT("),
1830 "a two-argument engine gets one call per pair:\n{key}"
1831 );
1832 }
1833
1834 #[test]
1838 fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
1839 let operator = Answered::<OperatorConcat>::new();
1840 let report = check(&operator);
1841 assert_eq!(
1842 report.findings.len(),
1843 check(&HealthyPlugin::new()).findings.len(),
1844 "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1845 );
1846 let key = razeri_plugin_contract::sid::sid_expression(
1847 SqlRenderer::sid_spelling(&operator),
1848 razeri_plugin_contract::sid::Backslash::of(&operator),
1849 None,
1850 &["a".to_owned(), "b".to_owned()],
1851 );
1852 assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
1853 assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
1854 }
1855
1856 #[test]
1859 fn a_join_that_names_nothing_must_be_fixed() {
1860 let report = check(&Answered::<NamelessConcat>::new());
1861 assert!(
1862 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1863 && finding.what.contains("joins the parts of a composite key")),
1864 "{report}"
1865 );
1866 assert!(
1867 !check(&Answered::<OperatorConcat>::new())
1868 .findings
1869 .iter()
1870 .any(|finding| finding.what.contains("joins the parts of a composite key")),
1871 "a named operator is not the finding"
1872 );
1873 }
1874
1875 #[test]
1879 fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
1880 let none = check(&Answered::<NoPowerOperator>::new());
1881 assert_eq!(
1882 none.findings.len(),
1883 check(&HealthyPlugin::new()).findings.len(),
1884 "saying the engine has no such operator owes nothing:\n{none}"
1885 );
1886 let blank = check(&Answered::<BlankPowerOperator>::new());
1887 assert!(
1888 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
1889 && finding.what.contains("render_power_operator")),
1890 "{blank}"
1891 );
1892 }
1893
1894 #[test]
1897 fn a_bitwise_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
1898 let none = check(&Answered::<NoShiftOperator>::new());
1899 assert_eq!(
1900 none.findings.len(),
1901 check(&HealthyPlugin::new()).findings.len(),
1902 "saying the engine has no left shift owes nothing:\n{none}"
1903 );
1904 let blank = check(&Answered::<BlankShiftOperator>::new());
1905 assert!(
1906 blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
1907 && finding.what.contains("render_bitwise_operator(ShiftLeft)")),
1908 "{blank}"
1909 );
1910 }
1911
1912 #[test]
1915 fn a_drop_that_loses_its_column_must_be_fixed() {
1916 let healthy = check(&HealthyPlugin::new());
1917 assert!(!healthy.to_string().contains("render_drop_column"), "{healthy}");
1918 let lost = check(&Answered::<DropLosesItsColumn>::new());
1919 assert!(
1920 lost.findings.iter().any(|finding| finding.severity == Severity::Blocking
1921 && finding.what.contains("render_drop_column")),
1922 "{lost}"
1923 );
1924 }
1925
1926 #[test]
1929 fn a_comparison_that_loses_its_value_must_be_fixed() {
1930 assert!(!check(&HealthyPlugin::new()).to_string().contains("render_comparable"));
1931 let report = check(&Answered::<ComparesSomethingElse>::new());
1932 assert!(
1933 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1934 && finding.what.contains("render_comparable")),
1935 "{report}"
1936 );
1937 }
1938
1939 #[test]
1943 fn a_begin_that_leaves_the_commit_needs_a_transaction() {
1944 let report = check(&Answered::<BeginWithoutTransactions>::new());
1945 assert!(
1946 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1947 && finding.what.contains("leaves the commit to the client")),
1948 "{report}"
1949 );
1950 }
1951
1952 #[test]
1954 fn a_block_terminator_with_a_blank_part_must_be_fixed() {
1955 let report = check(&Answered::<BlankBlockEnd>::new());
1956 assert!(
1957 report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1958 && finding.what.contains("block terminator")),
1959 "{report}"
1960 );
1961 }
1962
1963 #[test]
1966 fn a_deploy_client_has_to_be_told_which_file_to_run() {
1967 let described = check(&Answered::<DescribedClient>::new());
1968 assert_eq!(
1969 described.findings.len(),
1970 check(&HealthyPlugin::new()).findings.len(),
1971 "a client described in full owes nothing:\n{described}"
1972 );
1973 let fileless = check(&Answered::<FilelessClient>::new());
1974 assert!(
1975 fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
1976 && finding.what.contains("run_file never names the file")),
1977 "{fileless}"
1978 );
1979 }
1980
1981 #[test]
1985 fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
1986 struct Folder<const SCHEMAS: bool, const AGREES: bool>;
1987 impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
1988 fn render_data_type(&self, dt: &DataType) -> String {
1989 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1990 }
1991 fn quote_identifier(&self, name: &str) -> String {
1992 format!("\"{name}\"")
1993 }
1994 fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1995 match schema {
1996 Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
1997 Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
1998 None => self.quote_identifier(name),
1999 }
2000 }
2001 fn render_current_timestamp(&self) -> String {
2002 "CURRENT_TIMESTAMP".to_owned()
2003 }
2004 fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
2005 Some(format!("{schema}_{name}"))
2006 }
2007 }
2008 impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
2009 impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
2010 impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
2011 impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
2012 impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
2013 impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
2014 impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
2015 impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
2016 impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
2017 fn supports_schemas(&self) -> bool {
2018 S
2019 }
2020 }
2021 impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
2022 fn info(&self) -> DialectInfo {
2023 DialectInfo {
2024 name: "folderdb",
2025 display_name: "Folder DB",
2026 aliases: &[],
2027 dialect_family: DialectFamily::Custom("folderdb"),
2028 family: "Folder",
2029 is_cloud_native: false,
2030 is_olap: false,
2031 is_postgres_compatible: false,
2032 versions: &[],
2033 deploy_script: None,
2034 }
2035 }
2036 }
2037
2038 let fold_findings = |dialect: &dyn PlatformDialect| {
2039 let mut findings = Vec::new();
2040 check_schema_fold(dialect, &mut findings);
2041 findings
2042 };
2043 assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
2044 let disagrees = fold_findings(&Folder::<false, false>);
2045 assert_eq!(disagrees.len(), 1, "{disagrees:?}");
2046 assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
2047 let has_schemas = fold_findings(&Folder::<true, true>);
2048 assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
2049 assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
2050 }
2051
2052 #[test]
2061 fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
2062 let mut quiet = Vec::new();
2063 check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
2064 assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
2065
2066 let mut stated = Vec::new();
2067 check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
2068 assert_eq!(stated.len(), 1, "the stated restriction is reported once");
2069 assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
2070 assert!(
2071 stated[0].what.contains("literal"),
2072 "the finding names the restriction:\n{}",
2073 stated[0].what
2074 );
2075 assert!(
2076 stated[0].fix.contains("dynamic-execution"),
2077 "and says what Razeri does with the answer:\n{}",
2078 stated[0].fix
2079 );
2080
2081 let mut two_clauses = Vec::new();
2084 check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
2085 assert!(two_clauses.is_empty(), "one restriction is not the other");
2086 }
2087
2088 #[test]
2094 fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
2095 assert!(
2096 crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
2097 "the fixture registers through the registry, which is the whole path being graded"
2098 );
2099 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2103 assert!(
2104 registry
2105 .list()
2106 .iter()
2107 .filter(|info| info.name != Outside::NAME)
2108 .all(|info| !info.answers_to(Outside::NAME)),
2109 "the fixture's name must be one no other platform claims"
2110 );
2111
2112 let report = check(&OutsidePlugin::new());
2113 assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
2114 assert!(report.is_clean(), "and the report says so:\n{report}");
2115 assert_eq!(report.count(Severity::Blocking), 0, "{report}");
2116 assert!(
2117 report.to_string().contains("nothing to fix"),
2118 "a clean dialect is told it is clean:\n{report}"
2119 );
2120 }
2121
2122 #[test]
2128 fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
2129 let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
2130 for info in registry.list() {
2131 assert_eq!(
2132 info.spellings().first().copied(),
2133 Some(info.name),
2134 "a platform's own name leads its spellings"
2135 );
2136 assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
2137 }
2138 }
2139
2140 #[test]
2146 fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
2147 let mut registry = crate::dialect::PlatformRegistry::new();
2148 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2149 registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2150
2151 let report = check_with_registry(&Twin, ®istry);
2152 let collision = report
2153 .findings
2154 .iter()
2155 .find(|f| f.what.contains("claimed by more than one loaded platform"))
2156 .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
2157 assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
2158 assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
2159 assert!(collision.what.contains("twindb"), "{collision:?}");
2160 assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
2161 assert!(
2162 !mentions(&report, "does not resolve"),
2163 "a collision is not an unregistered platform:\n{report}"
2164 );
2165 }
2166
2167 #[test]
2171 fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
2172 let mut registry = crate::dialect::PlatformRegistry::new();
2173 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2174
2175 let mut findings = Vec::new();
2176 check_registration(&HealthyPlugin::new().info(), ®istry, &mut findings);
2177 assert!(findings.is_empty(), "{findings:?}");
2178
2179 for spelling in ["healthydb", "HealthyDB", " healthydb "] {
2180 assert!(
2181 crate::resolve::resolve_dialect_by_name_with_registry(spelling, ®istry).is_ok(),
2182 "'{spelling}' is the same platform"
2183 );
2184 }
2185 assert!(
2186 crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", ®istry).is_err()
2187 );
2188 }
2189
2190 #[test]
2195 fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
2196 let mut registry = crate::dialect::PlatformRegistry::new();
2197 registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2198
2199 let report = check_with_registry(&HealthyPlugin::new(), ®istry);
2200 assert!(
2201 report.is_clean(),
2202 "a loaded dialect that answered everything owes nothing:\n{report}"
2203 );
2204 }
2205
2206 #[test]
2210 fn no_fix_line_sends_an_author_into_razeris_own_tree() {
2211 let mut collided = crate::dialect::PlatformRegistry::new();
2212 collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
2213 collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
2214
2215 let mut fixes: Vec<String> = Vec::new();
2216 for report in [
2217 check(&BrokenPlugin),
2218 check(&HealthyPlugin::new()),
2219 check(&OutsidePlugin::new()),
2220 check_with_registry(&Twin, &collided),
2223 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2224 ] {
2225 fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
2226 }
2227 assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
2228 for fix in &fixes {
2229 for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
2230 assert!(
2231 !fix.contains(path),
2232 "a fix line names `{path}`, which is not in the author's crate:\n {fix}"
2233 );
2234 }
2235 }
2236 }
2237
2238 #[test]
2245 fn no_finding_holds_a_run_of_spaces() {
2246 struct Declining;
2247 impl SqlRenderer for Declining {
2248 fn render_data_type(&self, dt: &DataType) -> String {
2249 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2250 }
2251 fn quote_identifier(&self, name: &str) -> String {
2252 format!("\"{name}\"")
2253 }
2254 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2255 name.to_owned()
2256 }
2257 fn render_current_timestamp(&self) -> String {
2258 "CURRENT_TIMESTAMP".to_owned()
2259 }
2260 fn procedure_form(&self) -> ProcedureForm {
2261 ProcedureForm::Custom("Declining PSQL")
2262 }
2263 }
2264 impl ExpressionRenderer for Declining {}
2265 impl DdlRenderer for Declining {
2266 fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
2267 None
2268 }
2269 }
2270 impl DialectCapabilities for Declining {
2271 fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
2272 razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
2273 .and_a_nested_field()
2274 }
2275 }
2276 impl DmlRenderer for Declining {}
2277 impl ProcedureRenderer for Declining {}
2278 impl FormulaRenderer for Declining {}
2279 impl TaskRenderer for Declining {}
2280 impl ReplicationRenderer for Declining {}
2281 impl StorageRenderer for Declining {}
2282 impl PlatformDialect for Declining {
2283 fn info(&self) -> DialectInfo {
2284 DialectInfo {
2285 name: "decliningdb",
2286 display_name: "Declining DB",
2287 aliases: &[],
2288 dialect_family: DialectFamily::Custom("decliningdb"),
2289 family: "Declining",
2290 is_cloud_native: false,
2291 is_olap: false,
2292 is_postgres_compatible: false,
2293 versions: &[],
2294 deploy_script: None,
2295 }
2296 }
2297 }
2298
2299 let mut findings = Vec::new();
2300 check_evolution(&Declining, &mut findings);
2301 check_parameter_type_spelling(&Declining, &mut findings);
2302 assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
2303
2304 for report in [
2305 check(&BrokenPlugin),
2306 check(&HealthyPlugin::new()),
2307 check(&OutsidePlugin::new()),
2308 check(&Declining),
2309 check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
2310 ] {
2311 findings.extend(report.findings);
2312 }
2313 for finding in &findings {
2314 for text in [&finding.what, &finding.fix] {
2315 assert!(!text.contains(" "), "a finding holds a run of spaces:\n {text:?}");
2316 }
2317 }
2318 }
2319
2320 #[test]
2330 fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
2331 for (kind, form_method, render_method) in [
2332 (RoutineKind::Procedure, "procedure_form", "render_procedure"),
2333 (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
2334 (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
2335 ] {
2336 let mut promised = Vec::new();
2337 check_custom_grammar(
2338 CustomGrammar {
2339 kind,
2340 grammar: "PL/SQL",
2341 form_method,
2342 render_method,
2343 noun: "routines",
2344 },
2345 |_| None,
2346 &mut promised,
2347 );
2348 assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2349 assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2350 assert!(promised[0].what.contains(form_method), "{promised:?}");
2351 assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2352 assert!(promised[0].fix.contains(render_method), "{promised:?}");
2353
2354 let mut kept = Vec::new();
2356 check_custom_grammar(
2357 CustomGrammar {
2358 kind,
2359 grammar: "PL/SQL",
2360 form_method,
2361 render_method,
2362 noun: "routines",
2363 },
2364 |spec| Some(format!("CREATE {} …", spec.name)),
2365 &mut kept,
2366 );
2367 assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2368 }
2369 }
2370
2371 #[test]
2376 fn a_procedure_form_and_the_procedure_capability_must_agree() {
2377 fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2378 let mut findings = Vec::new();
2379 check_routine_forms(dialect, &mut findings);
2380 Report { dialect: "claimsdb".to_owned(), findings }
2381 }
2382
2383 struct Claims<const CUSTOM: bool, const HAS: bool>;
2386 impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2387 fn render_data_type(&self, dt: &DataType) -> String {
2388 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2389 }
2390 fn quote_identifier(&self, name: &str) -> String {
2391 format!("\"{name}\"")
2392 }
2393 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2394 self.quote_identifier(name)
2395 }
2396 fn render_current_timestamp(&self) -> String {
2397 "CURRENT_TIMESTAMP".to_owned()
2398 }
2399 fn procedure_form(&self) -> ProcedureForm {
2400 if CUSTOM {
2401 ProcedureForm::Custom("Firebird PSQL")
2402 } else {
2403 ProcedureForm::Unsupported(
2404 "This engine has no stored procedures; schedule the load as a query.",
2405 )
2406 }
2407 }
2408 fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2409 Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2410 }
2411 }
2412 impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2413 impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2414 impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2415 impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2416 impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2417 impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2418 impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2419 impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2420 impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2421 fn supports_stored_procedures(&self) -> bool {
2422 HAS
2423 }
2424 }
2425 impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2426 fn info(&self) -> DialectInfo {
2427 DialectInfo {
2428 name: "claimsdb",
2429 display_name: "Claims DB",
2430 aliases: &[],
2431 dialect_family: DialectFamily::Custom("claimsdb"),
2432 family: "Claims",
2433 is_cloud_native: false,
2434 is_olap: false,
2435 is_postgres_compatible: false,
2436 versions: &[],
2437 deploy_script: None,
2438 }
2439 }
2440 }
2441
2442 let disagreement = |report: &Report| {
2443 report
2444 .findings
2445 .iter()
2446 .filter(|finding| {
2447 finding.severity == Severity::Blocking
2448 && (finding.what.contains("says its engine has none")
2449 || finding.what.contains("refuses to write one"))
2450 })
2451 .count()
2452 };
2453 let mut findings = Vec::new();
2454 check_routine_forms(&Claims::<true, false>, &mut findings);
2455 let report = Report { dialect: "claimsdb".to_owned(), findings };
2456 assert_eq!(disagreement(&report), 1, "{report}");
2457 assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2458
2459 for (agrees, label) in [
2460 (check_forms(&Claims::<true, true>), "spelled and used"),
2461 (check_forms(&Claims::<false, false>), "refused and absent"),
2462 ] {
2463 assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2464 }
2465 assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2466 }
2467
2468 #[test]
2474 fn a_custom_render_method_that_returns_nothing_is_blocking() {
2475 let mut findings = Vec::new();
2476 check_custom_grammar(
2477 CustomGrammar {
2478 kind: RoutineKind::Procedure,
2479 grammar: "PL/SQL",
2480 form_method: "procedure_form",
2481 render_method: "render_procedure",
2482 noun: "procedures",
2483 },
2484 |_| Some(" \n".to_owned()),
2485 &mut findings,
2486 );
2487 assert_eq!(findings.len(), 1, "{findings:?}");
2488 assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2489 }
2490
2491 #[test]
2493 fn a_custom_grammar_with_no_name_is_blocking() {
2494 let mut findings = Vec::new();
2495 check_custom_grammar(
2496 CustomGrammar {
2497 kind: RoutineKind::ScalarFunction,
2498 grammar: " ",
2499 form_method: "scalar_function_form",
2500 render_method: "render_scalar_function",
2501 noun: "scalar functions",
2502 },
2503 |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2504 &mut findings,
2505 );
2506 assert_eq!(findings.len(), 1, "{findings:?}");
2507 assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2508 }
2509
2510 #[test]
2518 fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2519 struct Overstated;
2520 impl SqlRenderer for Overstated {
2521 fn render_data_type(&self, dt: &DataType) -> String {
2522 razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2523 }
2524 fn quote_identifier(&self, name: &str) -> String {
2525 format!("\"{name}\"")
2526 }
2527 fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2528 name.to_owned()
2529 }
2530 fn render_current_timestamp(&self) -> String {
2531 "CURRENT_TIMESTAMP".to_owned()
2532 }
2533 fn dialect_name(&self) -> &'static str {
2534 "overstateddb"
2535 }
2536 fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2537 Some(razeri_plugin_contract::SidHelperBody {
2538 language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2539 body: " \n ",
2540 })
2541 }
2542 fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2543 Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2544 }
2545 }
2546 impl ExpressionRenderer for Overstated {}
2547 impl DdlRenderer for Overstated {}
2548 impl DmlRenderer for Overstated {}
2549 impl ProcedureRenderer for Overstated {}
2550 impl FormulaRenderer for Overstated {}
2551 impl TaskRenderer for Overstated {}
2552 impl ReplicationRenderer for Overstated {}
2553 impl StorageRenderer for Overstated {}
2554 impl DialectCapabilities for Overstated {}
2555 impl PlatformDialect for Overstated {
2556 fn info(&self) -> DialectInfo {
2557 DialectInfo {
2558 name: "overstateddb",
2559 display_name: "Overstated DB",
2560 aliases: &[],
2561 dialect_family: DialectFamily::Custom(""),
2562 family: "Overstated",
2563 is_cloud_native: false,
2564 is_olap: false,
2565 is_postgres_compatible: false,
2566 versions: &[],
2567 deploy_script: None,
2568 }
2569 }
2570 }
2571
2572 let report = check(&Overstated);
2573 for (fragment, why) in [
2574 ("supplies an empty one", "a blank helper body"),
2575 ("does not carry the path", "a file relation that drops its path"),
2576 ] {
2577 let found = report
2578 .findings
2579 .iter()
2580 .find(|f| f.what.contains(fragment))
2581 .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
2582 assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
2583 }
2584
2585 let quiet = check(&HealthyPlugin::new());
2588 for fragment in ["supplies an empty one", "does not carry the path"] {
2589 assert!(
2590 !quiet.findings.iter().any(|f| f.what.contains(fragment)),
2591 "silence is the default and owes nothing:\n{quiet}"
2592 );
2593 }
2594 }
2595
2596 #[test]
2600 fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
2601 let mut mute = Vec::new();
2602 check_refusal_copy("the upsert refusal", "no merge", &mut mute);
2603 assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
2604 assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
2605
2606 let silence = check(&BrokenPlugin);
2607 assert!(
2608 silence
2609 .findings
2610 .iter()
2611 .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
2612 "a dialect that never answered is unanswered, not wrong:\n{silence}"
2613 );
2614 assert!(
2615 silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
2616 "the fix line names the way to state a measured refusal:\n{silence}"
2617 );
2618 }
2619
2620 #[test]
2624 fn the_report_reads_as_a_summary_then_a_fix_list() {
2625 let text = check(&BrokenPlugin).to_string();
2626 let first = text.lines().next().expect("a summary line");
2627 assert!(first.starts_with("brokendb:"), "{first}");
2628 assert!(first.contains("to fix"), "{first}");
2629 assert!(text.contains(" → "), "every finding names the fix:\n{text}");
2630 }
2631}