use std::collections::{HashMap, HashSet};
use anyhow::{Context, Result, bail};
use fraiseql_core::schema::{
ArgumentDefinition, AutoParams, CursorType, InjectedParamSource, QueryDefinition,
};
use tracing::warn;
use super::SchemaConverter;
use crate::schema::intermediate::{
IntermediateArgument, IntermediateAutoParams, IntermediateQuery, IntermediateQueryDefaults,
};
impl SchemaConverter {
pub(super) fn parse_inject_source(raw: &str) -> Result<InjectedParamSource> {
if let Some(claim) = raw.strip_prefix("jwt:") {
if claim.is_empty() {
bail!("inject source 'jwt:' requires a claim name (e.g. 'jwt:org_id')");
}
return Ok(InjectedParamSource::Jwt(claim.to_owned()));
}
bail!(
"Unknown inject source prefix in {raw:?}. \
Supported: 'jwt:<claim_name>' (e.g. 'jwt:org_id', 'jwt:sub')"
)
}
pub(super) fn convert_inject_params(
op_name: &str,
arg_names: &HashSet<&str>,
inject: indexmap::IndexMap<String, String>,
) -> Result<indexmap::IndexMap<String, InjectedParamSource>> {
inject
.into_iter()
.map(|(name, source)| {
if arg_names.contains(name.as_str()) {
bail!(
"Operation '{op_name}': inject param '{name}' conflicts with an explicit \
argument name. Rename either the inject param or the argument."
);
}
Ok((name, Self::parse_inject_source(&source)?))
})
.collect()
}
pub(super) fn convert_query(
intermediate: IntermediateQuery,
defaults: &IntermediateQueryDefaults,
) -> Result<QueryDefinition> {
if intermediate.relay {
if !intermediate.returns_list {
anyhow::bail!(
"Query '{}': relay=true requires returns_list=true; \
Relay connections only apply to list queries",
intermediate.name
);
}
if intermediate.sql_source.is_none() {
anyhow::bail!(
"Query '{}': relay=true requires sql_source to be set; \
the compiler needs the view name to derive the cursor column \
(pk_{{snake_case(return_type)}})",
intermediate.name
);
}
}
let arguments = intermediate
.arguments
.into_iter()
.map(Self::convert_argument)
.collect::<Result<Vec<_>>>()
.context(format!("Failed to convert query '{}'", intermediate.name))?;
let arg_names: HashSet<&str> = arguments.iter().map(|a| a.name.as_str()).collect();
let inject_params =
Self::convert_inject_params(&intermediate.name, &arg_names, intermediate.inject)
.context(format!(
"Failed to convert inject params for query '{}'",
intermediate.name
))?;
let auto_params = if intermediate.relay {
AutoParams {
has_where: true,
has_order_by: true,
has_limit: false,
has_offset: false,
}
} else if intermediate.returns_list {
let resolved = Self::resolve_auto_params(intermediate.auto_params.as_ref(), defaults);
Self::warn_auto_params(&intermediate.name, &resolved);
resolved
} else {
AutoParams::default()
};
let deprecation = intermediate
.deprecated
.map(|d| fraiseql_core::schema::DeprecationInfo { reason: d.reason });
let relay_cursor_column = if intermediate.relay {
Some(format!("pk_{}", fraiseql_core::utils::to_snake_case(&intermediate.return_type)))
} else {
None
};
for view in &intermediate.additional_views {
if !Self::is_safe_sql_identifier(view) {
anyhow::bail!(
"Query '{}': additional_views entry {:?} is not a valid SQL identifier. \
Use only letters, digits, and underscores (must start with a letter or \
underscore).",
intermediate.name,
view
);
}
}
Ok(QueryDefinition {
name: intermediate.name,
return_type: intermediate.return_type,
returns_list: intermediate.returns_list,
nullable: intermediate.nullable,
arguments,
sql_source: intermediate.sql_source,
description: intermediate.description,
auto_params,
deprecation,
jsonb_column: intermediate.jsonb_column.unwrap_or_else(|| "data".to_string()),
relay: intermediate.relay,
relay_cursor_column,
relay_cursor_type: match intermediate.relay_cursor_type.as_deref() {
Some("uuid") => CursorType::Uuid,
_ => CursorType::Int64,
},
inject_params,
cache_ttl_seconds: intermediate.cache_ttl_seconds,
additional_views: intermediate.additional_views,
requires_role: intermediate.requires_role,
rest_path: None,
rest_method: None,
native_columns: HashMap::new(),
})
}
pub(super) fn convert_argument(
intermediate: IntermediateArgument,
) -> Result<ArgumentDefinition> {
let arg_type = Self::parse_field_type(&intermediate.arg_type)?;
let deprecation = intermediate
.deprecated
.map(|d| fraiseql_core::schema::DeprecationInfo { reason: d.reason });
let default_value = intermediate
.default
.map(|v| fraiseql_core::schema::GraphQLValue::from_json(&v))
.transpose()
.with_context(|| {
format!("invalid default value for argument `{}`", intermediate.name)
})?;
Ok(ArgumentDefinition {
name: intermediate.name,
arg_type,
nullable: intermediate.nullable,
default_value,
description: None,
deprecation,
})
}
pub(super) fn resolve_auto_params(
per_query: Option<&IntermediateAutoParams>,
defaults: &IntermediateQueryDefaults,
) -> AutoParams {
match per_query {
None => AutoParams {
has_where: defaults.where_clause,
has_order_by: defaults.order_by,
has_limit: defaults.limit,
has_offset: defaults.offset,
},
Some(p) => AutoParams {
has_where: p.where_clause.unwrap_or(defaults.where_clause),
has_order_by: p.order_by.unwrap_or(defaults.order_by),
has_limit: p.limit.unwrap_or(defaults.limit),
has_offset: p.offset.unwrap_or(defaults.offset),
},
}
}
pub(super) fn warn_auto_params(name: &str, params: &AutoParams) {
if !params.has_limit {
warn!(
query = name,
"List query '{name}' has limit disabled and is not a Relay query. \
This query is unbounded and may scan the full table. \
Consider a SQL-level LIMIT in the view, or use relay=true."
);
}
if params.has_limit && !params.has_order_by {
warn!(
query = name,
"List query '{name}' paginates (limit=true) without ordering \
(order_by=false). Results may be non-deterministic across pages. \
Enable order_by or add ORDER BY in the SQL view."
);
}
}
}