use crate::{
db::{DatabaseAdapter, quote_postgres_identifier},
error::{FraiseQLError, Result},
schema::{CompiledSchema, MutationOperation},
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
#[default]
Relation,
Function,
}
impl SourceKind {
#[must_use]
pub const fn is_relation(&self) -> bool {
matches!(self, Self::Relation)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceProbe {
pub schema: Option<String>,
pub name: String,
pub kind: SourceKind,
}
impl SourceProbe {
#[must_use]
pub fn display_name(&self) -> String {
match &self.schema {
Some(s) => format!("{s}.{}", self.name),
None => self.name.clone(),
}
}
}
fn split_source(sql_source: &str) -> (Option<String>, String) {
match sql_source.split_once('.') {
Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
None => (None, sql_source.to_string()),
}
}
#[must_use]
pub fn regclass_literal(sql_source: &str) -> String {
let ident = match split_source(sql_source) {
(Some(schema), name) => {
format!("{}.{}", quote_postgres_identifier(&schema), quote_postgres_identifier(&name))
},
(None, name) => quote_postgres_identifier(&name),
};
ident.replace('\'', "''")
}
const fn mutation_source(mutation: &crate::schema::MutationDefinition) -> Option<&str> {
if let Some(src) = &mutation.sql_source {
return Some(src.as_str());
}
match &mutation.operation {
MutationOperation::Insert { table }
| MutationOperation::Update { table }
| MutationOperation::Delete { table }
if !table.is_empty() =>
{
Some(table.as_str())
},
_ => None,
}
}
#[must_use]
pub fn sql_source_probes(schema: &CompiledSchema) -> Vec<SourceProbe> {
let mut probes = Vec::with_capacity(schema.queries.len() + schema.mutations.len());
for query in &schema.queries {
if let Some(source) = &query.sql_source {
let (schema_part, name) = split_source(source);
probes.push(SourceProbe {
schema: schema_part,
name,
kind: query.sql_source_kind,
});
}
}
for mutation in &schema.mutations {
if let Some(source) = mutation_source(mutation) {
let (schema_part, name) = split_source(source);
probes.push(SourceProbe {
schema: schema_part,
name,
kind: SourceKind::Function,
});
}
}
probes
}
pub async fn refuse_standby_unreadable_sources<A: DatabaseAdapter + ?Sized>(
adapter: &A,
schema: &CompiledSchema,
) -> Result<()> {
if !adapter.serves_reads_from_standbys() {
return Ok(());
}
let unreadable = unreadable_dependencies(adapter, schema).await?;
if unreadable.is_empty() {
return Ok(());
}
let statements = logged_statements(&unreadable);
Err(FraiseQLError::Configuration {
message: format!(
"Read replicas are configured, but {} source relation(s) of this schema depend \
on an UNLOGGED or temporary table, which a hot standby cannot read: every query \
over them would fail on every replica.\n - {}\nMake those tables LOGGED, at the \
cost of WAL for every write to them, or remove `read_replica_urls`:\n {}\nA \
pg_tviews TVIEW is LOGGED unless its `logged` option is false; `SET LOGGED` also \
sets the option. A temporary table cannot be read from a replica at all.",
unreadable.len(),
unreadable.iter().map(Unreadable::described).collect::<Vec<_>>().join("\n - "),
statements.join("\n ")
),
})
}
struct Unreadable {
source: String,
relation: String,
qualified: String,
temporary: bool,
}
impl Unreadable {
fn described(&self) -> String {
format!("{} — reads {}", self.source, self.relation)
}
}
fn logged_statements(unreadable: &[Unreadable]) -> Vec<String> {
let mut statements: Vec<String> = unreadable
.iter()
.filter(|d| !d.temporary)
.map(|d| format!("ALTER TABLE {} SET LOGGED;", d.qualified))
.collect();
statements.sort();
statements.dedup();
statements
}
pub async fn standby_unreadable_sources<A: DatabaseAdapter + ?Sized>(
adapter: &A,
schema: &CompiledSchema,
) -> Result<Vec<String>> {
Ok(unreadable_dependencies(adapter, schema)
.await?
.iter()
.map(Unreadable::described)
.collect())
}
async fn unreadable_dependencies<A: DatabaseAdapter + ?Sized>(
adapter: &A,
schema: &CompiledSchema,
) -> Result<Vec<Unreadable>> {
let mut unreadable = Vec::new();
for probe in sql_source_probes(schema) {
if probe.kind != SourceKind::Relation {
continue;
}
let literal = regclass_literal(&probe.display_name());
let sql = format!(
"WITH RECURSIVE rel(oid, kind) AS ( \
SELECT c.oid, c.relkind FROM pg_class c WHERE c.oid = to_regclass('{literal}') \
UNION \
SELECT base.oid, base.relkind FROM rel \
JOIN pg_rewrite r ON r.ev_class = rel.oid \
JOIN pg_depend d ON d.classid = 'pg_rewrite'::regclass AND d.objid = r.oid \
AND d.refclassid = 'pg_class'::regclass \
JOIN pg_class base ON base.oid = d.refobjid AND base.oid <> rel.oid \
WHERE rel.kind = 'v' \
) \
SELECT c.oid::regclass::text AS relation, \
format('%I.%I', n.nspname, c.relname) AS qualified, \
c.relpersistence = 't' AS temporary \
FROM rel JOIN pg_class c ON c.oid = rel.oid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relpersistence IN ('u', 't') \
ORDER BY 1"
);
for row in adapter.execute_raw_query(&sql).await? {
let text = |key: &str| row.get(key).and_then(serde_json::Value::as_str);
if let (Some(relation), Some(qualified)) = (text("relation"), text("qualified")) {
unreadable.push(Unreadable {
source: probe.display_name(),
relation: relation.to_string(),
qualified: qualified.to_string(),
temporary: row.get("temporary").and_then(serde_json::Value::as_bool)
== Some(true),
});
}
}
}
Ok(unreadable)
}
#[cfg(test)]
#[path = "source_probe_tests.rs"]
mod source_probe_tests;
pub async fn refuse_unproven_native_joins<A: DatabaseAdapter + ?Sized>(
adapter: &A,
schema: &CompiledSchema,
) -> Result<()> {
let columns: Vec<(String, &str, &str)> = schema
.types
.iter()
.flat_map(|t| t.relationships.iter().map(move |r| (t, r)))
.filter_map(|(t, r)| r.native_join.as_ref().map(|j| (t, r, j)))
.flat_map(|(t, r, j)| {
let name = format!("{}.{}", t.name, r.name);
[
(name.clone(), j.child_view.as_str(), j.foreign_key.as_str()),
(name, j.parent_view.as_str(), j.referenced_key.as_str()),
]
})
.collect();
if columns.is_empty() {
return Ok(());
}
let quote = |s: &str| s.replace('\'', "''");
let values: Vec<String> = columns
.iter()
.enumerate()
.map(|(i, (_, view, column))| {
format!("({i}, to_regclass('{}'), '{}')", regclass_literal(view), quote(column))
})
.collect();
let sql = format!(
"SELECT v.i AS i FROM (VALUES {}) AS v(i, rel, col) \
WHERE NOT EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = v.rel \
AND a.attname = v.col AND a.attnum > 0 AND NOT a.attisdropped) ORDER BY 1",
values.join(", ")
);
let missing: Vec<String> = adapter
.execute_raw_query(&sql)
.await?
.iter()
.filter_map(|row| row.get("i").and_then(serde_json::Value::as_u64))
.filter_map(|i| columns.get(usize::try_from(i).ok()?))
.map(|(rel, view, column)| format!("{rel}: {view}.{column}"))
.collect();
if missing.is_empty() {
return Ok(());
}
Err(FraiseQLError::Configuration {
message: format!(
"This schema was compiled with `--database` to join {} relationship column(s) this \
database does not have:\n - {}\nRecompile with `fraiseql compile --database` \
against this database.",
missing.len(),
missing.join("\n - ")
),
})
}