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#[derive(Debug, Clone, Default)]
13pub struct GraphqlGraphFacts {
14 pub nodes: Vec<Node>,
16 pub edges: Vec<Edge>,
18 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#[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}