1use std::{
14 collections::{HashMap, HashSet},
15 fmt,
16};
17
18use fraiseql_core::{
19 db::{
20 DatabaseType,
21 introspector::{DatabaseIntrospector, RelationInfo},
22 },
23 schema::{
24 CompiledSchema, FieldType, PaginationOrder, SourceKind, SourceProbe, sql_source_probes,
25 },
26};
27use fraiseql_db::postgres::PostgresTlsConfig;
28
29use super::mutation_contract::Severity;
30
31pub struct DatabaseValidationReport {
33 pub warnings: Vec<DatabaseWarning>,
35 pub native_columns: HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>>,
43 pub pagination_orders: HashMap<String, PaginationOrder>,
54}
55
56#[derive(Debug)]
58pub enum DatabaseWarning {
59 MissingRelation {
61 query_name: String,
63 sql_source: String,
65 },
66 MissingFunction {
73 mutation_name: String,
75 sql_source: String,
77 },
78 MissingTypeSource {
86 type_name: String,
88 sql_source: String,
90 },
91 MissingAdditionalView {
93 query_name: String,
95 view_name: String,
97 },
98 MissingJsonColumn {
100 query_name: String,
102 sql_source: String,
104 column_name: String,
106 },
107 WrongJsonColumnType {
109 query_name: String,
111 sql_source: String,
113 column_name: String,
115 actual_type: String,
117 },
118 MissingPaginationColumn {
125 query_name: String,
127 sql_source: String,
129 column_name: String,
131 },
132 MissingIdentityColumn {
139 query_name: String,
141 sql_source: String,
143 pk_column: String,
145 },
146 SelfOrderingView {
158 query_name: String,
160 sql_source: String,
162 },
163 MissingCursorColumn {
165 query_name: String,
167 sql_source: String,
169 column_name: String,
171 },
172 MissingJsonKey {
174 query_name: String,
176 type_name: String,
178 sql_source: String,
180 json_column: String,
182 field_name: String,
184 json_key: String,
186 field_required: bool,
190 },
191 NativeColumnFallback {
197 query_name: String,
199 sql_source: String,
201 arg_name: String,
203 },
204 UnresolvableFilter {
209 query_name: String,
211 sql_source: String,
213 filter: QueryFilter,
215 key: String,
217 reason: InjectMiss,
219 },
220 TypeConvertibility {
225 query_name: String,
227 sql_source: String,
229 arg_name: String,
231 graphql_type: String,
233 column_type: String,
235 },
236 TemporalKeyInDocument {
242 query_name: String,
244 sql_source: String,
246 field: String,
248 column: String,
250 remedy: TemporalKeyRemedy,
252 },
253 TviewIndexMissing {
257 query_name: String,
259 tview: String,
261 keys: Vec<String>,
263 options: serde_json::Value,
266 },
267 NativeKeyDisagreement {
272 query_name: String,
274 sql_source: String,
276 column: String,
278 json_column: String,
280 sampled: u64,
282 disagreeing: u64,
284 },
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum TemporalKeyRemedy {
291 MapColumn,
293 AddToTview,
295 AddToTable,
297 ExposeFromView,
299}
300
301impl DatabaseWarning {
302 #[must_use]
306 pub fn statement(&self) -> Option<String> {
307 let Self::TviewIndexMissing { tview, options, .. } = self else {
308 return None;
309 };
310 let literal = |text: &str| text.replace('\'', "''");
311 Some(format!(
312 "SELECT tviews.pg_tviews_create_or_replace(format('%I.%I', schema, name), query, \
313 '{}'::jsonb) FROM tviews.registry WHERE format('%I.%I', schema, name) = '{}';",
314 literal(&options.to_string()),
315 literal(tview)
316 ))
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum QueryFilter {
323 Inject(String),
325 Argument(String),
327}
328
329impl fmt::Display for QueryFilter {
330 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331 match self {
332 Self::Inject(name) => write!(f, "inject_params['{name}']"),
333 Self::Argument(name) => write!(f, "argument `{name}`"),
334 }
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum InjectMiss {
341 NoJsonColumn,
343 NotInSampledRows {
345 json_column: String,
347 },
348 EmptyRelation {
350 json_column: String,
352 },
353}
354
355impl DatabaseWarning {
356 #[must_use]
371 pub const fn severity(&self) -> Severity {
372 match self {
373 Self::MissingRelation { .. }
374 | Self::MissingFunction { .. }
375 | Self::MissingAdditionalView { .. }
376 | Self::MissingJsonColumn { .. }
377 | Self::WrongJsonColumnType { .. }
378 | Self::MissingCursorColumn { .. }
379 | Self::MissingPaginationColumn { .. }
380 | Self::MissingIdentityColumn { .. }
381 | Self::TypeConvertibility { .. }
382 | Self::NativeKeyDisagreement { .. } => Severity::Error,
383 Self::UnresolvableFilter { reason, .. } => match reason {
385 InjectMiss::NoJsonColumn | InjectMiss::NotInSampledRows { .. } => Severity::Error,
386 InjectMiss::EmptyRelation { .. } => Severity::Warn,
387 },
388 Self::MissingJsonKey { field_required, .. } => {
389 if *field_required {
390 Severity::Error
391 } else {
392 Severity::Warn
393 }
394 },
395 Self::MissingTypeSource { .. }
396 | Self::NativeColumnFallback { .. }
397 | Self::SelfOrderingView { .. }
398 | Self::TemporalKeyInDocument { .. }
399 | Self::TviewIndexMissing { .. } => Severity::Warn,
400 }
401 }
402}
403
404impl fmt::Display for DatabaseWarning {
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406 match self {
407 Self::MissingRelation {
408 query_name,
409 sql_source,
410 } => {
411 write!(
412 f,
413 "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
414 )
415 },
416 Self::MissingFunction {
417 mutation_name,
418 sql_source,
419 } => {
420 write!(
421 f,
422 "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
423 )
424 },
425 Self::MissingTypeSource {
426 type_name,
427 sql_source,
428 } => {
429 write!(
430 f,
431 "type `{type_name}`: sql_source `{sql_source}` does not exist in the \
432 database — if `{type_name}` is an embedded value object it should declare \
433 no source (a value object has no independent identity; it is fetched via \
434 its parent)"
435 )
436 },
437 Self::MissingAdditionalView {
438 query_name,
439 view_name,
440 } => {
441 write!(
442 f,
443 "query `{query_name}`: additional_view `{view_name}` does not exist in database"
444 )
445 },
446 Self::MissingJsonColumn {
447 query_name,
448 sql_source,
449 column_name,
450 } => {
451 write!(
452 f,
453 "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
454 )
455 },
456 Self::WrongJsonColumnType {
457 query_name,
458 sql_source,
459 column_name,
460 actual_type,
461 } => {
462 write!(
463 f,
464 "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
465 )
466 },
467 Self::MissingCursorColumn {
468 query_name,
469 sql_source,
470 column_name,
471 } => {
472 write!(
473 f,
474 "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
475 )
476 },
477 Self::MissingPaginationColumn {
478 query_name,
479 sql_source,
480 column_name,
481 } => {
482 write!(
483 f,
484 "query `{query_name}`: pagination_order column `{column_name}` not found on \
485 `{sql_source}` — it is rendered into ORDER BY, so this query fails at \
486 request time"
487 )
488 },
489 Self::MissingIdentityColumn {
490 query_name,
491 sql_source,
492 pk_column,
493 } => {
494 write!(
495 f,
496 "query `{query_name}`: offset pages are ordered by the type's identity, and \
497 `{sql_source}` exposes neither `{pk_column}` nor an `id` column (ADR-0017 \
498 makes `id` a base column of every read relation). Expose one of them, or \
499 declare pagination_order"
500 )
501 },
502 Self::SelfOrderingView {
503 query_name,
504 sql_source,
505 } => {
506 write!(
507 f,
508 "query `{query_name}`: view `{sql_source}` carries its own ORDER BY, and \
509 this query does not declare pagination_order. An offset page will now be \
510 ordered by the entity identity instead, replacing the view's order. \
511 Declare pagination_order = \"none\" to keep it, or a column to confirm \
512 the new one"
513 )
514 },
515 Self::MissingJsonKey {
516 query_name,
517 type_name,
518 sql_source,
519 json_column,
520 field_name,
521 json_key,
522 field_required,
523 } => {
524 let requiredness = if *field_required {
525 "required"
526 } else {
527 "nullable"
528 };
529 write!(
530 f,
531 "query `{query_name}`: {requiredness} field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
532 )
533 },
534 Self::NativeColumnFallback {
535 query_name,
536 sql_source,
537 arg_name,
538 } => {
539 let key = fraiseql_core::utils::to_snake_case(arg_name);
540 write!(
541 f,
542 "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
543 (`{sql_source}.data->>'{key}'`) — no native column `{key}` found on \
544 `{sql_source}`. Add a native column with an index for O(log n) lookup."
545 )
546 },
547 Self::UnresolvableFilter {
548 query_name,
549 sql_source,
550 filter,
551 key,
552 reason,
553 } => {
554 let why = match reason {
555 InjectMiss::NoJsonColumn => "has no JSON column to fall back on".to_string(),
556 InjectMiss::NotInSampledRows { json_column } => {
557 format!("and none of the sampled `{json_column}` rows carries the key")
558 },
559 InjectMiss::EmptyRelation { json_column } => format!(
560 "and is empty, so whether `{json_column}` carries the key cannot be checked"
561 ),
562 };
563 let way_out = match filter {
564 QueryFilter::Inject(_) => {
565 "Add the column to the relation, or, if this data is not scoped by it, drop \
566 the parameter from this query (`exclude_inject_defaults` when it comes from \
567 [inject_defaults])"
568 },
569 QueryFilter::Argument(_) => {
570 "Add the column to the relation (or the key to its `data`), or rename the \
571 argument after the column it filters"
572 },
573 };
574 write!(
575 f,
576 "query `{query_name}`: {filter} filters `{sql_source}` on `{key}`, which has no \
577 `{key}` column {why} — the predicate would match no row and every caller would \
578 get an empty answer. {way_out}"
579 )
580 },
581 Self::TypeConvertibility {
582 query_name,
583 sql_source,
584 arg_name,
585 graphql_type,
586 column_type,
587 } => {
588 write!(
589 f,
590 "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
591 `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
592 Align the argument type with the column, or store the field in `data` for JSONB extraction."
593 )
594 },
595 Self::TemporalKeyInDocument {
596 query_name,
597 sql_source,
598 field,
599 column,
600 remedy,
601 } => {
602 let step = match remedy {
603 TemporalKeyRemedy::MapColumn => format!(
604 "`{sql_source}` already has `{column}`: give the query an argument named \
605 `{field}` so this compile maps it to the column"
606 ),
607 TemporalKeyRemedy::AddToTview => format!(
608 "Add `{column}` to the TVIEW's definition (with its own type, e.g. \
609 `timestamptz`, and an index), then give the query an argument named \
610 `{field}` so this compile maps it to the column"
611 ),
612 TemporalKeyRemedy::AddToTable => format!(
613 "Add a `{column}` column to `{sql_source}` (with its own type, e.g. \
614 `timestamptz`, and an index), then give the query an argument named \
615 `{field}` so this compile maps it to the column"
616 ),
617 TemporalKeyRemedy::ExposeFromView => format!(
618 "Expose `{column}` from the view's base table as a column of its own \
619 type (e.g. `timestamptz`), index it on the base table with the page's \
620 tie-breaker (`({column} DESC, id)`), then give the query an argument \
621 named `{field}` so this compile maps it to the column"
622 ),
623 };
624 write!(
625 f,
626 "query `{query_name}`: `{field}` is read out of `{sql_source}`'s document, so \
627 every sort or filter on it casts each row's value and no index can serve it. \
628 {step} (docs/performance.md, \"Use a Native Column\")."
629 )
630 },
631 Self::NativeKeyDisagreement {
632 query_name,
633 sql_source,
634 column,
635 json_column,
636 sampled,
637 disagreeing,
638 } => {
639 write!(
640 f,
641 "query `{query_name}`: is scoped by `{column}`, read from the native column \
642 at the root and from the `{json_column}` key `{column}` wherever the \
643 document is embedded, and {disagreeing} of {sampled} sampled \
644 `{sql_source}` rows hold a different value in each — an embedded document \
645 would be filtered on another value than its row. Build the key from the \
646 column (`jsonb_build_object('{column}', {column}, …)`)."
647 )
648 },
649 Self::TviewIndexMissing {
650 query_name,
651 tview,
652 keys,
653 ..
654 } => {
655 write!(
656 f,
657 "query `{query_name}`: filters `{tview}`'s document on `{}` on every call, \
658 and no index of the TVIEW serves it. Declare one with the TVIEW, keeping its \
659 other options (a replace's options are its whole declaration: one without \
660 `indexes` drops them): {}",
661 keys.join("`, `"),
662 self.statement().unwrap_or_default()
663 )
664 },
665 }
666 }
667}
668
669pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
671 let lower = data_type.to_lowercase();
672 match db_type {
673 DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
674 }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum SqlFamily {
680 Numeric,
682 Boolean,
684 Uuid,
686 Text,
688 Temporal,
690 Json,
692 Other,
694}
695
696fn sql_type_family(sql_type: &str) -> SqlFamily {
701 let lower = sql_type.trim().to_lowercase();
702 let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
703 match base {
704 "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
705 | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
706 | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
707 "boolean" | "bool" | "bit" => SqlFamily::Boolean,
708 "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
709 "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
710 | "character" | "clob" | "string" => SqlFamily::Text,
711 "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
712 | "interval" | "smalldatetime" => SqlFamily::Temporal,
713 "json" | "jsonb" => SqlFamily::Json,
714 _ => SqlFamily::Other,
715 }
716}
717
718fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
727 let base = match field_type {
729 FieldType::List { item: inner, .. } => inner.as_ref(),
730 other => other,
731 };
732 let family = sql_type_family(sql_type);
733 if family == SqlFamily::Other {
735 return true;
736 }
737 match base {
738 FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
739 FieldType::Boolean => family == SqlFamily::Boolean,
740 FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
741 _ => true,
742 }
743}
744
745fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
747 match sql_source.split_once('.') {
748 Some((schema, table)) => (Some(schema), table),
749 None => (None, sql_source),
750 }
751}
752
753fn relation_exists(
755 schema_qualified: &HashMap<(String, String), RelationInfo>,
756 unqualified: &HashMap<String, Vec<String>>,
757 sql_source: &str,
758) -> bool {
759 let (schema, name) = split_schema_qualified(sql_source);
760 if let Some(s) = schema {
761 schema_qualified.contains_key(&(s.to_string(), name.to_string()))
762 } else {
763 unqualified.contains_key(name)
764 }
765}
766
767async fn relation_source_exists(
776 introspector: &impl DatabaseIntrospector,
777 schema_qualified: &HashMap<(String, String), RelationInfo>,
778 unqualified: &HashMap<String, Vec<String>>,
779 sql_source: &str,
780) -> fraiseql_core::Result<bool> {
781 let (schema, name) = split_schema_qualified(sql_source);
782 if let Some(s) = schema {
783 if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
784 return Ok(exists);
785 }
786 }
787 Ok(relation_exists(schema_qualified, unqualified, sql_source))
789}
790
791async fn function_source_exists(
797 introspector: &impl DatabaseIntrospector,
798 sql_source: &str,
799) -> fraiseql_core::Result<bool> {
800 let (schema, name) = split_schema_qualified(sql_source);
801 Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
802}
803
804pub async fn validate_schema_against_database(
817 schema: &CompiledSchema,
818 introspector: &impl DatabaseIntrospector,
819) -> fraiseql_core::Result<DatabaseValidationReport> {
820 const AUTO_PARAM_NAMES: &[&str] = &[
823 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
824 ];
825
826 let mut warnings = Vec::new();
827 let mut native_columns: HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>> =
828 HashMap::new();
829 let mut pagination_orders: HashMap<String, PaginationOrder> = HashMap::new();
830 let db_type = introspector.database_type();
831
832 let relations = introspector.list_relations().await?;
834 let (schema_qualified, unqualified) = build_relation_maps(&relations);
835
836 for query in schema.queries.iter().filter(|q| q.sql_source_kind.is_relation()) {
839 if let Some(ref source) = query.sql_source {
840 if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
842 .await?
843 {
844 warnings.push(DatabaseWarning::MissingRelation {
845 query_name: query.name.clone(),
846 sql_source: source.clone(),
847 });
848 continue; }
850
851 let columns = introspector.get_columns(source).await?;
855 let not_null: std::collections::HashSet<String> = columns
858 .iter()
859 .filter(|(_, _, nullable)| !nullable)
860 .map(|(name, _, _)| name.clone())
861 .collect();
862 let column_map: HashMap<String, String> =
863 columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
864
865 let jsonb_col = &query.jsonb_column;
867 if !jsonb_col.is_empty() {
868 if let Some(actual_type) = column_map.get(jsonb_col) {
869 if !is_json_type(actual_type, db_type) {
870 warnings.push(DatabaseWarning::WrongJsonColumnType {
871 query_name: query.name.clone(),
872 sql_source: source.clone(),
873 column_name: jsonb_col.clone(),
874 actual_type: actual_type.clone(),
875 });
876 }
877 } else {
878 warnings.push(DatabaseWarning::MissingJsonColumn {
879 query_name: query.name.clone(),
880 sql_source: source.clone(),
881 column_name: jsonb_col.clone(),
882 });
883 }
884 }
885
886 if query.relay {
888 if let Some(ref cursor_col) = query.relay_cursor_column {
889 if !column_map.contains_key(cursor_col) {
890 warnings.push(DatabaseWarning::MissingCursorColumn {
891 query_name: query.name.clone(),
892 sql_source: source.clone(),
893 column_name: cursor_col.clone(),
894 });
895 }
896 }
897 }
898
899 match &query.pagination_order {
905 Some(PaginationOrder::Column(col)) if !column_map.contains_key(col) => {
906 warnings.push(DatabaseWarning::MissingPaginationColumn {
907 query_name: query.name.clone(),
908 sql_source: source.clone(),
909 column_name: col.clone(),
910 });
911 },
912 Some(PaginationOrder::Column(_)) => {},
913 Some(PaginationOrder::Identity) => {
914 let pk = pk_column_name(&query.return_type);
915 if column_map.contains_key(&pk) {
916 pagination_orders.insert(query.name.clone(), PaginationOrder::Column(pk));
917 } else if !column_map.contains_key("id") {
918 warnings.push(DatabaseWarning::MissingIdentityColumn {
919 query_name: query.name.clone(),
920 sql_source: source.clone(),
921 pk_column: pk,
922 });
923 }
924 },
925 None => {},
926 }
927
928 if query.pagination_order.is_some()
934 && introspector
935 .get_view_definition(source)
936 .await?
937 .is_some_and(|body| orders_itself(&body))
938 {
939 warnings.push(DatabaseWarning::SelfOrderingView {
940 query_name: query.name.clone(),
941 sql_source: source.clone(),
942 });
943 }
944
945 if !jsonb_col.is_empty() {
947 let json_type_ok =
948 column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
949
950 if json_type_ok {
951 validate_json_keys(
952 schema,
953 query,
954 source,
955 jsonb_col,
956 introspector,
957 source, &mut warnings,
959 )
960 .await?;
961 }
962 }
963
964 let direct_args: Vec<&str> = query
969 .arguments
970 .iter()
971 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
972 .map(|a| a.name.as_str())
973 .collect();
974
975 let (query_native, arg_fallbacks) = detect_query_native_columns(
976 &direct_args,
977 query.inject_params.keys().map(String::as_str),
978 &column_map,
979 ¬_null,
980 );
981 if !jsonb_col.is_empty()
984 && column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type))
985 {
986 check_native_keys_agree(
987 schema,
988 query,
989 source,
990 &query_native,
991 introspector,
992 &mut warnings,
993 )
994 .await?;
995 }
996
997 let mut unresolved: Vec<(QueryFilter, String)> = query
1001 .inject_params
1002 .keys()
1003 .filter(|param| !column_map.contains_key(*param))
1004 .map(|param| (QueryFilter::Inject(param.clone()), param.clone()))
1005 .collect();
1006 unresolved.extend(arg_fallbacks.into_iter().map(|arg| {
1007 let key = fraiseql_core::utils::to_snake_case(&arg);
1008 (QueryFilter::Argument(arg), key)
1009 }));
1010 if !unresolved.is_empty() {
1012 if let Some(declaration) = introspector.tview_declaration(source).await? {
1013 warnings.extend(tview_index_advice(query, &declaration, &unresolved));
1014 }
1015 }
1016 check_filters_resolvable(
1017 query,
1018 source,
1019 unresolved,
1020 &column_map,
1021 db_type,
1022 introspector,
1023 &mut warnings,
1024 )
1025 .await?;
1026
1027 for arg in &query.arguments {
1031 if let Some(native) = query_native.get(&arg.name) {
1032 let col_type = &native.pg_type;
1033 if !arg_type_convertible(&arg.arg_type, col_type) {
1034 warnings.push(DatabaseWarning::TypeConvertibility {
1035 query_name: query.name.clone(),
1036 sql_source: source.clone(),
1037 arg_name: arg.name.clone(),
1038 graphql_type: arg.arg_type.to_graphql_string(),
1039 column_type: col_type.clone(),
1040 });
1041 }
1042 }
1043 }
1044
1045 if !jsonb_col.is_empty() && compares_document_keys(query) {
1047 let backing = if introspector.is_tview(source).await? {
1048 TemporalBacking::Tview
1049 } else if relation_kind(&schema_qualified, &unqualified, source)
1050 == Some(fraiseql_core::db::RelationKind::View)
1051 || introspector.get_view_definition(source).await?.is_some()
1052 {
1053 TemporalBacking::View
1054 } else {
1055 TemporalBacking::Table
1056 };
1057 warnings.extend(temporal_keys_in_document(
1058 schema,
1059 query,
1060 source,
1061 &query_native,
1062 &column_map,
1063 backing,
1064 ));
1065 }
1066
1067 if !query_native.is_empty() {
1068 native_columns.insert(query.name.clone(), query_native);
1069 }
1070
1071 for view in &query.additional_views {
1073 if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
1074 .await?
1075 {
1076 warnings.push(DatabaseWarning::MissingAdditionalView {
1077 query_name: query.name.clone(),
1078 view_name: view.clone(),
1079 });
1080 }
1081 }
1082 }
1083 }
1084
1085 for mutation in &schema.mutations {
1089 if let Some(ref source) = mutation.sql_source {
1090 if !function_source_exists(introspector, source).await? {
1091 warnings.push(DatabaseWarning::MissingFunction {
1092 mutation_name: mutation.name.clone(),
1093 sql_source: source.clone(),
1094 });
1095 }
1096 }
1097 }
1098
1099 for ty in &schema.types {
1116 let source = ty.sql_source.as_str();
1117 if ty.is_error || source.is_empty() {
1118 continue;
1119 }
1120 if !relation_source_exists(introspector, &schema_qualified, &unqualified, source).await? {
1121 warnings.push(DatabaseWarning::MissingTypeSource {
1122 type_name: ty.name.to_string(),
1123 sql_source: source.to_string(),
1124 });
1125 }
1126 }
1127
1128 unify_tview_index_advice(&mut warnings);
1129
1130 Ok(DatabaseValidationReport {
1131 warnings,
1132 native_columns,
1133 pagination_orders,
1134 })
1135}
1136
1137fn unify_tview_index_advice(warnings: &mut [DatabaseWarning]) {
1142 let mut by_tview: HashMap<String, serde_json::Value> = HashMap::new();
1143 for warning in warnings.iter() {
1144 let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning else {
1145 continue;
1146 };
1147 let advised = options.get("indexes").and_then(serde_json::Value::as_array);
1148 match by_tview.get_mut(tview) {
1149 None => {
1150 by_tview.insert(tview.clone(), options.clone());
1151 },
1152 Some(merged) => {
1153 let Some(indexes) = merged.get_mut("indexes").and_then(|i| i.as_array_mut()) else {
1154 continue;
1155 };
1156 for index in advised.into_iter().flatten() {
1157 if !indexes.contains(index) {
1158 indexes.push(index.clone());
1159 }
1160 }
1161 },
1162 }
1163 }
1164 for warning in warnings.iter_mut() {
1165 if let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning {
1166 if let Some(merged) = by_tview.get(tview) {
1167 options.clone_from(merged);
1168 }
1169 }
1170 }
1171}
1172
1173fn temporal_keys_in_document(
1182 schema: &CompiledSchema,
1183 query: &fraiseql_core::schema::QueryDefinition,
1184 source: &str,
1185 query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
1186 column_map: &HashMap<String, String>,
1187 backing: TemporalBacking,
1188) -> Vec<DatabaseWarning> {
1189 let Some(ty) = schema.find_type(&query.return_type) else {
1190 return Vec::new();
1191 };
1192 ty.fields
1193 .iter()
1194 .filter(|f| matches!(f.field_type, FieldType::DateTime | FieldType::Date | FieldType::Time))
1195 .filter(|f| !query_native.contains_key(f.name.as_str()))
1196 .map(|f| {
1197 let column = fraiseql_core::utils::to_snake_case(f.name.as_str());
1198 let remedy = match backing {
1199 _ if column_map.contains_key(&column) => TemporalKeyRemedy::MapColumn,
1200 TemporalBacking::Tview => TemporalKeyRemedy::AddToTview,
1201 TemporalBacking::View => TemporalKeyRemedy::ExposeFromView,
1202 TemporalBacking::Table => TemporalKeyRemedy::AddToTable,
1203 };
1204 DatabaseWarning::TemporalKeyInDocument {
1205 query_name: query.name.clone(),
1206 sql_source: source.to_string(),
1207 field: f.name.to_string(),
1208 column,
1209 remedy,
1210 }
1211 })
1212 .collect()
1213}
1214
1215const fn compares_document_keys(query: &fraiseql_core::schema::QueryDefinition) -> bool {
1217 query.auto_params.has_order_by || query.auto_params.has_where
1218}
1219
1220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1222enum TemporalBacking {
1223 Tview,
1225 View,
1227 Table,
1229}
1230
1231fn relation_kind(
1234 schema_qualified: &HashMap<(String, String), RelationInfo>,
1235 unqualified: &HashMap<String, Vec<String>>,
1236 source: &str,
1237) -> Option<fraiseql_core::db::RelationKind> {
1238 let (schema, name) = match source.split_once('.') {
1239 Some((schema, name)) => (schema.to_string(), name),
1240 None => (unqualified.get(source)?.first()?.clone(), source),
1241 };
1242 schema_qualified.get(&(schema, name.to_string())).map(|r| r.kind.clone())
1243}
1244
1245fn tview_index_advice(
1252 query: &fraiseql_core::schema::QueryDefinition,
1253 declaration: &fraiseql_db::TviewDeclaration,
1254 filters: &[(QueryFilter, String)],
1255) -> Option<DatabaseWarning> {
1256 if filters.is_empty() {
1257 return None;
1258 }
1259 let keys: Vec<serde_json::Value> = filters
1260 .iter()
1261 .map(|(filter, key)| {
1262 let arg_type = match filter {
1263 QueryFilter::Argument(name) => {
1264 query.arguments.iter().find(|a| &a.name == name).map(|a| &a.arg_type)
1265 },
1266 QueryFilter::Inject(_) => None,
1267 };
1268 match arg_type {
1269 Some(FieldType::Int | FieldType::Float) => {
1270 serde_json::json!({ "path": key, "type": "numeric" })
1271 },
1272 Some(FieldType::Boolean) => serde_json::json!({ "path": key, "type": "boolean" }),
1273 _ => serde_json::json!(key),
1274 }
1275 })
1276 .collect();
1277 let declared: Vec<serde_json::Value> = declaration
1278 .options
1279 .get("indexes")
1280 .and_then(serde_json::Value::as_array)
1281 .cloned()
1282 .unwrap_or_default();
1283 if declared.iter().any(|index| index_leads_with(index, &keys)) {
1284 return None;
1285 }
1286 let mut options = declaration.options.clone();
1287 let mut indexes = declared;
1288 indexes.push(serde_json::Value::Array(keys));
1289 options
1290 .as_object_mut()?
1291 .insert("indexes".to_string(), serde_json::Value::Array(indexes));
1292 Some(DatabaseWarning::TviewIndexMissing {
1293 query_name: query.name.clone(),
1294 tview: declaration.tview.clone(),
1295 keys: filters.iter().map(|(_, key)| key.clone()).collect(),
1296 options,
1297 })
1298}
1299
1300fn index_leads_with(index: &serde_json::Value, keys: &[serde_json::Value]) -> bool {
1304 fn key(value: &serde_json::Value) -> Option<(&str, &str)> {
1305 match value {
1306 serde_json::Value::String(path) => Some((path, "text")),
1307 serde_json::Value::Object(key) => Some((
1308 key.get("path")?.as_str()?,
1309 key.get("type").and_then(serde_json::Value::as_str).unwrap_or("text"),
1310 )),
1311 _ => None,
1312 }
1313 }
1314 let Some(index) = index.as_array() else {
1315 return false;
1316 };
1317 index.len() >= keys.len()
1318 && index.iter().zip(keys).all(|(a, b)| key(a).is_some_and(|a| Some(a) == key(b)))
1319}
1320
1321fn pk_column_name(return_type: &str) -> String {
1328 format!("pk_{}", fraiseql_core::utils::to_snake_case(return_type))
1329}
1330
1331fn orders_itself(view_body: &str) -> bool {
1340 view_body.contains("ORDER BY")
1341}
1342
1343fn build_relation_maps(
1345 relations: &[RelationInfo],
1346) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
1347 let mut schema_qualified = HashMap::new();
1348 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
1349
1350 for rel in relations {
1351 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
1352 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
1353 }
1354
1355 (schema_qualified, unqualified)
1356}
1357
1358pub async fn find_unbacked_sources(
1375 schema: &CompiledSchema,
1376 introspector: &impl DatabaseIntrospector,
1377) -> fraiseql_core::Result<Vec<SourceProbe>> {
1378 let relations = introspector.list_relations().await?;
1379 let (schema_qualified, unqualified) = build_relation_maps(&relations);
1380
1381 let mut unbacked = Vec::new();
1382 for probe in sql_source_probes(schema) {
1383 let exists = match probe.kind {
1384 SourceKind::Function => introspector
1385 .function_exists(probe.schema.as_deref(), &probe.name)
1386 .await?
1387 .unwrap_or(true),
1388 SourceKind::Relation => {
1389 if let Some(s) = &probe.schema {
1390 match introspector.qualified_relation_exists(s, &probe.name).await? {
1391 Some(exists) => exists,
1392 None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
1393 }
1394 } else {
1395 unqualified.contains_key(&probe.name)
1396 }
1397 },
1398 };
1399 if !exists {
1400 unbacked.push(probe);
1401 }
1402 }
1403 Ok(unbacked)
1404}
1405
1406async fn validate_json_keys(
1408 schema: &CompiledSchema,
1409 query: &fraiseql_core::schema::QueryDefinition,
1410 source: &str,
1411 jsonb_col: &str,
1412 introspector: &impl DatabaseIntrospector,
1413 table_name: &str,
1414 warnings: &mut Vec<DatabaseWarning>,
1415) -> fraiseql_core::Result<()> {
1416 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
1417
1418 if samples.is_empty() {
1419 return Ok(());
1420 }
1421
1422 let mut all_keys = HashSet::new();
1424 for sample in &samples {
1425 if let serde_json::Value::Object(map) = sample {
1426 for key in map.keys() {
1427 all_keys.insert(key.clone());
1428 }
1429 }
1430 }
1431
1432 if all_keys.is_empty() {
1433 return Ok(());
1434 }
1435
1436 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
1438
1439 if let Some(type_def) = type_def {
1440 for field in &type_def.fields {
1441 let field_str = field.name.as_str();
1442 let json_key = fraiseql_core::utils::to_snake_case(field_str);
1446 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
1449 continue;
1450 }
1451 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
1452 warnings.push(DatabaseWarning::MissingJsonKey {
1453 query_name: query.name.clone(),
1454 type_name: type_def.name.as_str().to_string(),
1455 sql_source: source.to_string(),
1456 json_column: jsonb_col.to_string(),
1457 field_name: field_str.to_string(),
1458 json_key,
1459 field_required: !field.nullable,
1460 });
1461 }
1462 }
1463 }
1464
1465 Ok(())
1466}
1467
1468pub enum AnyIntrospector {
1473 Postgres(fraiseql_core::db::PostgresIntrospector),
1475}
1476
1477impl DatabaseIntrospector for AnyIntrospector {
1478 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
1479 match self {
1480 Self::Postgres(i) => i.list_fact_tables().await,
1481 }
1482 }
1483
1484 async fn get_columns(
1485 &self,
1486 table_name: &str,
1487 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
1488 match self {
1489 Self::Postgres(i) => i.get_columns(table_name).await,
1490 }
1491 }
1492
1493 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
1494 match self {
1495 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
1496 }
1497 }
1498
1499 fn database_type(&self) -> DatabaseType {
1500 match self {
1501 Self::Postgres(i) => i.database_type(),
1502 }
1503 }
1504
1505 async fn get_sample_jsonb(
1506 &self,
1507 table_name: &str,
1508 column_name: &str,
1509 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
1510 match self {
1511 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
1512 }
1513 }
1514
1515 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
1516 match self {
1517 Self::Postgres(i) => i.list_relations().await,
1518 }
1519 }
1520
1521 async fn get_sample_json_rows(
1522 &self,
1523 table_name: &str,
1524 column_name: &str,
1525 limit: usize,
1526 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
1527 match self {
1528 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
1529 }
1530 }
1531
1532 async fn column_key_disagreements(
1533 &self,
1534 relation: &str,
1535 column: &str,
1536 json_column: &str,
1537 key: &str,
1538 limit: usize,
1539 ) -> fraiseql_core::Result<Option<(u64, u64)>> {
1540 match self {
1541 Self::Postgres(i) => {
1542 i.column_key_disagreements(relation, column, json_column, key, limit).await
1543 },
1544 }
1545 }
1546
1547 async fn function_exists(
1548 &self,
1549 schema: Option<&str>,
1550 name: &str,
1551 ) -> fraiseql_core::Result<Option<bool>> {
1552 match self {
1555 Self::Postgres(i) => i.function_exists(schema, name).await,
1556 }
1557 }
1558
1559 async fn qualified_relation_exists(
1560 &self,
1561 schema: &str,
1562 name: &str,
1563 ) -> fraiseql_core::Result<Option<bool>> {
1564 match self {
1565 Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
1566 }
1567 }
1568
1569 async fn get_view_definition(&self, relation: &str) -> fraiseql_core::Result<Option<String>> {
1570 match self {
1571 Self::Postgres(i) => i.get_view_definition(relation).await,
1572 }
1573 }
1574
1575 async fn tview_declaration(
1576 &self,
1577 relation: &str,
1578 ) -> fraiseql_core::Result<Option<fraiseql_db::TviewDeclaration>> {
1579 match self {
1580 Self::Postgres(i) => i.tview_declaration(relation).await,
1581 }
1582 }
1583
1584 async fn is_tview(&self, relation: &str) -> fraiseql_core::Result<bool> {
1585 match self {
1586 Self::Postgres(i) => i.is_tview(relation).await,
1587 }
1588 }
1589}
1590
1591pub fn refuse_removed_engine_url(db_url: &str) -> anyhow::Result<()> {
1603 let removed_engine = db_url.starts_with("mysql")
1604 || db_url.starts_with("mariadb")
1605 || db_url.starts_with("sqlite")
1606 || db_url.starts_with("mssql")
1607 || db_url.starts_with("server=")
1608 || std::path::Path::new(db_url).extension().is_some_and(|ext| {
1609 ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite")
1610 });
1611 if removed_engine {
1612 anyhow::bail!(
1613 "This database URL targets an engine whose support was removed: FraiseQL is \
1614 PostgreSQL-only (G2 decision on #374; the non-PostgreSQL dialects were never \
1615 production-correct, see #721/#799). Use a postgresql:// URL."
1616 );
1617 }
1618 Ok(())
1619}
1620
1621pub async fn create_introspector(
1629 db_url: &str,
1630 tls: &PostgresTlsConfig,
1631) -> anyhow::Result<AnyIntrospector> {
1632 let pool = crate::connection::postgres_pool(db_url, "schema introspection", tls).await?;
1633 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
1634}
1635
1636async fn check_filters_resolvable(
1644 query: &fraiseql_core::schema::QueryDefinition,
1645 source: &str,
1646 unresolved: Vec<(QueryFilter, String)>,
1647 column_map: &HashMap<String, String>,
1648 db_type: DatabaseType,
1649 introspector: &impl DatabaseIntrospector,
1650 warnings: &mut Vec<DatabaseWarning>,
1651) -> fraiseql_core::Result<()> {
1652 if unresolved.is_empty() {
1653 return Ok(());
1654 }
1655 let jsonb_col = &query.jsonb_column;
1656 let json_ok = !jsonb_col.is_empty()
1657 && column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
1658 let carried: Option<HashSet<String>> = if json_ok {
1659 let samples =
1660 introspector.get_sample_json_rows(source, jsonb_col, INJECT_SAMPLE_ROWS).await?;
1661 if samples.is_empty() {
1662 None
1663 } else {
1664 Some(
1665 samples
1666 .iter()
1667 .filter_map(serde_json::Value::as_object)
1668 .flat_map(|row| row.keys().cloned())
1669 .collect(),
1670 )
1671 }
1672 } else {
1673 None
1674 };
1675 for (filter, key) in unresolved {
1676 let reason = match (&carried, json_ok) {
1677 (_, false) => InjectMiss::NoJsonColumn,
1678 (None, true) => InjectMiss::EmptyRelation {
1679 json_column: jsonb_col.clone(),
1680 },
1681 (Some(keys), true) if !keys.contains(&key) => InjectMiss::NotInSampledRows {
1682 json_column: jsonb_col.clone(),
1683 },
1684 (Some(_), true) => {
1685 if let QueryFilter::Argument(arg_name) = filter {
1686 warnings.push(DatabaseWarning::NativeColumnFallback {
1687 query_name: query.name.clone(),
1688 sql_source: source.to_string(),
1689 arg_name,
1690 });
1691 }
1692 continue;
1693 },
1694 };
1695 warnings.push(DatabaseWarning::UnresolvableFilter {
1696 query_name: query.name.clone(),
1697 sql_source: source.to_string(),
1698 filter,
1699 key,
1700 reason,
1701 });
1702 }
1703 Ok(())
1704}
1705
1706async fn check_native_keys_agree(
1711 schema: &fraiseql_core::schema::CompiledSchema,
1712 query: &fraiseql_core::schema::QueryDefinition,
1713 source: &str,
1714 query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
1715 introspector: &impl DatabaseIntrospector,
1716 warnings: &mut Vec<DatabaseWarning>,
1717) -> fraiseql_core::Result<()> {
1718 let Some(declared) = schema.find_type(&query.return_type) else {
1719 return Ok(());
1720 };
1721 for column in query.inject_params.keys().filter(|c| query_native.contains_key(*c)) {
1722 let key = fraiseql_core::utils::to_snake_case(column);
1723 let published = declared
1724 .fields
1725 .iter()
1726 .any(|f| fraiseql_core::utils::to_snake_case(f.name.as_str()) == key);
1727 if !published {
1728 continue;
1729 }
1730 let compared = introspector
1731 .column_key_disagreements(
1732 source,
1733 column,
1734 &query.jsonb_column,
1735 &key,
1736 AGREEMENT_SAMPLE_ROWS,
1737 )
1738 .await?;
1739 if let Some((sampled, disagreeing)) = compared.filter(|(_, d)| *d > 0) {
1740 warnings.push(DatabaseWarning::NativeKeyDisagreement {
1741 query_name: query.name.clone(),
1742 sql_source: source.to_string(),
1743 column: column.clone(),
1744 json_column: query.jsonb_column.clone(),
1745 sampled,
1746 disagreeing,
1747 });
1748 }
1749 }
1750 Ok(())
1751}
1752
1753const AGREEMENT_SAMPLE_ROWS: usize = 1000;
1755
1756const INJECT_SAMPLE_ROWS: usize = 20;
1758
1759fn detect_query_native_columns<'a>(
1772 direct_arg_names: &[&str],
1773 inject_param_names: impl Iterator<Item = &'a str>,
1774 column_map: &HashMap<String, String>,
1775 not_null: &std::collections::HashSet<String>,
1776) -> (HashMap<String, fraiseql_core::schema::NativeColumn>, Vec<String>) {
1777 let native_column = |column: &str, col_type: &String| fraiseql_core::schema::NativeColumn {
1778 pg_type: col_type.clone(),
1779 not_null: not_null.contains(column),
1780 };
1781 let mut native: HashMap<String, fraiseql_core::schema::NativeColumn> = HashMap::new();
1782 let mut arg_fallbacks: Vec<String> = Vec::new();
1783
1784 for arg in direct_arg_names {
1785 let column = fraiseql_core::utils::to_snake_case(arg);
1790 if let Some(col_type) = column_map.get(&column) {
1791 native.insert((*arg).to_string(), native_column(&column, col_type));
1792 } else {
1793 arg_fallbacks.push((*arg).to_string());
1794 }
1795 }
1796
1797 for name in inject_param_names {
1798 if let Some(col_type) = column_map.get(name) {
1799 native.insert(name.to_string(), native_column(name, col_type));
1800 }
1801 }
1802
1803 (native, arg_fallbacks)
1804}
1805
1806#[cfg(test)]
1807#[path = "database_validator_tests.rs"]
1808mod database_validator_tests;