Skip to main content

fraiseql_core/schema/
source_probe.rs

1//! The single, cross-crate definition of "what counts as a backed `sql_source`".
2//!
3//! A compiled schema declares, per operation, a `sql_source`: a **relation** (the
4//! view/table a query reads) or a **function** (the SQL function a mutation calls).
5//! Two separate consumers must agree on which database objects have to exist for a
6//! schema to be servable:
7//!
8//! - `fraiseql-cli` — `compile --database` / `doctor` / `validate --against-db` (executes each
9//!   probe through `pg_catalog`/the introspector).
10//! - `fraiseql-server` — the opt-in fail-fast boot check (executes each probe through the live
11//!   `DatabaseAdapter`).
12//!
13//! [`sql_source_probes`] turns a [`CompiledSchema`] into the work-list once, so the
14//! CLI gate and the server boot check cannot drift on the definition of "backed".
15//! Each side runs the list with its own connector.
16
17use crate::schema::{CompiledSchema, MutationOperation};
18
19/// Whether a `sql_source` names a relation (query backing) or a function
20/// (mutation backing). They are resolved differently: a relation via
21/// `to_regclass` / the relation catalog, a function via `pg_proc`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SourceKind {
24    /// A table / view / materialized view a query reads from.
25    Relation,
26    /// A SQL function a mutation calls.
27    Function,
28}
29
30/// One database object a schema declares it depends on, parsed from a `sql_source`.
31///
32/// The identifier is kept **verbatim** (case-sensitive): the runtime resolves it
33/// through `quote_postgres_identifier`, so a probe must too.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SourceProbe {
36    /// Explicit schema qualifier (`events` in `events.v_log`), or `None` for a
37    /// bare name resolved against the connection `search_path`.
38    pub schema: Option<String>,
39    /// The relation or function name, verbatim (no case-folding).
40    pub name:   String,
41    /// Whether to resolve `name` as a relation or a function.
42    pub kind:   SourceKind,
43}
44
45impl SourceProbe {
46    /// Render the probe as a (possibly schema-qualified) identifier — the form a
47    /// human-facing diagnostic shows (e.g. `events.v_log`, `app.create_order`).
48    #[must_use]
49    pub fn display_name(&self) -> String {
50        match &self.schema {
51            Some(s) => format!("{s}.{}", self.name),
52            None => self.name.clone(),
53        }
54    }
55}
56
57/// Split a possibly schema-qualified `sql_source` into `(schema?, name)`.
58///
59/// Naive `split_once('.')`, matching the two existing splitters in `fraiseql-cli`
60/// (`database_validator::split_schema_qualified` and `pg_catalog::split_qualified`)
61/// so all three agree. A quoted identifier containing a literal dot is out of
62/// scope — bare/dotted names only, kept verbatim.
63fn split_source(sql_source: &str) -> (Option<String>, String) {
64    match sql_source.split_once('.') {
65        Some((schema, name)) => (Some(schema.to_string()), name.to_string()),
66        None => (None, sql_source.to_string()),
67    }
68}
69
70/// Resolve a mutation's backing function name: its explicit `sql_source`, else the
71/// operation's non-empty table. `None` ⇒ not SQL-backed (federation / `Custom`
72/// without a table) and therefore not probed. Mirrors the `#397` mutation-contract
73/// `resolve_sql_source`.
74const fn mutation_source(mutation: &crate::schema::MutationDefinition) -> Option<&str> {
75    if let Some(src) = &mutation.sql_source {
76        return Some(src.as_str());
77    }
78    match &mutation.operation {
79        MutationOperation::Insert { table }
80        | MutationOperation::Update { table }
81        | MutationOperation::Delete { table }
82            if !table.is_empty() =>
83        {
84            Some(table.as_str())
85        },
86        _ => None,
87    }
88}
89
90/// Build the work-list of database objects a compiled schema must be backed by.
91///
92/// Queries contribute a [`SourceKind::Relation`] probe (their `sql_source` view),
93/// mutations a [`SourceKind::Function`] probe (their `sql_source`, or operation
94/// table). Operations with no SQL source (federation / non-SQL) are skipped — they
95/// have nothing to probe. This is the single definition both the CLI existence
96/// gate (#485) and the server fail-fast boot check (#487) consume.
97#[must_use]
98pub fn sql_source_probes(schema: &CompiledSchema) -> Vec<SourceProbe> {
99    let mut probes = Vec::with_capacity(schema.queries.len() + schema.mutations.len());
100
101    for query in &schema.queries {
102        if let Some(source) = &query.sql_source {
103            let (schema_part, name) = split_source(source);
104            probes.push(SourceProbe {
105                schema: schema_part,
106                name,
107                kind: SourceKind::Relation,
108            });
109        }
110    }
111
112    for mutation in &schema.mutations {
113        if let Some(source) = mutation_source(mutation) {
114            let (schema_part, name) = split_source(source);
115            probes.push(SourceProbe {
116                schema: schema_part,
117                name,
118                kind: SourceKind::Function,
119            });
120        }
121    }
122
123    probes
124}
125
126#[cfg(test)]
127#[path = "source_probe_tests.rs"]
128mod source_probe_tests;