use std::{
collections::{HashMap, HashSet},
fmt,
};
use fraiseql_core::{
db::{
DatabaseType,
introspector::{DatabaseIntrospector, RelationInfo},
},
schema::{
CompiledSchema, FieldType, PaginationOrder, SourceKind, SourceProbe, sql_source_probes,
},
};
use fraiseql_db::postgres::PostgresTlsConfig;
use super::mutation_contract::Severity;
pub struct DatabaseValidationReport {
pub warnings: Vec<DatabaseWarning>,
pub native_columns: HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>>,
pub pagination_orders: HashMap<String, PaginationOrder>,
}
#[derive(Debug)]
pub enum DatabaseWarning {
MissingRelation {
query_name: String,
sql_source: String,
},
MissingFunction {
mutation_name: String,
sql_source: String,
},
MissingTypeSource {
type_name: String,
sql_source: String,
},
MissingAdditionalView {
query_name: String,
view_name: String,
},
MissingJsonColumn {
query_name: String,
sql_source: String,
column_name: String,
},
WrongJsonColumnType {
query_name: String,
sql_source: String,
column_name: String,
actual_type: String,
},
MissingPaginationColumn {
query_name: String,
sql_source: String,
column_name: String,
},
MissingIdentityColumn {
query_name: String,
sql_source: String,
pk_column: String,
},
SelfOrderingView {
query_name: String,
sql_source: String,
},
MissingCursorColumn {
query_name: String,
sql_source: String,
column_name: String,
},
MissingJsonKey {
query_name: String,
type_name: String,
sql_source: String,
json_column: String,
field_name: String,
json_key: String,
field_required: bool,
},
NativeColumnFallback {
query_name: String,
sql_source: String,
arg_name: String,
},
UnresolvableFilter {
query_name: String,
sql_source: String,
filter: QueryFilter,
key: String,
reason: InjectMiss,
},
TypeConvertibility {
query_name: String,
sql_source: String,
arg_name: String,
graphql_type: String,
column_type: String,
},
TemporalKeyInDocument {
query_name: String,
sql_source: String,
field: String,
column: String,
column_exists: bool,
},
TviewIndexMissing {
query_name: String,
tview: String,
keys: Vec<String>,
options: serde_json::Value,
},
NativeKeyDisagreement {
query_name: String,
sql_source: String,
column: String,
json_column: String,
sampled: u64,
disagreeing: u64,
},
}
impl DatabaseWarning {
#[must_use]
pub fn statement(&self) -> Option<String> {
let Self::TviewIndexMissing { tview, options, .. } = self else {
return None;
};
let literal = |text: &str| text.replace('\'', "''");
Some(format!(
"SELECT tviews.pg_tviews_create_or_replace(format('%I.%I', schema, name), query, \
'{}'::jsonb) FROM tviews.registry WHERE format('%I.%I', schema, name) = '{}';",
literal(&options.to_string()),
literal(tview)
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryFilter {
Inject(String),
Argument(String),
}
impl fmt::Display for QueryFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Inject(name) => write!(f, "inject_params['{name}']"),
Self::Argument(name) => write!(f, "argument `{name}`"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectMiss {
NoJsonColumn,
NotInSampledRows {
json_column: String,
},
EmptyRelation {
json_column: String,
},
}
impl DatabaseWarning {
#[must_use]
pub const fn severity(&self) -> Severity {
match self {
Self::MissingRelation { .. }
| Self::MissingFunction { .. }
| Self::MissingAdditionalView { .. }
| Self::MissingJsonColumn { .. }
| Self::WrongJsonColumnType { .. }
| Self::MissingCursorColumn { .. }
| Self::MissingPaginationColumn { .. }
| Self::MissingIdentityColumn { .. }
| Self::TypeConvertibility { .. }
| Self::NativeKeyDisagreement { .. } => Severity::Error,
Self::UnresolvableFilter { reason, .. } => match reason {
InjectMiss::NoJsonColumn | InjectMiss::NotInSampledRows { .. } => Severity::Error,
InjectMiss::EmptyRelation { .. } => Severity::Warn,
},
Self::MissingJsonKey { field_required, .. } => {
if *field_required {
Severity::Error
} else {
Severity::Warn
}
},
Self::MissingTypeSource { .. }
| Self::NativeColumnFallback { .. }
| Self::SelfOrderingView { .. }
| Self::TemporalKeyInDocument { .. }
| Self::TviewIndexMissing { .. } => Severity::Warn,
}
}
}
impl fmt::Display for DatabaseWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingRelation {
query_name,
sql_source,
} => {
write!(
f,
"query `{query_name}`: sql_source `{sql_source}` does not exist in database"
)
},
Self::MissingFunction {
mutation_name,
sql_source,
} => {
write!(
f,
"mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
)
},
Self::MissingTypeSource {
type_name,
sql_source,
} => {
write!(
f,
"type `{type_name}`: sql_source `{sql_source}` does not exist in the \
database — if `{type_name}` is an embedded value object it should declare \
no source (a value object has no independent identity; it is fetched via \
its parent)"
)
},
Self::MissingAdditionalView {
query_name,
view_name,
} => {
write!(
f,
"query `{query_name}`: additional_view `{view_name}` does not exist in database"
)
},
Self::MissingJsonColumn {
query_name,
sql_source,
column_name,
} => {
write!(
f,
"query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
)
},
Self::WrongJsonColumnType {
query_name,
sql_source,
column_name,
actual_type,
} => {
write!(
f,
"query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
)
},
Self::MissingCursorColumn {
query_name,
sql_source,
column_name,
} => {
write!(
f,
"query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
)
},
Self::MissingPaginationColumn {
query_name,
sql_source,
column_name,
} => {
write!(
f,
"query `{query_name}`: pagination_order column `{column_name}` not found on \
`{sql_source}` — it is rendered into ORDER BY, so this query fails at \
request time"
)
},
Self::MissingIdentityColumn {
query_name,
sql_source,
pk_column,
} => {
write!(
f,
"query `{query_name}`: offset pages are ordered by the type's identity, and \
`{sql_source}` exposes neither `{pk_column}` nor an `id` column (ADR-0017 \
makes `id` a base column of every read relation). Expose one of them, or \
declare pagination_order"
)
},
Self::SelfOrderingView {
query_name,
sql_source,
} => {
write!(
f,
"query `{query_name}`: view `{sql_source}` carries its own ORDER BY, and \
this query does not declare pagination_order. An offset page will now be \
ordered by the entity identity instead, replacing the view's order. \
Declare pagination_order = \"none\" to keep it, or a column to confirm \
the new one"
)
},
Self::MissingJsonKey {
query_name,
type_name,
sql_source,
json_column,
field_name,
json_key,
field_required,
} => {
let requiredness = if *field_required {
"required"
} else {
"nullable"
};
write!(
f,
"query `{query_name}`: {requiredness} field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
)
},
Self::NativeColumnFallback {
query_name,
sql_source,
arg_name,
} => {
let key = fraiseql_core::utils::to_snake_case(arg_name);
write!(
f,
"query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
(`{sql_source}.data->>'{key}'`) — no native column `{key}` found on \
`{sql_source}`. Add a native column with an index for O(log n) lookup."
)
},
Self::UnresolvableFilter {
query_name,
sql_source,
filter,
key,
reason,
} => {
let why = match reason {
InjectMiss::NoJsonColumn => "has no JSON column to fall back on".to_string(),
InjectMiss::NotInSampledRows { json_column } => {
format!("and none of the sampled `{json_column}` rows carries the key")
},
InjectMiss::EmptyRelation { json_column } => format!(
"and is empty, so whether `{json_column}` carries the key cannot be checked"
),
};
let way_out = match filter {
QueryFilter::Inject(_) => {
"Add the column to the relation, or, if this data is not scoped by it, drop \
the parameter from this query (`exclude_inject_defaults` when it comes from \
[inject_defaults])"
},
QueryFilter::Argument(_) => {
"Add the column to the relation (or the key to its `data`), or rename the \
argument after the column it filters"
},
};
write!(
f,
"query `{query_name}`: {filter} filters `{sql_source}` on `{key}`, which has no \
`{key}` column {why} — the predicate would match no row and every caller would \
get an empty answer. {way_out}"
)
},
Self::TypeConvertibility {
query_name,
sql_source,
arg_name,
graphql_type,
column_type,
} => {
write!(
f,
"query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
`{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
Align the argument type with the column, or store the field in `data` for JSONB extraction."
)
},
Self::TemporalKeyInDocument {
query_name,
sql_source,
field,
column,
column_exists,
} => {
let step = if *column_exists {
format!("`{sql_source}` already has `{column}`: give the query")
} else {
format!(
"add `{column}` to the TVIEW's definition (with its own type, e.g. \
`timestamptz`, and an index), then give the query"
)
};
write!(
f,
"query `{query_name}`: `{field}` is read out of `{sql_source}`'s document, so \
every sort or filter on it casts each row's value and no index can serve it. \
{step} an argument named `{field}` so this compile maps it to the column \
(docs/performance.md, \"Use a Native Column\")."
)
},
Self::NativeKeyDisagreement {
query_name,
sql_source,
column,
json_column,
sampled,
disagreeing,
} => {
write!(
f,
"query `{query_name}`: is scoped by `{column}`, read from the native column \
at the root and from the `{json_column}` key `{column}` wherever the \
document is embedded, and {disagreeing} of {sampled} sampled \
`{sql_source}` rows hold a different value in each — an embedded document \
would be filtered on another value than its row. Build the key from the \
column (`jsonb_build_object('{column}', {column}, …)`)."
)
},
Self::TviewIndexMissing {
query_name,
tview,
keys,
..
} => {
write!(
f,
"query `{query_name}`: filters `{tview}`'s document on `{}` on every call, \
and no index of the TVIEW serves it. Declare one with the TVIEW, keeping its \
other options (a replace's options are its whole declaration: one without \
`indexes` drops them): {}",
keys.join("`, `"),
self.statement().unwrap_or_default()
)
},
}
}
}
pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
let lower = data_type.to_lowercase();
match db_type {
DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SqlFamily {
Numeric,
Boolean,
Uuid,
Text,
Temporal,
Json,
Other,
}
fn sql_type_family(sql_type: &str) -> SqlFamily {
let lower = sql_type.trim().to_lowercase();
let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
match base {
"smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
| "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
| "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
"boolean" | "bool" | "bit" => SqlFamily::Boolean,
"uuid" | "uniqueidentifier" => SqlFamily::Uuid,
"text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
| "character" | "clob" | "string" => SqlFamily::Text,
"date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
| "interval" | "smalldatetime" => SqlFamily::Temporal,
"json" | "jsonb" => SqlFamily::Json,
_ => SqlFamily::Other,
}
}
fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
let base = match field_type {
FieldType::List { item: inner, .. } => inner.as_ref(),
other => other,
};
let family = sql_type_family(sql_type);
if family == SqlFamily::Other {
return true;
}
match base {
FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
FieldType::Boolean => family == SqlFamily::Boolean,
FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
_ => true,
}
}
fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
match sql_source.split_once('.') {
Some((schema, table)) => (Some(schema), table),
None => (None, sql_source),
}
}
fn relation_exists(
schema_qualified: &HashMap<(String, String), RelationInfo>,
unqualified: &HashMap<String, Vec<String>>,
sql_source: &str,
) -> bool {
let (schema, name) = split_schema_qualified(sql_source);
if let Some(s) = schema {
schema_qualified.contains_key(&(s.to_string(), name.to_string()))
} else {
unqualified.contains_key(name)
}
}
async fn relation_source_exists(
introspector: &impl DatabaseIntrospector,
schema_qualified: &HashMap<(String, String), RelationInfo>,
unqualified: &HashMap<String, Vec<String>>,
sql_source: &str,
) -> fraiseql_core::Result<bool> {
let (schema, name) = split_schema_qualified(sql_source);
if let Some(s) = schema {
if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
return Ok(exists);
}
}
Ok(relation_exists(schema_qualified, unqualified, sql_source))
}
async fn function_source_exists(
introspector: &impl DatabaseIntrospector,
sql_source: &str,
) -> fraiseql_core::Result<bool> {
let (schema, name) = split_schema_qualified(sql_source);
Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
}
pub async fn validate_schema_against_database(
schema: &CompiledSchema,
introspector: &impl DatabaseIntrospector,
) -> fraiseql_core::Result<DatabaseValidationReport> {
const AUTO_PARAM_NAMES: &[&str] = &[
"where", "limit", "offset", "orderBy", "first", "last", "after", "before",
];
let mut warnings = Vec::new();
let mut native_columns: HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>> =
HashMap::new();
let mut pagination_orders: HashMap<String, PaginationOrder> = HashMap::new();
let db_type = introspector.database_type();
let relations = introspector.list_relations().await?;
let (schema_qualified, unqualified) = build_relation_maps(&relations);
for query in schema.queries.iter().filter(|q| q.sql_source_kind.is_relation()) {
if let Some(ref source) = query.sql_source {
if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
.await?
{
warnings.push(DatabaseWarning::MissingRelation {
query_name: query.name.clone(),
sql_source: source.clone(),
});
continue; }
let columns = introspector.get_columns(source).await?;
let not_null: std::collections::HashSet<String> = columns
.iter()
.filter(|(_, _, nullable)| !nullable)
.map(|(name, _, _)| name.clone())
.collect();
let column_map: HashMap<String, String> =
columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
let jsonb_col = &query.jsonb_column;
if !jsonb_col.is_empty() {
if let Some(actual_type) = column_map.get(jsonb_col) {
if !is_json_type(actual_type, db_type) {
warnings.push(DatabaseWarning::WrongJsonColumnType {
query_name: query.name.clone(),
sql_source: source.clone(),
column_name: jsonb_col.clone(),
actual_type: actual_type.clone(),
});
}
} else {
warnings.push(DatabaseWarning::MissingJsonColumn {
query_name: query.name.clone(),
sql_source: source.clone(),
column_name: jsonb_col.clone(),
});
}
}
if query.relay {
if let Some(ref cursor_col) = query.relay_cursor_column {
if !column_map.contains_key(cursor_col) {
warnings.push(DatabaseWarning::MissingCursorColumn {
query_name: query.name.clone(),
sql_source: source.clone(),
column_name: cursor_col.clone(),
});
}
}
}
match &query.pagination_order {
Some(PaginationOrder::Column(col)) if !column_map.contains_key(col) => {
warnings.push(DatabaseWarning::MissingPaginationColumn {
query_name: query.name.clone(),
sql_source: source.clone(),
column_name: col.clone(),
});
},
Some(PaginationOrder::Column(_)) => {},
Some(PaginationOrder::Identity) => {
let pk = pk_column_name(&query.return_type);
if column_map.contains_key(&pk) {
pagination_orders.insert(query.name.clone(), PaginationOrder::Column(pk));
} else if !column_map.contains_key("id") {
warnings.push(DatabaseWarning::MissingIdentityColumn {
query_name: query.name.clone(),
sql_source: source.clone(),
pk_column: pk,
});
}
},
None => {},
}
if query.pagination_order.is_some()
&& introspector
.get_view_definition(source)
.await?
.is_some_and(|body| orders_itself(&body))
{
warnings.push(DatabaseWarning::SelfOrderingView {
query_name: query.name.clone(),
sql_source: source.clone(),
});
}
if !jsonb_col.is_empty() {
let json_type_ok =
column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
if json_type_ok {
validate_json_keys(
schema,
query,
source,
jsonb_col,
introspector,
source, &mut warnings,
)
.await?;
}
}
let direct_args: Vec<&str> = query
.arguments
.iter()
.filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
.map(|a| a.name.as_str())
.collect();
let (query_native, arg_fallbacks) = detect_query_native_columns(
&direct_args,
query.inject_params.keys().map(String::as_str),
&column_map,
¬_null,
);
if !jsonb_col.is_empty()
&& column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type))
{
check_native_keys_agree(
schema,
query,
source,
&query_native,
introspector,
&mut warnings,
)
.await?;
}
let mut unresolved: Vec<(QueryFilter, String)> = query
.inject_params
.keys()
.filter(|param| !column_map.contains_key(*param))
.map(|param| (QueryFilter::Inject(param.clone()), param.clone()))
.collect();
unresolved.extend(arg_fallbacks.into_iter().map(|arg| {
let key = fraiseql_core::utils::to_snake_case(&arg);
(QueryFilter::Argument(arg), key)
}));
if !unresolved.is_empty() {
if let Some(declaration) = introspector.tview_declaration(source).await? {
warnings.extend(tview_index_advice(query, &declaration, &unresolved));
}
}
check_filters_resolvable(
query,
source,
unresolved,
&column_map,
db_type,
introspector,
&mut warnings,
)
.await?;
for arg in &query.arguments {
if let Some(native) = query_native.get(&arg.name) {
let col_type = &native.pg_type;
if !arg_type_convertible(&arg.arg_type, col_type) {
warnings.push(DatabaseWarning::TypeConvertibility {
query_name: query.name.clone(),
sql_source: source.clone(),
arg_name: arg.name.clone(),
graphql_type: arg.arg_type.to_graphql_string(),
column_type: col_type.clone(),
});
}
}
}
if !jsonb_col.is_empty() {
warnings.extend(temporal_keys_in_document(
schema,
query,
source,
&query_native,
&column_map,
));
}
if !query_native.is_empty() {
native_columns.insert(query.name.clone(), query_native);
}
for view in &query.additional_views {
if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
.await?
{
warnings.push(DatabaseWarning::MissingAdditionalView {
query_name: query.name.clone(),
view_name: view.clone(),
});
}
}
}
}
for mutation in &schema.mutations {
if let Some(ref source) = mutation.sql_source {
if !function_source_exists(introspector, source).await? {
warnings.push(DatabaseWarning::MissingFunction {
mutation_name: mutation.name.clone(),
sql_source: source.clone(),
});
}
}
}
for ty in &schema.types {
let source = ty.sql_source.as_str();
if ty.is_error || source.is_empty() {
continue;
}
if !relation_source_exists(introspector, &schema_qualified, &unqualified, source).await? {
warnings.push(DatabaseWarning::MissingTypeSource {
type_name: ty.name.to_string(),
sql_source: source.to_string(),
});
}
}
unify_tview_index_advice(&mut warnings);
Ok(DatabaseValidationReport {
warnings,
native_columns,
pagination_orders,
})
}
fn unify_tview_index_advice(warnings: &mut [DatabaseWarning]) {
let mut by_tview: HashMap<String, serde_json::Value> = HashMap::new();
for warning in warnings.iter() {
let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning else {
continue;
};
let advised = options.get("indexes").and_then(serde_json::Value::as_array);
match by_tview.get_mut(tview) {
None => {
by_tview.insert(tview.clone(), options.clone());
},
Some(merged) => {
let Some(indexes) = merged.get_mut("indexes").and_then(|i| i.as_array_mut()) else {
continue;
};
for index in advised.into_iter().flatten() {
if !indexes.contains(index) {
indexes.push(index.clone());
}
}
},
}
}
for warning in warnings.iter_mut() {
if let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning {
if let Some(merged) = by_tview.get(tview) {
options.clone_from(merged);
}
}
}
}
fn temporal_keys_in_document(
schema: &CompiledSchema,
query: &fraiseql_core::schema::QueryDefinition,
source: &str,
query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
column_map: &HashMap<String, String>,
) -> Vec<DatabaseWarning> {
let relation = source.rsplit('.').next().unwrap_or(source);
if !relation.starts_with("tv_")
|| !(query.auto_params.has_order_by || query.auto_params.has_where)
{
return Vec::new();
}
let Some(ty) = schema.find_type(&query.return_type) else {
return Vec::new();
};
ty.fields
.iter()
.filter(|f| matches!(f.field_type, FieldType::DateTime | FieldType::Date | FieldType::Time))
.filter(|f| !query_native.contains_key(f.name.as_str()))
.map(|f| {
let column = fraiseql_core::utils::to_snake_case(f.name.as_str());
DatabaseWarning::TemporalKeyInDocument {
query_name: query.name.clone(),
sql_source: source.to_string(),
field: f.name.to_string(),
column_exists: column_map.contains_key(&column),
column,
}
})
.collect()
}
fn tview_index_advice(
query: &fraiseql_core::schema::QueryDefinition,
declaration: &fraiseql_db::TviewDeclaration,
filters: &[(QueryFilter, String)],
) -> Option<DatabaseWarning> {
if filters.is_empty() {
return None;
}
let keys: Vec<serde_json::Value> = filters
.iter()
.map(|(filter, key)| {
let arg_type = match filter {
QueryFilter::Argument(name) => {
query.arguments.iter().find(|a| &a.name == name).map(|a| &a.arg_type)
},
QueryFilter::Inject(_) => None,
};
match arg_type {
Some(FieldType::Int | FieldType::Float) => {
serde_json::json!({ "path": key, "type": "numeric" })
},
Some(FieldType::Boolean) => serde_json::json!({ "path": key, "type": "boolean" }),
_ => serde_json::json!(key),
}
})
.collect();
let declared: Vec<serde_json::Value> = declaration
.options
.get("indexes")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
if declared.iter().any(|index| index_leads_with(index, &keys)) {
return None;
}
let mut options = declaration.options.clone();
let mut indexes = declared;
indexes.push(serde_json::Value::Array(keys));
options
.as_object_mut()?
.insert("indexes".to_string(), serde_json::Value::Array(indexes));
Some(DatabaseWarning::TviewIndexMissing {
query_name: query.name.clone(),
tview: declaration.tview.clone(),
keys: filters.iter().map(|(_, key)| key.clone()).collect(),
options,
})
}
fn index_leads_with(index: &serde_json::Value, keys: &[serde_json::Value]) -> bool {
fn key(value: &serde_json::Value) -> Option<(&str, &str)> {
match value {
serde_json::Value::String(path) => Some((path, "text")),
serde_json::Value::Object(key) => Some((
key.get("path")?.as_str()?,
key.get("type").and_then(serde_json::Value::as_str).unwrap_or("text"),
)),
_ => None,
}
}
let Some(index) = index.as_array() else {
return false;
};
index.len() >= keys.len()
&& index.iter().zip(keys).all(|(a, b)| key(a).is_some_and(|a| Some(a) == key(b)))
}
fn pk_column_name(return_type: &str) -> String {
format!("pk_{}", fraiseql_core::utils::to_snake_case(return_type))
}
fn orders_itself(view_body: &str) -> bool {
view_body.contains("ORDER BY")
}
fn build_relation_maps(
relations: &[RelationInfo],
) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
let mut schema_qualified = HashMap::new();
let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
for rel in relations {
schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
}
(schema_qualified, unqualified)
}
pub async fn find_unbacked_sources(
schema: &CompiledSchema,
introspector: &impl DatabaseIntrospector,
) -> fraiseql_core::Result<Vec<SourceProbe>> {
let relations = introspector.list_relations().await?;
let (schema_qualified, unqualified) = build_relation_maps(&relations);
let mut unbacked = Vec::new();
for probe in sql_source_probes(schema) {
let exists = match probe.kind {
SourceKind::Function => introspector
.function_exists(probe.schema.as_deref(), &probe.name)
.await?
.unwrap_or(true),
SourceKind::Relation => {
if let Some(s) = &probe.schema {
match introspector.qualified_relation_exists(s, &probe.name).await? {
Some(exists) => exists,
None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
}
} else {
unqualified.contains_key(&probe.name)
}
},
};
if !exists {
unbacked.push(probe);
}
}
Ok(unbacked)
}
async fn validate_json_keys(
schema: &CompiledSchema,
query: &fraiseql_core::schema::QueryDefinition,
source: &str,
jsonb_col: &str,
introspector: &impl DatabaseIntrospector,
table_name: &str,
warnings: &mut Vec<DatabaseWarning>,
) -> fraiseql_core::Result<()> {
let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
if samples.is_empty() {
return Ok(());
}
let mut all_keys = HashSet::new();
for sample in &samples {
if let serde_json::Value::Object(map) = sample {
for key in map.keys() {
all_keys.insert(key.clone());
}
}
}
if all_keys.is_empty() {
return Ok(());
}
let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
if let Some(type_def) = type_def {
for field in &type_def.fields {
let field_str = field.name.as_str();
let json_key = fraiseql_core::utils::to_snake_case(field_str);
if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
continue;
}
if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
warnings.push(DatabaseWarning::MissingJsonKey {
query_name: query.name.clone(),
type_name: type_def.name.as_str().to_string(),
sql_source: source.to_string(),
json_column: jsonb_col.to_string(),
field_name: field_str.to_string(),
json_key,
field_required: !field.nullable,
});
}
}
}
Ok(())
}
pub enum AnyIntrospector {
Postgres(fraiseql_core::db::PostgresIntrospector),
}
impl DatabaseIntrospector for AnyIntrospector {
async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
match self {
Self::Postgres(i) => i.list_fact_tables().await,
}
}
async fn get_columns(
&self,
table_name: &str,
) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
match self {
Self::Postgres(i) => i.get_columns(table_name).await,
}
}
async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
match self {
Self::Postgres(i) => i.get_indexed_columns(table_name).await,
}
}
fn database_type(&self) -> DatabaseType {
match self {
Self::Postgres(i) => i.database_type(),
}
}
async fn get_sample_jsonb(
&self,
table_name: &str,
column_name: &str,
) -> fraiseql_core::Result<Option<serde_json::Value>> {
match self {
Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
}
}
async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
match self {
Self::Postgres(i) => i.list_relations().await,
}
}
async fn get_sample_json_rows(
&self,
table_name: &str,
column_name: &str,
limit: usize,
) -> fraiseql_core::Result<Vec<serde_json::Value>> {
match self {
Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
}
}
async fn column_key_disagreements(
&self,
relation: &str,
column: &str,
json_column: &str,
key: &str,
limit: usize,
) -> fraiseql_core::Result<Option<(u64, u64)>> {
match self {
Self::Postgres(i) => {
i.column_key_disagreements(relation, column, json_column, key, limit).await
},
}
}
async fn function_exists(
&self,
schema: Option<&str>,
name: &str,
) -> fraiseql_core::Result<Option<bool>> {
match self {
Self::Postgres(i) => i.function_exists(schema, name).await,
}
}
async fn qualified_relation_exists(
&self,
schema: &str,
name: &str,
) -> fraiseql_core::Result<Option<bool>> {
match self {
Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
}
}
async fn get_view_definition(&self, relation: &str) -> fraiseql_core::Result<Option<String>> {
match self {
Self::Postgres(i) => i.get_view_definition(relation).await,
}
}
async fn tview_declaration(
&self,
relation: &str,
) -> fraiseql_core::Result<Option<fraiseql_db::TviewDeclaration>> {
match self {
Self::Postgres(i) => i.tview_declaration(relation).await,
}
}
}
pub fn refuse_removed_engine_url(db_url: &str) -> anyhow::Result<()> {
let removed_engine = db_url.starts_with("mysql")
|| db_url.starts_with("mariadb")
|| db_url.starts_with("sqlite")
|| db_url.starts_with("mssql")
|| db_url.starts_with("server=")
|| std::path::Path::new(db_url).extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite")
});
if removed_engine {
anyhow::bail!(
"This database URL targets an engine whose support was removed: FraiseQL is \
PostgreSQL-only (G2 decision on #374; the non-PostgreSQL dialects were never \
production-correct, see #721/#799). Use a postgresql:// URL."
);
}
Ok(())
}
pub async fn create_introspector(
db_url: &str,
tls: &PostgresTlsConfig,
) -> anyhow::Result<AnyIntrospector> {
let pool = crate::connection::postgres_pool(db_url, "schema introspection", tls).await?;
Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
}
async fn check_filters_resolvable(
query: &fraiseql_core::schema::QueryDefinition,
source: &str,
unresolved: Vec<(QueryFilter, String)>,
column_map: &HashMap<String, String>,
db_type: DatabaseType,
introspector: &impl DatabaseIntrospector,
warnings: &mut Vec<DatabaseWarning>,
) -> fraiseql_core::Result<()> {
if unresolved.is_empty() {
return Ok(());
}
let jsonb_col = &query.jsonb_column;
let json_ok = !jsonb_col.is_empty()
&& column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
let carried: Option<HashSet<String>> = if json_ok {
let samples =
introspector.get_sample_json_rows(source, jsonb_col, INJECT_SAMPLE_ROWS).await?;
if samples.is_empty() {
None
} else {
Some(
samples
.iter()
.filter_map(serde_json::Value::as_object)
.flat_map(|row| row.keys().cloned())
.collect(),
)
}
} else {
None
};
for (filter, key) in unresolved {
let reason = match (&carried, json_ok) {
(_, false) => InjectMiss::NoJsonColumn,
(None, true) => InjectMiss::EmptyRelation {
json_column: jsonb_col.clone(),
},
(Some(keys), true) if !keys.contains(&key) => InjectMiss::NotInSampledRows {
json_column: jsonb_col.clone(),
},
(Some(_), true) => {
if let QueryFilter::Argument(arg_name) = filter {
warnings.push(DatabaseWarning::NativeColumnFallback {
query_name: query.name.clone(),
sql_source: source.to_string(),
arg_name,
});
}
continue;
},
};
warnings.push(DatabaseWarning::UnresolvableFilter {
query_name: query.name.clone(),
sql_source: source.to_string(),
filter,
key,
reason,
});
}
Ok(())
}
async fn check_native_keys_agree(
schema: &fraiseql_core::schema::CompiledSchema,
query: &fraiseql_core::schema::QueryDefinition,
source: &str,
query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
introspector: &impl DatabaseIntrospector,
warnings: &mut Vec<DatabaseWarning>,
) -> fraiseql_core::Result<()> {
let Some(declared) = schema.find_type(&query.return_type) else {
return Ok(());
};
for column in query.inject_params.keys().filter(|c| query_native.contains_key(*c)) {
let key = fraiseql_core::utils::to_snake_case(column);
let published = declared
.fields
.iter()
.any(|f| fraiseql_core::utils::to_snake_case(f.name.as_str()) == key);
if !published {
continue;
}
let compared = introspector
.column_key_disagreements(
source,
column,
&query.jsonb_column,
&key,
AGREEMENT_SAMPLE_ROWS,
)
.await?;
if let Some((sampled, disagreeing)) = compared.filter(|(_, d)| *d > 0) {
warnings.push(DatabaseWarning::NativeKeyDisagreement {
query_name: query.name.clone(),
sql_source: source.to_string(),
column: column.clone(),
json_column: query.jsonb_column.clone(),
sampled,
disagreeing,
});
}
}
Ok(())
}
const AGREEMENT_SAMPLE_ROWS: usize = 1000;
const INJECT_SAMPLE_ROWS: usize = 20;
fn detect_query_native_columns<'a>(
direct_arg_names: &[&str],
inject_param_names: impl Iterator<Item = &'a str>,
column_map: &HashMap<String, String>,
not_null: &std::collections::HashSet<String>,
) -> (HashMap<String, fraiseql_core::schema::NativeColumn>, Vec<String>) {
let native_column = |column: &str, col_type: &String| fraiseql_core::schema::NativeColumn {
pg_type: col_type.clone(),
not_null: not_null.contains(column),
};
let mut native: HashMap<String, fraiseql_core::schema::NativeColumn> = HashMap::new();
let mut arg_fallbacks: Vec<String> = Vec::new();
for arg in direct_arg_names {
let column = fraiseql_core::utils::to_snake_case(arg);
if let Some(col_type) = column_map.get(&column) {
native.insert((*arg).to_string(), native_column(&column, col_type));
} else {
arg_fallbacks.push((*arg).to_string());
}
}
for name in inject_param_names {
if let Some(col_type) = column_map.get(name) {
native.insert(name.to_string(), native_column(name, col_type));
}
}
(native, arg_fallbacks)
}
#[cfg(test)]
#[path = "database_validator_tests.rs"]
mod database_validator_tests;