Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions crates/lineark-codegen/src/emit_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,16 @@ fn emit_query(
let is_connection =
return_type_name.ends_with("Connection") || return_type_name.ends_with("Payload");

// Nullable return type: schema has `Type` (nullable) vs `Type!` (non-null).
let is_nullable = !is_connection && !matches!(field.ty, GqlType::NonNull(_));

if has_optional {
emit_builder_query(
field,
rename,
&args,
is_connection,
is_nullable,
object_map,
type_kind_map,
)
Expand All @@ -153,6 +157,7 @@ fn emit_query(
rename,
&args,
is_connection,
is_nullable,
object_map,
type_kind_map,
)
Expand All @@ -166,6 +171,7 @@ fn emit_direct_query(
rename: Option<&str>,
args: &[ArgInfo],
is_connection: bool,
is_nullable: bool,
object_map: &HashMap<&str, &ObjectDef>,
_type_kind_map: &HashMap<String, TypeKind>,
) -> QueryResult {
Expand Down Expand Up @@ -278,18 +284,30 @@ fn emit_direct_query(
}
};

let (return_type, execute_call) = if is_nullable {
(
quote! { Option<T> },
quote! { client.execute::<Option<T>>(&query, variables, #data_path).await },
)
} else {
(
quote! { T },
quote! { client.execute::<T>(&query, variables, #data_path).await },
)
};

let standalone_fn = quote! {
#doc
pub async fn #method_name<T: DeserializeOwned + GraphQLFields<FullType = super::types::#node_type_ident>>(client: &Client, #(#params),*) -> Result<T, LinearError> {
pub async fn #method_name<T: DeserializeOwned + GraphQLFields<FullType = super::types::#node_type_ident>>(client: &Client, #(#params),*) -> Result<#return_type, LinearError> {
let variables = serde_json::json!({ #(#variables_json),* });
#query_build
client.execute::<T>(&query, variables, #data_path).await
#execute_call
}
};

let client_method = quote! {
#doc
pub async fn #method_name<T: DeserializeOwned + GraphQLFields<FullType = super::types::#node_type_ident>>(&self, #(#params),*) -> Result<T, LinearError> {
pub async fn #method_name<T: DeserializeOwned + GraphQLFields<FullType = super::types::#node_type_ident>>(&self, #(#params),*) -> Result<#return_type, LinearError> {
crate::generated::queries::#method_name::<T>(self, #(#call_args),*).await
}
};
Expand All @@ -309,6 +327,7 @@ fn emit_builder_query(
rename: Option<&str>,
args: &[ArgInfo],
is_connection: bool,
is_nullable: bool,
object_map: &HashMap<&str, &ObjectDef>,
_type_kind_map: &HashMap<String, TypeKind>,
) -> QueryResult {
Expand Down Expand Up @@ -456,12 +475,24 @@ fn emit_builder_query(
}
};

let (return_type, execute_call) = if is_nullable {
(
quote! { Option<T> },
quote! { self.client.execute::<Option<T>>(&query, variables, #data_path).await },
)
} else {
(
quote! { T },
quote! { self.client.execute::<T>(&query, variables, #data_path).await },
)
};

(
quote! { T },
return_type,
quote! {
#build_variables
#query_build
self.client.execute::<T>(&query, variables, #data_path).await
#execute_call
},
)
};
Expand Down
69 changes: 69 additions & 0 deletions crates/lineark-sdk/src/field_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ pub trait GraphQLFields {
fn selection() -> String;
}

// Nullable queries: Option<T> delegates to T's selection.
// This allows `client.some_nullable_query::<Option<MyType>>()` to work
// when the GraphQL schema returns a nullable type (e.g. `Issue` vs `Issue!`).
impl<T: GraphQLFields> GraphQLFields for Option<T> {
type FullType = T::FullType;
fn selection() -> String {
T::selection()
}
}

/// Marker trait for compile-time field type compatibility.
///
/// Validates that a full type's field type `Self` is compatible with a custom
Expand All @@ -68,3 +78,62 @@ impl<T> FieldCompatible<Option<T>> for Option<Box<T>> {}
impl FieldCompatible<String> for chrono::DateTime<chrono::Utc> {}
impl FieldCompatible<Option<String>> for Option<chrono::DateTime<chrono::Utc>> {}
impl FieldCompatible<String> for Option<chrono::DateTime<chrono::Utc>> {}

#[cfg(test)]
mod tests {
use super::*;

struct FakeFullType;

struct FakeIssue;
impl GraphQLFields for FakeIssue {
type FullType = FakeFullType;
fn selection() -> String {
"id title url".to_string()
}
}

#[test]
fn option_delegates_selection_to_inner_type() {
assert_eq!(
<Option<FakeIssue> as GraphQLFields>::selection(),
"id title url"
);
}

#[test]
fn option_preserves_full_type() {
// Compile-time proof: Option<FakeIssue>::FullType == FakeFullType
fn assert_full_type<T: GraphQLFields<FullType = FakeFullType>>() {}
assert_full_type::<FakeIssue>();
assert_full_type::<Option<FakeIssue>>();
}

#[test]
fn option_nullable_query_deserialization() {
// Proves the full chain: Option<T> with GraphQLFields + serde handles null
#[derive(serde::Deserialize)]
struct MyIssue {
id: String,
}
impl GraphQLFields for MyIssue {
type FullType = FakeFullType;
fn selection() -> String {
"id".to_string()
}
}

// Null → None
let null_val = serde_json::Value::Null;
let result: Option<MyIssue> = serde_json::from_value(null_val).unwrap();
assert!(result.is_none());

// Object → Some
let obj_val = serde_json::json!({"id": "abc-123"});
let result: Option<MyIssue> = serde_json::from_value(obj_val).unwrap();
assert_eq!(result.unwrap().id, "abc-123");

// Selection works through Option
assert_eq!(<Option<MyIssue> as GraphQLFields>::selection(), "id");
}
}
11 changes: 11 additions & 0 deletions crates/lineark-sdk/src/generated/client_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ impl Client {
) -> Result<T, LinearError> {
crate::generated::queries::issue::<T>(self, id).await
}
/// Find issue based on the VCS branch name.
///
/// Full type: [`Issue`](super::types::Issue)
pub async fn issue_vcs_branch_search<
T: DeserializeOwned + GraphQLFields<FullType = super::types::Issue>,
>(
&self,
branch_name: String,
) -> Result<Option<T>, LinearError> {
crate::generated::queries::issue_vcs_branch_search::<T>(self, branch_name).await
}
/// All issue relationships.
///
/// Full type: [`IssueRelation`](super::types::IssueRelation)
Expand Down
23 changes: 23 additions & 0 deletions crates/lineark-sdk/src/generated/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,29 @@ pub async fn issue<T: DeserializeOwned + GraphQLFields<FullType = super::types::
);
client.execute::<T>(&query, variables, "issue").await
}
/// Find issue based on the VCS branch name.
///
/// Full type: [`Issue`](super::types::Issue)
pub async fn issue_vcs_branch_search<
T: DeserializeOwned + GraphQLFields<FullType = super::types::Issue>,
>(
client: &Client,
branch_name: String,
) -> Result<Option<T>, LinearError> {
let variables = serde_json::json!({ "branchName" : branch_name });
let selection = T::selection();
let query = format!(
"query {}({}) {{ {}({}) {{ {} }} }}",
"IssueVcsBranchSearch",
"$branchName: String!",
"issueVcsBranchSearch",
"branchName: $branchName",
selection
);
client
.execute::<Option<T>>(&query, variables, "issueVcsBranchSearch")
.await
}
/// All issue relationships.
///
/// Full type: [`IssueRelation`](super::types::IssueRelation)
Expand Down
1 change: 1 addition & 0 deletions schema/operations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ cycles = true
cycle = true
issueLabels = true
searchIssues = true
issueVcsBranchSearch = true
workflowStates = true

# Phase 3 — Rich features
Expand Down
Loading