Skip to main content

lineark_sdk/
field_update.rs

1//! Three-state wrapper for nullable input fields.
2//!
3//! GraphQL distinguishes between an omitted field ("don't change this") and an
4//! explicit `null` ("clear this field"). `Option<T>` + `skip_serializing_if`
5//! can only express the first. [`MaybeUndefined<T>`] carries both, so
6//! generated input types can drive the Linear API faithfully without hand-rolled
7//! JSON patches on the consumer side.
8//!
9//! Codegen emits nullable input fields as:
10//!
11//! ```rust,ignore
12//! #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
13//! pub lead_id: MaybeUndefined<String>,
14//! ```
15//!
16//! Consumers choose one of:
17//!
18//! | Intent                    | Value                          | Wire form       |
19//! |---------------------------|--------------------------------|-----------------|
20//! | Leave unchanged           | `MaybeUndefined::Undefined`    | field omitted   |
21//! | Clear on the server       | `MaybeUndefined::Null`         | `"field": null` |
22//! | Set to a value            | `MaybeUndefined::Value(v)`     | `"field": v`    |
23
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26/// A three-state field value: undefined (omitted), null (explicit clear), or a concrete value.
27///
28/// See the [module documentation](self) for the rationale and wire-format mapping.
29///
30/// # Struct-context contract
31///
32/// The [`Undefined`](MaybeUndefined::Undefined) / [`Null`](MaybeUndefined::Null)
33/// distinction is preserved on the wire **only** when this value sits in a
34/// struct field carrying
35/// `#[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]`
36/// — which is what codegen emits for every nullable input field. In any other
37/// context (`serde_json::to_value(MaybeUndefined::<T>::Undefined)`, a bare
38/// value inside a `Vec<MaybeUndefined<T>>`, etc.) `Undefined` cannot be
39/// "omitted" — there's no containing struct to omit it from — and serializes
40/// as JSON `null`, collapsing the distinction. Use this type *only* as a
41/// struct field paired with the skip predicate above.
42///
43/// # Derive bounds
44///
45/// The [`Eq`] and [`Hash`] impls are conditional on `T: Eq + Hash`. Types
46/// containing non-`Eq` scalars (notably `f64`) therefore can't derive those
47/// traits transitively — this is expected and matches `Option<T>`.
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub enum MaybeUndefined<T> {
50    /// Field is absent from the serialized output.
51    Undefined,
52    /// Field is serialized as JSON `null` (clears the value on the server).
53    Null,
54    /// Field is serialized as the wrapped value.
55    Value(T),
56}
57
58impl<T> MaybeUndefined<T> {
59    /// Returns `true` if the value is [`MaybeUndefined::Undefined`].
60    ///
61    /// Codegen uses this as the `skip_serializing_if` predicate so `Undefined`
62    /// fields are omitted from the serialized output entirely.
63    pub fn is_undefined(&self) -> bool {
64        matches!(self, Self::Undefined)
65    }
66}
67
68// Manual impl (not `#[derive(Default)]`) so the `Default` bound on `T` is
69// avoided — consumers need `MaybeUndefined::<T>::default()` to work for any T,
70// not just those that themselves implement `Default`.
71#[allow(clippy::derivable_impls)]
72impl<T> Default for MaybeUndefined<T> {
73    fn default() -> Self {
74        Self::Undefined
75    }
76}
77
78impl<T> From<T> for MaybeUndefined<T> {
79    fn from(v: T) -> Self {
80        Self::Value(v)
81    }
82}
83
84/// Lifts an `Option<T>` into the three-state world, collapsing `None` to
85/// [`Undefined`](MaybeUndefined::Undefined).
86///
87/// This is the right default for **constructing** input values: a CLI flag
88/// that wasn't passed (`Option::None`) means "leave the field unchanged", so
89/// it maps to `Undefined`. If you instead want `None` to clear the field on
90/// the server, use [`MaybeUndefined::Null`] explicitly.
91///
92/// Note the intentional asymmetry with [`Deserialize`]: when round-tripping
93/// through JSON, an absent field deserializes to `Undefined` (via the
94/// `#[serde(default)]` on the field), while an explicit JSON `null`
95/// deserializes to `Null`. That matches GraphQL's wire semantics. `Option<T>`
96/// doesn't carry the "absent" vs "null" distinction, so `From<Option<T>>`
97/// can't preserve it either.
98impl<T> From<Option<T>> for MaybeUndefined<T> {
99    fn from(o: Option<T>) -> Self {
100        match o {
101            Some(v) => Self::Value(v),
102            None => Self::Undefined,
103        }
104    }
105}
106
107impl<T: Serialize> Serialize for MaybeUndefined<T> {
108    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
109        match self {
110            // Unreachable in normal use: codegen emits
111            // `skip_serializing_if = "MaybeUndefined::is_undefined"`, so serde
112            // never asks us to serialize the Undefined variant on struct fields.
113            Self::Undefined => s.serialize_none(),
114            Self::Null => s.serialize_none(),
115            Self::Value(v) => v.serialize(s),
116        }
117    }
118}
119
120/// Maps the three JSON inputs a struct field can present as follows:
121///
122/// | Input                          | Result      |
123/// |--------------------------------|-------------|
124/// | field absent from JSON         | `Undefined` (via `#[serde(default)]`) |
125/// | field present with `null`      | `Null`      |
126/// | field present with a value `v` | `Value(v)`  |
127///
128/// Absent-field handling is driven by `#[serde(default)]` on the struct
129/// field, not by this impl — serde only calls `deserialize` when the key is
130/// present. Codegen emits that attribute on every nullable input field, which
131/// is what preserves the three-state distinction on round-trip.
132///
133/// Note the intentional asymmetry with [`From<Option<T>>`]: `None → Undefined`
134/// during construction (a missing CLI flag shouldn't touch the server),
135/// but JSON `null → Null` during deserialization (the server *did* send
136/// `null`).
137impl<'de, T: Deserialize<'de>> Deserialize<'de> for MaybeUndefined<T> {
138    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
139        Option::<T>::deserialize(d).map(|o| match o {
140            Some(v) => Self::Value(v),
141            None => Self::Null,
142        })
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use serde::{Deserialize, Serialize};
150
151    #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
152    struct Host {
153        #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
154        field: MaybeUndefined<String>,
155    }
156
157    #[test]
158    fn default_is_undefined() {
159        let m: MaybeUndefined<String> = MaybeUndefined::default();
160        assert!(matches!(m, MaybeUndefined::Undefined));
161        assert!(m.is_undefined());
162    }
163
164    #[test]
165    fn from_value_is_value() {
166        let m: MaybeUndefined<String> = MaybeUndefined::from("hi".to_string());
167        assert_eq!(m, MaybeUndefined::Value("hi".to_string()));
168    }
169
170    #[test]
171    fn from_option_maps_correctly() {
172        let some: MaybeUndefined<i32> = MaybeUndefined::from(Some(5));
173        let none: MaybeUndefined<i32> = MaybeUndefined::from(Option::<i32>::None);
174        assert_eq!(some, MaybeUndefined::Value(5));
175        assert_eq!(none, MaybeUndefined::Undefined);
176    }
177
178    #[test]
179    fn serialize_value_emits_value() {
180        let host = Host {
181            field: MaybeUndefined::Value("hello".to_string()),
182        };
183        assert_eq!(
184            serde_json::to_string(&host).unwrap(),
185            r#"{"field":"hello"}"#
186        );
187    }
188
189    #[test]
190    fn serialize_null_emits_null() {
191        let host = Host {
192            field: MaybeUndefined::Null,
193        };
194        assert_eq!(serde_json::to_string(&host).unwrap(), r#"{"field":null}"#);
195    }
196
197    #[test]
198    fn serialize_undefined_is_skipped() {
199        let host = Host {
200            field: MaybeUndefined::Undefined,
201        };
202        assert_eq!(serde_json::to_string(&host).unwrap(), r#"{}"#);
203    }
204
205    #[test]
206    fn deserialize_value_is_value() {
207        let host: Host = serde_json::from_str(r#"{"field":"hello"}"#).unwrap();
208        assert_eq!(host.field, MaybeUndefined::Value("hello".to_string()));
209    }
210
211    #[test]
212    fn deserialize_null_is_null() {
213        let host: Host = serde_json::from_str(r#"{"field":null}"#).unwrap();
214        assert_eq!(host.field, MaybeUndefined::Null);
215    }
216
217    #[test]
218    fn deserialize_absent_is_undefined() {
219        let host: Host = serde_json::from_str(r#"{}"#).unwrap();
220        assert_eq!(host.field, MaybeUndefined::Undefined);
221    }
222}