1use std::collections::BTreeMap;
15
16use serde_json::Value;
17
18use crate::compat::{Change, Direction, Severity, compare_keys, sort};
19
20pub fn diff(old: &Value, new: &Value) -> Vec<Change> {
22 let mut changes = Vec::new();
23
24 compare_keys(
25 &mut changes,
26 &members(old, "types"),
27 &members(new, "types"),
28 "type",
29 compare_type,
30 );
31
32 for root in ["Query", "Mutation", "Subscription"] {
35 let (o, n) = (fields(&old["roots"][root]), fields(&new["roots"][root]));
36 let dir = Direction::Response;
37 compare_keys(&mut changes, &o, &n, "field", |ch, at, o, n| {
38 compare_field(ch, &format!("{root}.{at}"), o, n, dir);
39 compare_arguments(ch, &format!("{root}.{at}"), o, n);
40 });
41 }
42
43 sort(&mut changes);
44 changes
45}
46
47fn members(doc: &Value, key: &str) -> BTreeMap<String, Value> {
48 doc[key]
49 .as_object()
50 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
51 .unwrap_or_default()
52}
53
54fn fields(decl: &Value) -> BTreeMap<String, Value> {
55 decl.as_object()
56 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
57 .unwrap_or_default()
58}
59
60fn compare_type(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value) {
61 let (ok, nk) = (kind(old), kind(new));
62 if ok != nk {
63 changes.push(Change::new(
64 Severity::Breaking,
65 at,
66 format!("kind {ok} -> {nk}"),
67 ));
68 return; }
70
71 if ok == "enum" {
72 compare_enum_values(changes, at, old, new);
73 return;
74 }
75 if ok == "scalar" {
76 return;
77 }
78
79 let dir = if ok == "input" {
81 Direction::Request
82 } else {
83 Direction::Response
84 };
85 let (o, n) = (fields(&old["fields"]), fields(&new["fields"]));
86 compare_field_set(changes, at, &o, &n, dir);
87}
88
89fn compare_field_set(
91 changes: &mut Vec<Change>,
92 at: &str,
93 old: &BTreeMap<String, Value>,
94 new: &BTreeMap<String, Value>,
95 dir: Direction,
96) {
97 let mut names: Vec<&String> = old.keys().chain(new.keys()).collect();
98 names.sort_unstable();
99 names.dedup();
100
101 for name in names {
102 let where_ = format!("{at}.{name}");
103 match (old.get(name), new.get(name)) {
104 (Some(_), None) => {
105 changes.push(Change::new(Severity::Breaking, where_, "field removed"))
106 }
107 (None, Some(f)) => {
108 let severity = match (dir, non_null(type_of(f))) {
111 (Direction::Request, true) => Severity::Breaking,
112 _ => Severity::Additive,
113 };
114 changes.push(Change::new(severity, where_, "field added"));
115 }
116 (Some(o), Some(n)) => compare_field(changes, &where_, o, n, dir),
117 (None, None) => {}
118 }
119 }
120}
121
122fn compare_field(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value, dir: Direction) {
124 let (ot, nt) = (type_of(old), type_of(new));
125 if ot == nt {
126 return;
127 }
128 if shape(ot) != shape(nt) {
129 changes.push(Change::new(
130 Severity::Breaking,
131 at,
132 format!("type {ot} -> {nt}"),
133 ));
134 return;
135 }
136 let tightened =
138 non_null(nt) && !non_null(ot) || nt.matches('!').count() > ot.matches('!').count();
139 let severity = match (dir, tightened) {
140 (Direction::Request, true) | (Direction::Response, false) => Severity::Breaking,
142 _ => Severity::Additive,
143 };
144 let detail = if tightened {
145 format!("became non-null ({ot} -> {nt})")
146 } else {
147 format!("became nullable ({ot} -> {nt})")
148 };
149 changes.push(Change::new(severity, at, detail));
150}
151
152fn compare_arguments(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value) {
154 let (o, n) = (fields(&old["args"]), fields(&new["args"]));
155 if o.is_empty() && n.is_empty() {
156 return;
157 }
158 let mut renamed = Vec::new();
159 compare_field_set(&mut renamed, at, &o, &n, Direction::Request);
160 changes.extend(renamed.into_iter().map(|c| Change {
162 detail: c.detail.replace("field ", "argument "),
163 ..c
164 }));
165}
166
167fn compare_enum_values(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value) {
168 let values = |v: &Value| -> Vec<String> {
169 v["values"]
170 .as_array()
171 .map(|a| {
172 a.iter()
173 .filter_map(|s| s.as_str().map(String::from))
174 .collect()
175 })
176 .unwrap_or_default()
177 };
178 let (o, n) = (values(old), values(new));
179 for gone in o.iter().filter(|v| !n.contains(v)) {
180 changes.push(Change::new(
181 Severity::Breaking,
182 at,
183 format!("value {gone} no longer accepted"),
184 ));
185 }
186 for added in n.iter().filter(|v| !o.contains(v)) {
187 changes.push(Change::new(
188 Severity::Additive,
189 at,
190 format!("value {added} added"),
191 ));
192 }
193}
194
195fn kind(decl: &Value) -> &str {
196 decl["kind"].as_str().unwrap_or("type")
197}
198
199fn type_of(field: &Value) -> &str {
200 field["type"].as_str().unwrap_or("")
201}
202
203fn shape(t: &str) -> String {
206 t.replace('!', "")
207}
208
209fn non_null(t: &str) -> bool {
210 t.ends_with('!')
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::compat::is_compatible;
217 use serde_json::json;
218
219 fn graph(types: Value, query_fields: Value) -> Value {
220 json!({ "types": types, "roots": { "Query": query_fields } })
221 }
222
223 fn item(fields: Value) -> Value {
224 json!({ "Item": { "kind": "type", "fields": fields } })
225 }
226
227 fn new_item(fields: Value) -> Value {
228 json!({ "NewItemInput": { "kind": "input", "fields": fields } })
229 }
230
231 #[test]
232 fn an_unchanged_graph_is_compatible_with_itself() {
233 let g = graph(
234 item(json!({ "id": { "type": "String!" } })),
235 json!({ "item": { "type": "Item!" } }),
236 );
237 assert!(diff(&g, &g).is_empty());
238 }
239
240 #[test]
241 fn removing_an_output_field_breaks_clients() {
242 let old = graph(
243 item(json!({ "id": { "type": "String!" }, "note": { "type": "String" } })),
244 json!({}),
245 );
246 let new = graph(item(json!({ "id": { "type": "String!" } })), json!({}));
247 let changes = diff(&old, &new);
248 assert_eq!(changes.len(), 1, "got {changes:?}");
249 assert_eq!(changes[0].severity, Severity::Breaking);
250 assert_eq!(changes[0].at, "Item.note");
251 }
252
253 #[test]
254 fn adding_an_output_field_is_additive() {
255 let old = graph(item(json!({ "id": { "type": "String!" } })), json!({}));
256 let new = graph(
257 item(json!({ "id": { "type": "String!" }, "note": { "type": "String!" } })),
258 json!({}),
259 );
260 assert!(is_compatible(&diff(&old, &new)));
261 }
262
263 #[test]
264 fn an_output_field_losing_its_guarantee_breaks_clients() {
265 let old = graph(item(json!({ "id": { "type": "String!" } })), json!({}));
266 let new = graph(item(json!({ "id": { "type": "String" } })), json!({}));
267 let changes = diff(&old, &new);
268 assert_eq!(changes[0].severity, Severity::Breaking);
269 assert!(changes[0].detail.contains("became nullable"), "{changes:?}");
270 }
271
272 #[test]
273 fn the_same_edit_on_an_input_relaxes_a_demand() {
274 let old = graph(
275 new_item(json!({ "name": { "type": "String!" } })),
276 json!({}),
277 );
278 let new = graph(new_item(json!({ "name": { "type": "String" } })), json!({}));
279 let changes = diff(&old, &new);
280 assert_eq!(changes[0].severity, Severity::Additive, "{changes:?}");
281 }
282
283 #[test]
284 fn a_new_required_input_field_breaks_callers() {
285 let old = graph(
286 new_item(json!({ "name": { "type": "String!" } })),
287 json!({}),
288 );
289 let new = graph(
290 new_item(json!({ "name": { "type": "String!" }, "qty": { "type": "Int!" } })),
291 json!({}),
292 );
293 let changes = diff(&old, &new);
294 assert_eq!(changes[0].severity, Severity::Breaking);
295 assert_eq!(changes[0].at, "NewItemInput.qty");
296 }
297
298 #[test]
299 fn a_new_optional_input_field_does_not() {
300 let old = graph(
301 new_item(json!({ "name": { "type": "String!" } })),
302 json!({}),
303 );
304 let new = graph(
305 new_item(json!({ "name": { "type": "String!" }, "qty": { "type": "Int" } })),
306 json!({}),
307 );
308 assert!(is_compatible(&diff(&old, &new)));
309 }
310
311 #[test]
312 fn changing_the_underlying_type_is_breaking_either_way() {
313 let old = graph(item(json!({ "id": { "type": "String!" } })), json!({}));
314 let new = graph(item(json!({ "id": { "type": "Int!" } })), json!({}));
315 let changes = diff(&old, &new);
316 assert_eq!(changes[0].severity, Severity::Breaking);
317 assert!(changes[0].detail.contains("String! -> Int!"));
318 }
319
320 #[test]
321 fn list_item_nullability_is_read_as_a_guarantee_too() {
322 let old = graph(item(json!({ "tags": { "type": "[String!]!" } })), json!({}));
323 let new = graph(item(json!({ "tags": { "type": "[String]!" } })), json!({}));
324 let changes = diff(&old, &new);
325 assert_eq!(changes.len(), 1, "got {changes:?}");
326 assert_eq!(changes[0].severity, Severity::Breaking);
327 }
328
329 #[test]
330 fn removing_a_root_field_breaks_callers_and_adding_one_does_not() {
331 let old = graph(json!({}), json!({ "item": { "type": "Item!" } }));
332 let new = graph(json!({}), json!({ "other": { "type": "Item!" } }));
333 let changes = diff(&old, &new);
334 assert_eq!(changes.len(), 2, "got {changes:?}");
335 assert_eq!(changes[0].severity, Severity::Breaking);
336 assert_eq!(changes[0].at, "item");
337 }
338
339 #[test]
340 fn a_newly_required_argument_breaks_callers() {
341 let old = graph(
342 json!({}),
343 json!({ "item": { "type": "Item!", "args": { "id": { "type": "ID!" } } } }),
344 );
345 let new = graph(
346 json!({}),
347 json!({ "item": { "type": "Item!",
348 "args": { "id": { "type": "ID!" }, "tenant": { "type": "ID!" } } } }),
349 );
350 let changes = diff(&old, &new);
351 assert_eq!(changes.len(), 1, "got {changes:?}");
352 assert_eq!(changes[0].severity, Severity::Breaking);
353 assert!(changes[0].detail.contains("argument added"), "{changes:?}");
354 }
355
356 #[test]
357 fn an_optional_new_argument_is_additive() {
358 let old = graph(json!({}), json!({ "item": { "type": "Item!" } }));
359 let new = graph(
360 json!({}),
361 json!({ "item": { "type": "Item!", "args": { "tenant": { "type": "ID" } } } }),
362 );
363 assert!(is_compatible(&diff(&old, &new)), "{:?}", diff(&old, &new));
364 }
365
366 #[test]
367 fn removing_an_enum_value_breaks_consumers() {
368 let old = graph(
369 json!({ "Status": { "kind": "enum", "values": ["ACTIVE", "ARCHIVED"] } }),
370 json!({}),
371 );
372 let new = graph(
373 json!({ "Status": { "kind": "enum", "values": ["ACTIVE"] } }),
374 json!({}),
375 );
376 let changes = diff(&old, &new);
377 assert_eq!(changes.len(), 1, "got {changes:?}");
378 assert!(changes[0].detail.contains("ARCHIVED"));
379 assert_eq!(changes[0].severity, Severity::Breaking);
380 }
381
382 #[test]
383 fn a_type_that_becomes_an_input_is_breaking() {
384 let old = graph(item(json!({ "id": { "type": "String!" } })), json!({}));
385 let new = graph(
386 json!({ "Item": { "kind": "input", "fields": { "id": { "type": "String!" } } } }),
387 json!({}),
388 );
389 let changes = diff(&old, &new);
390 assert_eq!(changes.len(), 1, "got {changes:?}");
391 assert!(changes[0].detail.contains("kind type -> input"));
392 }
393}