Skip to main content

soothfast_spec/graphql/
diff.rs

1//! Compatibility diffing between two generated GraphQL type graphs.
2//!
3//! GraphQL states nullability on the reference (`id: ID!`) rather than on the
4//! parent, so this is not the JSON Schema comparison the other dialects
5//! share — but the asymmetry underneath it is the same one. On an **output**
6//! type, `T!` becoming `T` withdraws a guarantee a client already relies on;
7//! on an **input** type, the same edit relaxes a demand, and it is `T`
8//! becoming `T!` that breaks the caller.
9//!
10//! Comparing the graph rather than the SDL text is what lets the gate say
11//! "field removed" instead of "line 42 changed": reformatting is invisible
12//! here, and a real edit is named at the field it happened to.
13
14use std::collections::BTreeMap;
15
16use serde_json::Value;
17
18use crate::compat::{Change, Direction, Severity, compare_keys, sort};
19
20/// Compare two generated type graphs, oldest first.
21pub 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    // Root fields are entry points: adding one can never break a caller, and
33    // removing one always does, whichever root it sits on.
34    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; // Nothing below the kind is comparable once it moved.
69    }
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    // An input type's fields flow inward; every other declaration's flow out.
80    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
89/// Field presence and shape, with requiredness read in the given direction.
90fn 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                // A new non-null input field is one every caller now has to
109                // supply; a new output field is a gift.
110                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
122/// One field's type, compared in a known direction.
123fn 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    // Same underlying type, different nullability.
137    let tightened =
138        non_null(nt) && !non_null(ot) || nt.matches('!').count() > ot.matches('!').count();
139    let severity = match (dir, tightened) {
140        // Demanding more of a caller, or guaranteeing less to one.
141        (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
152/// A field's arguments, which always flow inward however the field is used.
153fn 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    // Reword so an argument does not report itself as a field.
161    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
203/// A type reference with every `!` removed, so two references can be compared
204/// for "same underlying type" independently of nullability.
205fn 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}