use fraiseql_core::{
db::{DatabaseAdapter, quote_postgres_identifier},
schema::{CompiledSchema, SourceKind, SourceProbe, sql_source_probes},
};
fn existence_sql(probe: &SourceProbe) -> String {
match probe.kind {
SourceKind::Relation => {
if let Some(s) = &probe.schema {
format!(
"SELECT EXISTS(SELECT 1 FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = '{}' AND c.relname = '{}') AS source_exists",
s.replace('\'', "''"),
probe.name.replace('\'', "''")
)
} else {
let literal = quote_postgres_identifier(&probe.name).replace('\'', "''");
format!("SELECT to_regclass('{literal}') IS NOT NULL AS source_exists")
}
},
SourceKind::Function => {
let name = probe.name.replace('\'', "''");
match &probe.schema {
Some(s) => {
let schema = s.replace('\'', "''");
format!(
"SELECT EXISTS(SELECT 1 FROM pg_proc p \
JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = '{schema}' AND p.proname = '{name}' \
AND p.prokind IN ('f','p')) AS source_exists"
)
},
None => format!(
"SELECT EXISTS(SELECT 1 FROM pg_proc p \
JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE p.proname = '{name}' \
AND n.nspname = ANY(current_schemas(false)) \
AND p.prokind IN ('f','p')) AS source_exists"
),
}
},
}
}
pub async fn find_unbacked_sources<A: DatabaseAdapter>(
schema: &CompiledSchema,
adapter: &A,
) -> fraiseql_core::Result<Vec<SourceProbe>> {
let mut unbacked = Vec::new();
for probe in sql_source_probes(schema) {
let rows = adapter.execute_raw_query(&existence_sql(&probe)).await?;
let exists = rows
.first()
.and_then(|r| r.get("source_exists"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !exists {
unbacked.push(probe);
}
}
Ok(unbacked)
}
fn privilege_sql(probe: &SourceProbe) -> String {
let name = probe.name.replace('\'', "''");
let schema_filter = probe.schema.as_ref().map_or_else(
|| "n.nspname = ANY(current_schemas(false))".to_string(),
|s| format!("n.nspname = '{}'", s.replace('\'', "''")),
);
match probe.kind {
SourceKind::Relation => format!(
"SELECT CASE \
WHEN NOT bool_or(has_schema_privilege(n.oid, 'USAGE')) \
THEN 'USAGE on schema ' || min(n.nspname) \
WHEN NOT bool_or(has_table_privilege(c.oid, 'SELECT')) THEN 'SELECT' \
END AS missing \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE {schema_filter} AND c.relname = '{name}' HAVING count(*) > 0"
),
SourceKind::Function => format!(
"SELECT CASE \
WHEN NOT bool_or(has_schema_privilege(n.oid, 'USAGE')) \
THEN 'USAGE on schema ' || min(n.nspname) \
WHEN NOT bool_or(has_function_privilege(p.oid, 'EXECUTE')) THEN 'EXECUTE' \
END AS missing \
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace \
WHERE {schema_filter} AND p.proname = '{name}' AND p.prokind IN ('f','p') \
HAVING count(*) > 0"
),
}
}
pub async fn find_unusable_sources<A: DatabaseAdapter>(
schema: &CompiledSchema,
adapter: &A,
) -> fraiseql_core::Result<Vec<(SourceProbe, String)>> {
let mut unusable = Vec::new();
for probe in sql_source_probes(schema) {
let rows = adapter.execute_raw_query(&privilege_sql(&probe)).await?;
let missing = rows
.first()
.and_then(|r| r.get("missing"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if let Some(missing) = missing {
unusable.push((probe, missing));
}
}
Ok(unusable)
}
pub async fn connected_role<A: DatabaseAdapter>(adapter: &A) -> fraiseql_core::Result<String> {
let rows = adapter.execute_raw_query("SELECT current_user::text AS role").await?;
Ok(rows
.first()
.and_then(|r| r.get("role"))
.and_then(serde_json::Value::as_str)
.unwrap_or("the connected role")
.to_string())
}
#[must_use]
pub fn format_source_problems(
unbacked: &[SourceProbe],
unusable: &[(SourceProbe, String)],
role: &str,
) -> String {
use std::fmt::Write as _;
let mut out = if unbacked.is_empty() {
String::from(
"fail-fast sql_source validation failed — declared sources cannot be used by the \
server's database role:",
)
} else {
format_unbacked(unbacked)
};
for (probe, missing) in unusable {
let kind = match probe.kind {
SourceKind::Relation => "relation",
SourceKind::Function => "function",
};
let _ =
write!(out, "\n - {} ({kind}): {missing} not granted to {role}", probe.display_name());
}
out
}
#[must_use]
pub fn format_unbacked(unbacked: &[SourceProbe]) -> String {
use std::fmt::Write as _;
let mut out = String::from(
"fail-fast sql_source validation failed — declared sources are not backed by the database:",
);
for probe in unbacked {
let kind = match probe.kind {
SourceKind::Relation => "relation",
SourceKind::Function => "function",
};
let _ = write!(out, "\n - {} ({kind}) does not exist", probe.display_name());
}
out
}
#[cfg(test)]
#[path = "sql_source_check_tests.rs"]
mod sql_source_check_tests;