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}