diff --git a/crates/lineark-codegen/src/emit_queries.rs b/crates/lineark-codegen/src/emit_queries.rs index f1aab21..3c26fa0 100644 --- a/crates/lineark-codegen/src/emit_queries.rs +++ b/crates/lineark-codegen/src/emit_queries.rs @@ -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, ) @@ -153,6 +157,7 @@ fn emit_query( rename, &args, is_connection, + is_nullable, object_map, type_kind_map, ) @@ -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, ) -> QueryResult { @@ -278,18 +284,30 @@ fn emit_direct_query( } }; + let (return_type, execute_call) = if is_nullable { + ( + quote! { Option }, + quote! { client.execute::>(&query, variables, #data_path).await }, + ) + } else { + ( + quote! { T }, + quote! { client.execute::(&query, variables, #data_path).await }, + ) + }; + let standalone_fn = quote! { #doc - pub async fn #method_name>(client: &Client, #(#params),*) -> Result { + pub async fn #method_name>(client: &Client, #(#params),*) -> Result<#return_type, LinearError> { let variables = serde_json::json!({ #(#variables_json),* }); #query_build - client.execute::(&query, variables, #data_path).await + #execute_call } }; let client_method = quote! { #doc - pub async fn #method_name>(&self, #(#params),*) -> Result { + pub async fn #method_name>(&self, #(#params),*) -> Result<#return_type, LinearError> { crate::generated::queries::#method_name::(self, #(#call_args),*).await } }; @@ -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, ) -> QueryResult { @@ -456,12 +475,24 @@ fn emit_builder_query( } }; + let (return_type, execute_call) = if is_nullable { + ( + quote! { Option }, + quote! { self.client.execute::>(&query, variables, #data_path).await }, + ) + } else { + ( + quote! { T }, + quote! { self.client.execute::(&query, variables, #data_path).await }, + ) + }; + ( - quote! { T }, + return_type, quote! { #build_variables #query_build - self.client.execute::(&query, variables, #data_path).await + #execute_call }, ) }; diff --git a/crates/lineark-sdk/src/field_selection.rs b/crates/lineark-sdk/src/field_selection.rs index 18a9c1f..b61845a 100644 --- a/crates/lineark-sdk/src/field_selection.rs +++ b/crates/lineark-sdk/src/field_selection.rs @@ -45,6 +45,16 @@ pub trait GraphQLFields { fn selection() -> String; } +// Nullable queries: Option delegates to T's selection. +// This allows `client.some_nullable_query::>()` to work +// when the GraphQL schema returns a nullable type (e.g. `Issue` vs `Issue!`). +impl GraphQLFields for Option { + 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 @@ -68,3 +78,62 @@ impl FieldCompatible> for Option> {} impl FieldCompatible for chrono::DateTime {} impl FieldCompatible> for Option> {} impl FieldCompatible for Option> {} + +#[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!( + as GraphQLFields>::selection(), + "id title url" + ); + } + + #[test] + fn option_preserves_full_type() { + // Compile-time proof: Option::FullType == FakeFullType + fn assert_full_type>() {} + assert_full_type::(); + assert_full_type::>(); + } + + #[test] + fn option_nullable_query_deserialization() { + // Proves the full chain: Option 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 = 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 = serde_json::from_value(obj_val).unwrap(); + assert_eq!(result.unwrap().id, "abc-123"); + + // Selection works through Option + assert_eq!( as GraphQLFields>::selection(), "id"); + } +} diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..5dd5008 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -101,6 +101,17 @@ impl Client { ) -> Result { crate::generated::queries::issue::(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, + >( + &self, + branch_name: String, + ) -> Result, LinearError> { + crate::generated::queries::issue_vcs_branch_search::(self, branch_name).await + } /// All issue relationships. /// /// Full type: [`IssueRelation`](super::types::IssueRelation) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..304d1dd 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -1208,6 +1208,29 @@ pub async fn issue(&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, +>( + client: &Client, + branch_name: String, +) -> Result, 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::>(&query, variables, "issueVcsBranchSearch") + .await +} /// All issue relationships. /// /// Full type: [`IssueRelation`](super::types::IssueRelation) diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..7b9ed35 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -12,6 +12,7 @@ cycles = true cycle = true issueLabels = true searchIssues = true +issueVcsBranchSearch = true workflowStates = true # Phase 3 — Rich features