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}