Skip to main content

fraiseql_cli/commands/federation/
graph.rs

1//! Federation graph export command.
2//!
3//! Usage: fraiseql federation graph <schema.compiled.json>... [--format=json|dot|mermaid]
4//!
5//! One compiled schema describes one subgraph: its `service_name`, the entities it owns
6//! and the ones it extends. The graph is drawn from the schemas given: one node per
7//! subgraph, and an edge `A → B` labelled `E` when `A` extends entity `E` and `B` owns
8//! it. An entity a subgraph extends that no input owns is listed under `unresolved`, so
9//! a missing input reads as a gap rather than as a smaller federation.
10//!
11//! This command used to read its input, discard it and print the same three-subgraph
12//! graph for anything, `{}` included (#1404).
13
14use std::{fmt::Display, str::FromStr};
15
16use anyhow::Result;
17use serde::Serialize;
18
19use super::check::load;
20use crate::output::CommandResult;
21
22const COMMAND: &str = "federation graph";
23
24/// Export format for federation graph
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum GraphFormat {
28    /// JSON format (machine-readable)
29    Json,
30    /// DOT format (Graphviz)
31    Dot,
32    /// Mermaid format (documentation)
33    Mermaid,
34}
35
36impl FromStr for GraphFormat {
37    type Err = String;
38
39    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
40        match s.to_lowercase().as_str() {
41            "json" => Ok(GraphFormat::Json),
42            "dot" => Ok(GraphFormat::Dot),
43            "mermaid" => Ok(GraphFormat::Mermaid),
44            other => Err(format!("Unknown format: {other}. Use json, dot, or mermaid")),
45        }
46    }
47}
48
49impl Display for GraphFormat {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            GraphFormat::Json => write!(f, "json"),
53            GraphFormat::Dot => write!(f, "dot"),
54            GraphFormat::Mermaid => write!(f, "mermaid"),
55        }
56    }
57}
58
59/// The federation the given subgraphs form.
60#[derive(Debug, Serialize)]
61pub struct FederationGraph {
62    /// One node per input, in input order.
63    pub subgraphs:  Vec<Subgraph>,
64    /// `from` extends `entity`, which `to` owns.
65    pub edges:      Vec<Edge>,
66    /// Entities a subgraph extends that no input owns.
67    pub unresolved: Vec<Unresolved>,
68}
69
70/// One subgraph: a compiled schema's `federation` block.
71#[derive(Debug, Serialize)]
72pub struct Subgraph {
73    /// The subgraph's `service_name`.
74    pub name:    String,
75    /// Entities this subgraph declares and resolves.
76    pub owns:    Vec<String>,
77    /// Entities this subgraph extends from another subgraph.
78    pub extends: Vec<String>,
79}
80
81/// `from` extends `entity`, which `to` owns.
82#[derive(Debug, Serialize)]
83pub struct Edge {
84    /// The extending subgraph.
85    pub from:   String,
86    /// The owning subgraph.
87    pub to:     String,
88    /// The entity linking them.
89    pub entity: String,
90}
91
92/// An entity a subgraph extends that none of the inputs owns.
93#[derive(Debug, Serialize)]
94pub struct Unresolved {
95    /// The extending subgraph.
96    pub subgraph: String,
97    /// The entity with no owner among the inputs.
98    pub entity:   String,
99}
100
101/// Run federation graph command.
102///
103/// # Errors
104///
105/// Returns an error if a schema file cannot be read, or the graph cannot be serialized.
106/// An input that is not a compiled schema, or has no federation block or `service_name`,
107/// is a `validation-failed` result naming it.
108pub fn run(schema_paths: &[String], format: GraphFormat) -> Result<CommandResult> {
109    let mut subgraphs = Vec::with_capacity(schema_paths.len());
110    let mut problems = Vec::new();
111    for path in schema_paths {
112        let schema = match load(path)? {
113            Ok(schema) => schema,
114            Err(problem) => {
115                problems.push(problem);
116                continue;
117            },
118        };
119        let Some(federation) = schema.federation.filter(|f| f.enabled) else {
120            problems
121                .push(format!("{path} declares no federation; compile it with federation enabled"));
122            continue;
123        };
124        let Some(name) = federation.service_name else {
125            problems.push(format!("{path} has no federation service_name"));
126            continue;
127        };
128        let (extended, owned): (Vec<_>, Vec<_>) =
129            federation.entities.into_iter().partition(|e| e.extends);
130        subgraphs.push(Subgraph {
131            name,
132            owns: owned.into_iter().map(|e| e.name).collect(),
133            extends: extended.into_iter().map(|e| e.name).collect(),
134        });
135    }
136    if !problems.is_empty() {
137        return Ok(CommandResult::validation_failed(COMMAND, problems, "INVALID_SCHEMA"));
138    }
139
140    let graph = build(subgraphs);
141    let output = match format {
142        GraphFormat::Json => serde_json::to_value(&graph)?,
143        GraphFormat::Dot => serde_json::Value::String(to_dot(&graph)),
144        GraphFormat::Mermaid => serde_json::Value::String(to_mermaid(&graph)),
145    };
146    Ok(CommandResult::success("federation/graph", output))
147}
148
149/// Connect each extension to every input that owns the entity.
150fn build(subgraphs: Vec<Subgraph>) -> FederationGraph {
151    let mut edges = Vec::new();
152    let mut unresolved = Vec::new();
153    for from in &subgraphs {
154        for entity in &from.extends {
155            let owners: Vec<&Subgraph> =
156                subgraphs.iter().filter(|s| s.owns.contains(entity)).collect();
157            if owners.is_empty() {
158                unresolved.push(Unresolved {
159                    subgraph: from.name.clone(),
160                    entity:   entity.clone(),
161                });
162            }
163            for to in owners {
164                edges.push(Edge {
165                    from:   from.name.clone(),
166                    to:     to.name.clone(),
167                    entity: entity.clone(),
168                });
169            }
170        }
171    }
172    FederationGraph {
173        subgraphs,
174        edges,
175        unresolved,
176    }
177}
178
179/// A DOT double-quoted string.
180///
181/// A JSON string literal is one: DOT reads `\"`, `\\` and `\n` inside quotes exactly as
182/// JSON writes them, so the escaping is serde_json's rather than a hand-rolled copy (#719).
183fn dot_quoted(s: &str) -> String {
184    serde_json::Value::String(s.to_owned()).to_string()
185}
186
187/// Mermaid label text: its quote and markup characters as HTML entities.
188fn mermaid_text(s: &str) -> String {
189    s.replace('"', "#quot;")
190        .replace('<', "#lt;")
191        .replace('>', "#gt;")
192        .replace('\n', " ")
193}
194
195/// Convert federation graph to DOT format (Graphviz).
196///
197/// Node ids are positional (`n0`, `n1`, …) and names appear only as quoted labels, so a
198/// name cannot inject DOT syntax.
199pub(crate) fn to_dot(graph: &FederationGraph) -> String {
200    let mut dot = String::from("digraph federation {\n");
201    let id = |name: &str| graph.subgraphs.iter().position(|s| s.name == name).unwrap_or(0);
202    for (i, subgraph) in graph.subgraphs.iter().enumerate() {
203        let label = format!("{}\n[{}]", subgraph.name, subgraph.owns.join(", "));
204        dot.push_str(&format!("    n{i} [label={}];\n", dot_quoted(&label)));
205    }
206    for edge in &graph.edges {
207        dot.push_str(&format!(
208            "    n{} -> n{} [label={}];\n",
209            id(&edge.from),
210            id(&edge.to),
211            dot_quoted(&edge.entity)
212        ));
213    }
214    dot.push_str("}\n");
215    dot
216}
217
218/// Convert federation graph to Mermaid format, with positional node ids and quoted labels.
219pub(crate) fn to_mermaid(graph: &FederationGraph) -> String {
220    let mut mermaid = String::from("graph LR\n");
221    let id = |name: &str| graph.subgraphs.iter().position(|s| s.name == name).unwrap_or(0);
222    for (i, subgraph) in graph.subgraphs.iter().enumerate() {
223        let owns: Vec<String> = subgraph.owns.iter().map(|e| mermaid_text(e)).collect();
224        mermaid.push_str(&format!(
225            "    n{i}[\"{}<br/>[{}]\"]\n",
226            mermaid_text(&subgraph.name),
227            owns.join("<br/>")
228        ));
229    }
230    for edge in &graph.edges {
231        mermaid.push_str(&format!(
232            "    n{} -->|\"{}\"| n{}\n",
233            id(&edge.from),
234            mermaid_text(&edge.entity),
235            id(&edge.to)
236        ));
237    }
238    mermaid
239}