Skip to main content

lineark_sdk/
field_selection.rs

1//! Type-driven GraphQL field selection.
2//!
3//! Each type that implements [`GraphQLFields`] knows how to describe its
4//! own GraphQL selection string. Generated types return all their scalar
5//! fields. Consumers can define custom lean types with only the fields
6//! they need — the struct shape *is* the query shape.
7//!
8//! The `FullType` associated type provides compile-time validation:
9//! - Generated types implement `GraphQLFields` with `FullType = Self`
10//! - Custom types use `#[graphql(full_type = X)]` to validate fields at compile time
11
12/// Trait implemented by types that know their GraphQL field selection.
13///
14/// The `FullType` associated type ties this implementation to a specific
15/// GraphQL schema type, enabling compile-time validation:
16/// - **Generated types** set `FullType = Self` — they validate against themselves.
17/// - **Custom lean types** set `FullType` to the corresponding generated type,
18///   enabling compile-time field existence and type checks via `#[graphql(full_type = X)]`.
19///
20/// # Example
21///
22/// ```ignore
23/// use lineark_sdk::generated::types::Team;
24///
25/// #[derive(Deserialize, GraphQLFields)]
26/// #[graphql(full_type = Team)]
27/// struct TeamRow {
28///     id: String,
29///     key: String,
30///     name: String,
31/// }
32///
33/// // Compile-time validated: TeamRow fields exist on Team with compatible types
34/// let teams = client.teams::<TeamRow>().first(10).send().await?;
35/// ```
36pub trait GraphQLFields {
37    /// The full generated type this implementation validates against.
38    type FullType;
39
40    /// Return the GraphQL field selection string for this type.
41    ///
42    /// For flat types, this is just space-separated field names.
43    /// For types with nested objects, include sub-selections:
44    /// `"id title team { id name }"`.
45    fn selection() -> String;
46}
47
48// Nullable queries: Option<T> delegates to T's selection.
49// This allows `client.some_nullable_query::<Option<MyType>>()` to work
50// when the GraphQL schema returns a nullable type (e.g. `Issue` vs `Issue!`).
51impl<T: GraphQLFields> GraphQLFields for Option<T> {
52    type FullType = T::FullType;
53    fn selection() -> String {
54        T::selection()
55    }
56}
57
58// Batch mutations: Vec<T> delegates to T's selection.
59// This allows mutations returning lists (e.g. `issueBatchUpdate`) to use
60// `execute_mutation::<Vec<T>>()` — the selection set is the same as for T.
61impl<T: GraphQLFields> GraphQLFields for Vec<T> {
62    type FullType = T::FullType;
63    fn selection() -> String {
64        T::selection()
65    }
66}
67
68/// Marker trait for compile-time field type compatibility.
69///
70/// Validates that a full type's field type `Self` is compatible with a custom
71/// type's field type `Custom`. Covers common wrapping patterns used in
72/// generated types (`Option`, `Box`, `Vec`).
73pub trait FieldCompatible<Custom> {}
74
75// Exact match
76impl<T> FieldCompatible<T> for T {}
77
78// Unwrap Option: full type has Option<T>, custom type has T
79impl<T> FieldCompatible<T> for Option<T> {}
80
81// Unwrap Option<Box<T>>: full type has Option<Box<T>>, custom type has T
82impl<T> FieldCompatible<T> for Option<Box<T>> {}
83
84// Unbox, keep Option: full type has Option<Box<T>>, custom type has Option<T>
85impl<T> FieldCompatible<Option<T>> for Option<Box<T>> {}
86
87// Cross-type: DateTime serializes as ISO 8601 string in JSON
88impl FieldCompatible<String> for chrono::DateTime<chrono::Utc> {}
89impl FieldCompatible<Option<String>> for Option<chrono::DateTime<chrono::Utc>> {}
90impl FieldCompatible<String> for Option<chrono::DateTime<chrono::Utc>> {}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    struct FakeFullType;
97
98    struct FakeIssue;
99    impl GraphQLFields for FakeIssue {
100        type FullType = FakeFullType;
101        fn selection() -> String {
102            "id title url".to_string()
103        }
104    }
105
106    #[test]
107    fn option_delegates_selection_to_inner_type() {
108        assert_eq!(
109            <Option<FakeIssue> as GraphQLFields>::selection(),
110            "id title url"
111        );
112    }
113
114    #[test]
115    fn vec_delegates_selection_to_inner_type() {
116        assert_eq!(
117            <Vec<FakeIssue> as GraphQLFields>::selection(),
118            "id title url"
119        );
120    }
121
122    #[test]
123    fn option_preserves_full_type() {
124        // Compile-time proof: Option/Vec<FakeIssue>::FullType == FakeFullType
125        fn assert_full_type<T: GraphQLFields<FullType = FakeFullType>>() {}
126        assert_full_type::<FakeIssue>();
127        assert_full_type::<Option<FakeIssue>>();
128        assert_full_type::<Vec<FakeIssue>>();
129    }
130
131    #[test]
132    fn option_nullable_query_deserialization() {
133        // Proves the full chain: Option<T> with GraphQLFields + serde handles null
134        #[derive(serde::Deserialize)]
135        struct MyIssue {
136            id: String,
137        }
138        impl GraphQLFields for MyIssue {
139            type FullType = FakeFullType;
140            fn selection() -> String {
141                "id".to_string()
142            }
143        }
144
145        // Null → None
146        let null_val = serde_json::Value::Null;
147        let result: Option<MyIssue> = serde_json::from_value(null_val).unwrap();
148        assert!(result.is_none());
149
150        // Object → Some
151        let obj_val = serde_json::json!({"id": "abc-123"});
152        let result: Option<MyIssue> = serde_json::from_value(obj_val).unwrap();
153        assert_eq!(result.unwrap().id, "abc-123");
154
155        // Selection works through Option
156        assert_eq!(<Option<MyIssue> as GraphQLFields>::selection(), "id");
157    }
158}