Skip to main content

code_system_graph_core/
graphql_graph.rs

1use std::collections::BTreeMap;
2
3use code_system_graph_model::{
4    Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
5};
6
7use crate::{
8    GraphqlDocument, GraphqlLineRange, GraphqlOperationKind, GraphqlResolver, GraphqlTypeDefinition
9};
10
11/// Graph facts assembled from a workspace-wide set of GraphQL documents.
12#[derive(Debug, Clone, Default)]
13pub struct GraphqlGraphFacts {
14    /// Provider/consumer operation, resolver, and artifact nodes.
15    pub nodes: Vec<Node>,
16    /// Exact GraphQL consumer/provider and implementation edges.
17    pub edges: Vec<Edge>,
18    /// Direct SDL, operation, persisted-manifest, and resolver evidence.
19    pub evidence: Vec<Evidence>,
20}
21
22struct Provider {
23    repo_id: RepoId,
24    kind: Option<GraphqlOperationKind>,
25    field: String,
26    coordinate: String,
27    node: Node,
28    evidence: Evidence,
29}
30
31struct Consumer {
32    kind: GraphqlOperationKind,
33    root_field: String,
34    node: Node,
35    evidence: Evidence,
36}
37
38/// Converts GraphQL documents into deterministic graph facts and links unambiguous root fields.
39///
40/// Each input tuple contains repository ID, source path, content hash, and extracted document.
41/// Consumers remain unlinked when zero or multiple providers expose the same root coordinate.
42#[must_use]
43pub fn graphql_documents_to_graph(
44    inputs: &[(&RepoId, &str, &str, &GraphqlDocument)],
45) -> GraphqlGraphFacts {
46    let mut result = GraphqlGraphFacts::default();
47    let mut providers = Vec::new();
48    let mut consumers = Vec::new();
49    let mut resolvers = Vec::new();
50    for (repo_id, source_path, content_hash, document) in inputs {
51        let artifact = artifact_node(repo_id, source_path);
52        let artifact_evidence = graphql_evidence(
53            repo_id,
54            source_path,
55            content_hash,
56            GraphqlLineRange { start: 1, end: 1 },
57            "GraphQL artifact",
58        );
59        result.edges.push(edge(
60            &repository_node(repo_id).id,
61            &artifact.id,
62            EdgeKind::Contains,
63            vec![artifact_evidence.id.clone()],
64        ));
65        result.nodes.push(repository_node(repo_id));
66        result.nodes.push(artifact);
67        result.evidence.push(artifact_evidence);
68        append_providers(&mut providers, repo_id, source_path, content_hash, document);
69        append_consumers(&mut consumers, repo_id, source_path, content_hash, document);
70        resolvers.extend(document.resolvers.iter().map(|resolver| {
71            (
72                (*repo_id).clone(),
73                (*source_path).to_owned(),
74                (*content_hash).to_owned(),
75                resolver.clone(),
76            )
77        }));
78    }
79    link_consumers(&providers, &consumers, &mut result);
80    link_resolvers(&providers, &resolvers, &mut result);
81    for provider in providers {
82        result.nodes.push(provider.node);
83        result.evidence.push(provider.evidence);
84    }
85    for consumer in consumers {
86        result.nodes.push(consumer.node);
87        result.evidence.push(consumer.evidence);
88    }
89    finish(&mut result);
90    result
91}
92
93fn append_providers(
94    output: &mut Vec<Provider>,
95    repo_id: &RepoId,
96    source_path: &str,
97    content_hash: &str,
98    document: &GraphqlDocument,
99) {
100    for definition in &document.types {
101        append_provider_fields(
102            output,
103            repo_id,
104            source_path,
105            content_hash,
106            definition,
107            root_kind(&definition.name),
108        );
109    }
110}
111
112fn append_provider_fields(
113    output: &mut Vec<Provider>,
114    repo_id: &RepoId,
115    source_path: &str,
116    content_hash: &str,
117    definition: &GraphqlTypeDefinition,
118    kind: Option<GraphqlOperationKind>,
119) {
120    for field in &definition.fields {
121        let stable_key = format!(
122            "graphql:{}:provider:{}:{}",
123            repo_id.as_str(),
124            definition.name,
125            field.name
126        );
127        output.push(Provider {
128            repo_id: repo_id.clone(),
129            kind,
130            field: field.name.clone(),
131            coordinate: field.coordinate.clone(),
132            node: Node {
133                id: NodeId::new(stable_id("node", &stable_key)),
134                kind: NodeKind::GraphqlOperation,
135                repo_id: Some(repo_id.clone()),
136                stable_key,
137                label: field.coordinate.clone(),
138            },
139            evidence: graphql_evidence(
140                repo_id,
141                source_path,
142                content_hash,
143                field.lines,
144                "GraphQL SDL root field",
145            ),
146        });
147    }
148}
149
150fn append_consumers(
151    output: &mut Vec<Consumer>,
152    repo_id: &RepoId,
153    source_path: &str,
154    content_hash: &str,
155    document: &GraphqlDocument,
156) {
157    for (index, operation) in document.operations.iter().enumerate() {
158        let roots = operation
159            .consumed_field_paths
160            .iter()
161            .filter_map(|path| path.split('.').next())
162            .collect::<std::collections::BTreeSet<_>>();
163        for root in roots {
164            let name = operation
165                .name
166                .clone()
167                .unwrap_or_else(|| format!("anonymous-{index}"));
168            let stable_key = format!(
169                "graphql:{}:consumer:{source_path}:{}:{name}:{root}",
170                repo_id.as_str(),
171                operation_kind(operation.kind)
172            );
173            output.push(Consumer {
174                kind: operation.kind,
175                root_field: root.to_owned(),
176                node: Node {
177                    id: NodeId::new(stable_id("node", &stable_key)),
178                    kind: NodeKind::GraphqlOperation,
179                    repo_id: Some(repo_id.clone()),
180                    stable_key,
181                    label: format!("{} {name}", operation_kind(operation.kind)),
182                },
183                evidence: graphql_evidence(
184                    repo_id,
185                    source_path,
186                    content_hash,
187                    operation.lines,
188                    "GraphQL consumer operation",
189                ),
190            });
191        }
192    }
193}
194
195fn link_consumers(providers: &[Provider], consumers: &[Consumer], result: &mut GraphqlGraphFacts) {
196    let mut candidates: BTreeMap<(GraphqlOperationKind, &str), Vec<&Provider>> = BTreeMap::new();
197    for provider in providers {
198        if let Some(kind) = provider.kind {
199            candidates
200                .entry((kind, provider.field.as_str()))
201                .or_default()
202                .push(provider);
203        }
204    }
205    for consumer in consumers {
206        let Some(matches) = candidates.get(&(consumer.kind, consumer.root_field.as_str())) else {
207            continue;
208        };
209        if matches.len() != 1 {
210            continue;
211        }
212        let provider = matches[0];
213        result.edges.push(edge(
214            &consumer.node.id,
215            &provider.node.id,
216            EdgeKind::CallsRemote,
217            vec![consumer.evidence.id.clone(), provider.evidence.id.clone()],
218        ));
219    }
220}
221
222fn link_resolvers(
223    providers: &[Provider],
224    resolvers: &[(RepoId, String, String, GraphqlResolver)],
225    result: &mut GraphqlGraphFacts,
226) {
227    for (repo_id, source_path, content_hash, resolver) in resolvers {
228        let Some(provider) = providers.iter().find(|provider| {
229            provider.repo_id == *repo_id && provider.coordinate == resolver.coordinate
230        }) else {
231            continue;
232        };
233        let stable_key = format!(
234            "graphql-resolver:{}:{source_path}:{}:{}",
235            repo_id.as_str(),
236            resolver.coordinate,
237            resolver.symbol
238        );
239        let node = Node {
240            id: NodeId::new(stable_id("node", &stable_key)),
241            kind: NodeKind::SymbolRef,
242            repo_id: Some(repo_id.clone()),
243            stable_key,
244            label: resolver.symbol.clone(),
245        };
246        let resolver_evidence = graphql_evidence(
247            repo_id,
248            source_path,
249            content_hash,
250            resolver.lines,
251            "GraphQL resolver",
252        );
253        result.edges.push(edge(
254            &provider.node.id,
255            &node.id,
256            EdgeKind::ImplementedBy,
257            vec![provider.evidence.id.clone(), resolver_evidence.id.clone()],
258        ));
259        result.nodes.push(node);
260        result.evidence.push(resolver_evidence);
261    }
262}
263
264fn artifact_node(repo_id: &RepoId, source_path: &str) -> Node {
265    let stable_key = format!("graphql-artifact:{}:{source_path}", repo_id.as_str());
266    Node {
267        id: NodeId::new(stable_id("node", &stable_key)),
268        kind: NodeKind::Artifact,
269        repo_id: Some(repo_id.clone()),
270        stable_key,
271        label: source_path.to_owned(),
272    }
273}
274
275fn repository_node(repo_id: &RepoId) -> Node {
276    let stable_key = format!("repository:{}", repo_id.as_str());
277    Node {
278        id: NodeId::new(stable_id("node", &stable_key)),
279        kind: NodeKind::Repository,
280        repo_id: Some(repo_id.clone()),
281        stable_key,
282        label: repo_id.as_str().to_owned(),
283    }
284}
285
286fn graphql_evidence(
287    repo_id: &RepoId,
288    source_path: &str,
289    content_hash: &str,
290    lines: GraphqlLineRange,
291    note: &str,
292) -> Evidence {
293    let key = format!(
294        "{}:{source_path}:{}:{}:{note}:{content_hash}",
295        repo_id.as_str(),
296        lines.start,
297        lines.end
298    );
299    Evidence {
300        id: EvidenceId::new(stable_id("evidence", &key)),
301        repo_id: Some(repo_id.clone()),
302        file_path: Some(source_path.to_owned()),
303        start_line: Some(lines.start),
304        end_line: Some(lines.end),
305        extractor: "code-system-graph.graphql".to_owned(),
306        extractor_version: "1.0.0".to_owned(),
307        provenance: Provenance::Extracted,
308        confidence: 1.0,
309        observed_at_commit: None,
310        content_hash: Some(content_hash.to_owned()),
311        note: Some(note.to_owned()),
312    }
313}
314
315fn edge(source: &NodeId, target: &NodeId, kind: EdgeKind, evidence: Vec<EvidenceId>) -> Edge {
316    let key = format!("{}:{kind:?}:{}", source.as_str(), target.as_str());
317    Edge {
318        id: EdgeId::new(stable_id("edge", &key)),
319        source: source.clone(),
320        target: target.clone(),
321        kind,
322        confidence: 1.0,
323        status: EpistemicStatus::Confirmed,
324        evidence,
325    }
326}
327
328fn root_kind(name: &str) -> Option<GraphqlOperationKind> {
329    match name {
330        "Query" => Some(GraphqlOperationKind::Query),
331        "Mutation" => Some(GraphqlOperationKind::Mutation),
332        "Subscription" => Some(GraphqlOperationKind::Subscription),
333        _ => None,
334    }
335}
336
337fn operation_kind(kind: GraphqlOperationKind) -> &'static str {
338    match kind {
339        GraphqlOperationKind::Query => "query",
340        GraphqlOperationKind::Mutation => "mutation",
341        GraphqlOperationKind::Subscription => "subscription",
342    }
343}
344
345fn finish(result: &mut GraphqlGraphFacts) {
346    result.nodes.sort_by(|left, right| left.id.cmp(&right.id));
347    result.nodes.dedup_by(|left, right| left.id == right.id);
348    result.edges.sort_by(|left, right| left.id.cmp(&right.id));
349    result.edges.dedup_by(|left, right| left.id == right.id);
350    result
351        .evidence
352        .sort_by(|left, right| left.id.cmp(&right.id));
353    result.evidence.dedup_by(|left, right| left.id == right.id);
354}
355
356#[cfg(test)]
357mod tests {
358    use code_system_graph_model::RepoId;
359
360    use super::graphql_documents_to_graph;
361    use crate::extract_graphql_document;
362
363    #[test]
364    fn graphql_graph_should_link_exact_consumer_to_single_provider() {
365        let provider =
366            extract_graphql_document("schema.graphql", "type Query { order(id: ID!): String }");
367        let consumer =
368            extract_graphql_document("orders.graphql", "query Order { order(id: \"1\") }");
369        let provider_repo = RepoId::new("repo:api");
370        let consumer_repo = RepoId::new("repo:web");
371        let facts =
372            provider
373                .as_ref()
374                .ok()
375                .zip(consumer.as_ref().ok())
376                .map(|(provider, consumer)| {
377                    graphql_documents_to_graph(&[
378                        (&provider_repo, "schema.graphql", "provider-hash", provider),
379                        (&consumer_repo, "orders.graphql", "consumer-hash", consumer),
380                    ])
381                });
382
383        assert!(matches!(
384            facts,
385            Some(facts)
386                if facts
387                    .edges
388                    .iter()
389                    .any(|edge| edge.kind == code_system_graph_model::EdgeKind::CallsRemote)
390        ));
391    }
392}