diff --git a/birdie_snapshots/mutation_with_input_object_variable.accepted b/birdie_snapshots/mutation_with_input_object_variable.accepted index e6e9ce7..4b6a02c 100644 --- a/birdie_snapshots/mutation_with_input_object_variable.accepted +++ b/birdie_snapshots/mutation_with_input_object_variable.accepted @@ -12,19 +12,31 @@ import gleam/json import gleam/list import gleam/result import squall -import gleam/option.{type Option} +import gleam/option.{type Option, Some, None} pub type ProfileInput { ProfileInput(display_name: Option(String), description: Option(String)) } fn profile_input_to_json(input: ProfileInput) -> json.Json { - json.object( - [ - #("displayName", json.nullable(input.display_name, json.string)), - #("description", json.nullable(input.description, json.string)), - ], - ) + [{ + case input.display_name { + Some(val) -> Some(#("displayName", json.string(val))) + None -> None + } + }, { + case input.description { + Some(val) -> Some(#("description", json.string(val))) + None -> None + } + }] + |> list.filter_map(fn(x) { + case x { + Some(val) -> Ok(val) + None -> Error(Nil) + } + }) + |> json.object } pub type Profile { diff --git a/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted b/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted new file mode 100644 index 0000000..65c73a5 --- /dev/null +++ b/birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted @@ -0,0 +1,97 @@ +--- +version: 1.4.1 +title: Mutation with JSON scalar in InputObject +file: ./test/codegen_test.gleam +test_name: generate_mutation_with_json_input_field_test +--- +import gleam/dynamic/decode +import gleam/http +import gleam/http/request +import gleam/httpc +import gleam/json +import gleam/list +import gleam/result +import squall +import gleam/option.{type Option, Some, None} +import gleam/dynamic.{type Dynamic} + +pub type SettingsInput { + SettingsInput(metadata: Option(json.Json), display_name: Option(String)) +} + +fn settings_input_to_json(input: SettingsInput) -> json.Json { + [{ + case input.metadata { + Some(val) -> Some(#("metadata", val)) + None -> None + } + }, { + case input.display_name { + Some(val) -> Some(#("displayName", json.string(val))) + None -> None + } + }] + |> list.filter_map(fn(x) { + case x { + Some(val) -> Ok(val) + None -> Error(Nil) + } + }) + |> json.object +} + +pub type Settings { + Settings(id: String, metadata: Option(Dynamic)) +} + +pub fn settings_decoder() -> decode.Decoder(Settings) { + use id <- decode.field("id", decode.string) + use metadata <- decode.field("metadata", decode.optional(decode.dynamic)) + decode.success(Settings(id: id, metadata: metadata)) +} + +pub type UpdateSettingsResponse { + UpdateSettingsResponse(update_settings: Option(Settings)) +} + +pub fn update_settings_response_decoder() -> decode.Decoder(UpdateSettingsResponse) { + use update_settings <- decode.field("updateSettings", decode.optional(settings_decoder())) + decode.success(UpdateSettingsResponse(update_settings: update_settings)) +} + +pub fn update_settings(client: squall.Client, input: SettingsInput) -> Result(UpdateSettingsResponse, String) { + let query = + "mutation UpdateSettings($input: SettingsInput!) { updateSettings(input: $input) { id metadata } }" + let variables = + json.object([#("input", settings_input_to_json(input))]) + let body = + json.object([#("query", json.string(query)), #("variables", variables)]) + use req <- result.try( + request.to(client.endpoint) + |> result.map_error(fn(_) { "Invalid endpoint URL" }), + ) + let req = + req + |> request.set_method(http.Post) + |> request.set_body(json.to_string(body)) + |> request.set_header("content-type", "application/json") + let req = + list.fold(client.headers, req, fn(r, header) { + request.set_header(r, header.0, header.1) + }) + use resp <- result.try( + httpc.send(req) + |> result.map_error(fn(_) { "HTTP request failed" }), + ) + use json_value <- result.try( + json.parse(from: resp.body, using: decode.dynamic) + |> result.map_error(fn(_) { "Failed to decode JSON response" }), + ) + let data_and_response_decoder = { + use data <- decode.field("data", update_settings_response_decoder()) + decode.success(data) + } + decode.run(json_value, data_and_response_decoder) + |> result.map_error(fn(_) { "Failed to decode response data" }) +} + diff --git a/birdie_snapshots/mutation_with_nested_input_object_types.accepted b/birdie_snapshots/mutation_with_nested_input_object_types.accepted index db5d3c5..a08f681 100644 --- a/birdie_snapshots/mutation_with_nested_input_object_types.accepted +++ b/birdie_snapshots/mutation_with_nested_input_object_types.accepted @@ -12,19 +12,23 @@ import gleam/json import gleam/list import gleam/result import squall -import gleam/option.{type Option} +import gleam/option.{type Option, Some, None} pub type BlobInput { BlobInput(data: String, mime_type: String) } fn blob_input_to_json(input: BlobInput) -> json.Json { - json.object( - [ - #("data", json.string(input.data)), - #("mimeType", json.string(input.mime_type)), - ], - ) + [Some(#("data", json.string(input.data))), Some(#("mimeType", json.string( + input.mime_type, + )))] + |> list.filter_map(fn(x) { + case x { + Some(val) -> Ok(val) + None -> Error(Nil) + } + }) + |> json.object } pub type ProfileInput { @@ -36,16 +40,29 @@ pub type ProfileInput { } fn profile_input_to_json(input: ProfileInput) -> json.Json { - json.object( - [ - #("displayName", json.nullable(input.display_name, json.string)), - #("avatar", json.nullable(input.avatar, blob_input_to_json)), - #("interests", json.nullable( - input.interests, - fn(list) { json.array(from: list, of: json.string) }, - )), - ], - ) + [{ + case input.display_name { + Some(val) -> Some(#("displayName", json.string(val))) + None -> None + } + }, { + case input.avatar { + Some(val) -> Some(#("avatar", blob_input_to_json(val))) + None -> None + } + }, { + case input.interests { + Some(val) -> Some(#("interests", json.array(from: val, of: json.string))) + None -> None + } + }] + |> list.filter_map(fn(x) { + case x { + Some(val) -> Ok(val) + None -> Error(Nil) + } + }) + |> json.object } pub type Profile { diff --git a/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted b/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted new file mode 100644 index 0000000..fc0d062 --- /dev/null +++ b/birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted @@ -0,0 +1,110 @@ +--- +version: 1.4.1 +title: Mutation with optional InputObject fields (imports Some, None) +file: ./test/codegen_test.gleam +test_name: generate_mutation_with_optional_input_fields_test +--- +import gleam/dynamic/decode +import gleam/http +import gleam/http/request +import gleam/httpc +import gleam/json +import gleam/list +import gleam/result +import squall +import gleam/option.{type Option, Some, None} + +pub type ProfileInput { + ProfileInput( + display_name: Option(String), + description: Option(String), + avatar: Option(json.Json), + ) +} + +fn profile_input_to_json(input: ProfileInput) -> json.Json { + [{ + case input.display_name { + Some(val) -> Some(#("displayName", json.string(val))) + None -> None + } + }, { + case input.description { + Some(val) -> Some(#("description", json.string(val))) + None -> None + } + }, { + case input.avatar { + Some(val) -> Some(#("avatar", val)) + None -> None + } + }] + |> list.filter_map(fn(x) { + case x { + Some(val) -> Ok(val) + None -> Error(Nil) + } + }) + |> json.object +} + +pub type Profile { + Profile(id: String, display_name: Option(String), description: Option(String)) +} + +pub fn profile_decoder() -> decode.Decoder(Profile) { + use id <- decode.field("id", decode.string) + use display_name <- decode.field("displayName", decode.optional(decode.string)) + use description <- decode.field("description", decode.optional(decode.string)) + decode.success(Profile( + id: id, + display_name: display_name, + description: description, + )) +} + +pub type CreateProfileResponse { + CreateProfileResponse(create_profile: Option(Profile)) +} + +pub fn create_profile_response_decoder() -> decode.Decoder(CreateProfileResponse) { + use create_profile <- decode.field("createProfile", decode.optional(profile_decoder())) + decode.success(CreateProfileResponse(create_profile: create_profile)) +} + +pub fn create_profile(client: squall.Client, input: ProfileInput) -> Result(CreateProfileResponse, String) { + let query = + "mutation CreateProfile($input: ProfileInput!) { createProfile(input: $input) { id displayName description } }" + let variables = + json.object([#("input", profile_input_to_json(input))]) + let body = + json.object([#("query", json.string(query)), #("variables", variables)]) + use req <- result.try( + request.to(client.endpoint) + |> result.map_error(fn(_) { "Invalid endpoint URL" }), + ) + let req = + req + |> request.set_method(http.Post) + |> request.set_body(json.to_string(body)) + |> request.set_header("content-type", "application/json") + let req = + list.fold(client.headers, req, fn(r, header) { + request.set_header(r, header.0, header.1) + }) + use resp <- result.try( + httpc.send(req) + |> result.map_error(fn(_) { "HTTP request failed" }), + ) + use json_value <- result.try( + json.parse(from: resp.body, using: decode.dynamic) + |> result.map_error(fn(_) { "Failed to decode JSON response" }), + ) + let data_and_response_decoder = { + use data <- decode.field("data", create_profile_response_decoder()) + decode.success(data) + } + decode.run(json_value, data_and_response_decoder) + |> result.map_error(fn(_) { "Failed to decode response data" }) +} + diff --git a/birdie_snapshots/query_with_json_scalar_field.accepted b/birdie_snapshots/query_with_json_scalar_field.accepted index 8f655c5..55a9111 100644 --- a/birdie_snapshots/query_with_json_scalar_field.accepted +++ b/birdie_snapshots/query_with_json_scalar_field.accepted @@ -1,6 +1,8 @@ --- version: 1.4.1 title: Query with JSON scalar field +file: ./test/codegen_test.gleam +test_name: generate_query_with_json_scalar_test --- import gleam/dynamic/decode import gleam/http diff --git a/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted b/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted new file mode 100644 index 0000000..056fb71 --- /dev/null +++ b/birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted @@ -0,0 +1,76 @@ +--- +version: 1.4.1 +title: Query with optional response fields (no Some, None imports) +file: ./test/codegen_test.gleam +test_name: generate_query_with_optional_response_fields_test +--- +import gleam/dynamic/decode +import gleam/http +import gleam/http/request +import gleam/httpc +import gleam/json +import gleam/list +import gleam/result +import squall +import gleam/option.{type Option} + +pub type Profile { + Profile(id: String, display_name: Option(String), description: Option(String)) +} + +pub fn profile_decoder() -> decode.Decoder(Profile) { + use id <- decode.field("id", decode.string) + use display_name <- decode.field("displayName", decode.optional(decode.string)) + use description <- decode.field("description", decode.optional(decode.string)) + decode.success(Profile( + id: id, + display_name: display_name, + description: description, + )) +} + +pub type GetProfileResponse { + GetProfileResponse(profile: Option(Profile)) +} + +pub fn get_profile_response_decoder() -> decode.Decoder(GetProfileResponse) { + use profile <- decode.field("profile", decode.optional(profile_decoder())) + decode.success(GetProfileResponse(profile: profile)) +} + +pub fn get_profile(client: squall.Client) -> Result(GetProfileResponse, String) { + let query = + "query GetProfile { profile { id displayName description } }" + let variables = + json.object([]) + let body = + json.object([#("query", json.string(query)), #("variables", variables)]) + use req <- result.try( + request.to(client.endpoint) + |> result.map_error(fn(_) { "Invalid endpoint URL" }), + ) + let req = + req + |> request.set_method(http.Post) + |> request.set_body(json.to_string(body)) + |> request.set_header("content-type", "application/json") + let req = + list.fold(client.headers, req, fn(r, header) { + request.set_header(r, header.0, header.1) + }) + use resp <- result.try( + httpc.send(req) + |> result.map_error(fn(_) { "HTTP request failed" }), + ) + use json_value <- result.try( + json.parse(from: resp.body, using: decode.dynamic) + |> result.map_error(fn(_) { "Failed to decode JSON response" }), + ) + let data_and_response_decoder = { + use data <- decode.field("data", get_profile_response_decoder()) + decode.success(data) + } + decode.run(json_value, data_and_response_decoder) + |> result.map_error(fn(_) { "Failed to decode response data" }) +} + diff --git a/src/squall/internal/codegen.gleam b/src/squall/internal/codegen.gleam index b264992..e7e1ab2 100644 --- a/src/squall/internal/codegen.gleam +++ b/src/squall/internal/codegen.gleam @@ -130,6 +130,7 @@ fn detect_option_usage_in_gleam_type( | type_mapping.FloatType | type_mapping.BoolType | type_mapping.DynamicType + | type_mapping.JsonType | type_mapping.CustomType(_) -> False type_mapping.ListType(inner) -> detect_option_usage_in_gleam_type(inner) type_mapping.OptionType(_inner) -> True @@ -144,6 +145,7 @@ fn detect_dynamic_usage_in_gleam_type( | type_mapping.IntType | type_mapping.FloatType | type_mapping.BoolType + | type_mapping.JsonType | type_mapping.CustomType(_) -> False type_mapping.DynamicType -> True type_mapping.ListType(inner) -> detect_dynamic_usage_in_gleam_type(inner) @@ -157,8 +159,8 @@ fn detect_option_usage(fields: List(#(String, schema.TypeRef))) -> Bool { |> list.fold(False, fn(acc, field) { let #(_field_name, type_ref) = field - // Convert to GleamType - case type_mapping.graphql_to_gleam_nullable(type_ref) { + // Convert to GleamType (use OutputContext for response types) + case type_mapping.graphql_to_gleam_nullable(type_ref, type_mapping.OutputContext) { Ok(gleam_type) -> { let needs_option = detect_option_usage_in_gleam_type(gleam_type) acc || needs_option @@ -174,8 +176,8 @@ fn detect_dynamic_usage(fields: List(#(String, schema.TypeRef))) -> Bool { |> list.fold(False, fn(acc, field) { let #(_field_name, type_ref) = field - // Convert to GleamType - case type_mapping.graphql_to_gleam_nullable(type_ref) { + // Convert to GleamType (use OutputContext for response types) + case type_mapping.graphql_to_gleam_nullable(type_ref, type_mapping.OutputContext) { Ok(gleam_type) -> { let needs_dynamic = detect_dynamic_usage_in_gleam_type(gleam_type) acc || needs_dynamic @@ -185,8 +187,23 @@ fn detect_dynamic_usage(fields: List(#(String, schema.TypeRef))) -> Bool { }) } +/// Detect if Some/None constructors are needed (only for input serializers with optional fields) +fn detect_optional_input_fields(input_types: List(InputTypeInfo)) -> Bool { + input_types + |> list.any(fn(input_info) { + input_info.input_fields + |> list.any(fn(field) { + // Check if field is NOT NonNullType (i.e., it's optional) + case field.type_ref { + schema.NonNullType(_) -> False + _ -> True + } + }) + }) +} + /// Generate imports section with conditional Option and Dynamic imports -fn imports_doc(needs_option: Bool, needs_dynamic: Bool) -> Document { +fn imports_doc(needs_option: Bool, needs_dynamic: Bool, needs_option_constructors: Bool) -> Document { let core_imports = [ "import gleam/dynamic/decode", "import gleam/http", @@ -198,9 +215,10 @@ fn imports_doc(needs_option: Bool, needs_dynamic: Bool) -> Document { "import squall", ] - let optional_imports = case needs_option { - True -> ["import gleam/option.{type Option}"] - False -> [] + let optional_imports = case needs_option, needs_option_constructors { + False, _ -> [] + True, False -> ["import gleam/option.{type Option}"] + True, True -> ["import gleam/option.{type Option, Some, None}"] } let dynamic_imports = case needs_dynamic { @@ -308,22 +326,28 @@ pub fn generate_operation( ) // Detect Option type usage from all field types - // Collect all field types: response, nested, input, and variables - let all_field_types = + // Separate output fields (response/nested) from input fields for proper context detection + let output_field_types = list.flatten([ field_types, list.flat_map(nested_types, fn(nt) { nt.fields }), - list.flat_map(input_types, fn(it) { - it.input_fields - |> list.map(fn(iv) { #(iv.name, iv.type_ref) }) - }), ]) + let input_field_types = + list.flat_map(input_types, fn(it) { + it.input_fields + |> list.map(fn(iv) { #(iv.name, iv.type_ref) }) + }) + + let all_field_types = list.append(output_field_types, input_field_types) + let needs_option = detect_option_usage(all_field_types) - let needs_dynamic = detect_dynamic_usage(all_field_types) + // Only check output fields for Dynamic usage (input fields use json.Json, not Dynamic) + let needs_dynamic = detect_dynamic_usage(output_field_types) + let needs_option_constructors = detect_optional_input_fields(input_types) // Build imports - let imports = imports_doc(needs_option, needs_dynamic) + let imports = imports_doc(needs_option, needs_dynamic, needs_option_constructors) // Combine all code using doc combinators // Order: imports, input types, nested types, response type, response decoder, function @@ -441,6 +465,14 @@ fn get_base_type_name(type_ref: schema.TypeRef) -> String { } } +// Unwrap NonNull wrapper from optional type +fn unwrap_option_type(type_ref: schema.TypeRef) -> schema.TypeRef { + case type_ref { + schema.NonNullType(inner) -> inner + _ -> type_ref + } +} + // Collect all InputObject types used in variables fn collect_input_types( variables: List(parser.Variable), @@ -547,6 +579,7 @@ fn generate_type_definition( let sanitized_name = sanitize_field_name(name) use gleam_type <- result.try(type_mapping.graphql_to_gleam_nullable( type_ref, + type_mapping.OutputContext, )) let field_doc = doc.concat([ @@ -587,6 +620,7 @@ fn generate_decoder_with_schema( let sanitized_name = sanitize_field_name(name) use gleam_type <- result.try(type_mapping.graphql_to_gleam_nullable( type_ref, + type_mapping.OutputContext, )) let field_decoder = doc.concat([ @@ -638,6 +672,7 @@ fn generate_field_decoder_with_schema( type_mapping.IntType -> "decode.int" type_mapping.FloatType -> "decode.float" type_mapping.BoolType -> "decode.bool" + type_mapping.JsonType -> "decode.dynamic" type_mapping.DynamicType -> "decode.dynamic" type_mapping.ListType(inner) -> { let inner_decoder = @@ -696,6 +731,7 @@ fn generate_field_decoder(gleam_type: type_mapping.GleamType) -> String { type_mapping.IntType -> "decode.int" type_mapping.FloatType -> "decode.float" type_mapping.BoolType -> "decode.bool" + type_mapping.JsonType -> "decode.dynamic" type_mapping.DynamicType -> "decode.dynamic" type_mapping.ListType(inner) -> "decode.list(" <> generate_field_decoder(inner) <> ")" @@ -713,6 +749,7 @@ fn generate_input_type_definition(input_info: InputTypeInfo) -> Document { let sanitized_name = sanitize_field_name(input_value.name) use gleam_type <- result.try(type_mapping.graphql_to_gleam_nullable( input_value.type_ref, + type_mapping.InputContext, )) let field_doc = doc.concat([ @@ -749,30 +786,82 @@ fn generate_input_serializer(input_info: InputTypeInfo) -> Document { let sanitized_name = sanitize_field_name(input_value.name) use gleam_type <- result.try(type_mapping.graphql_to_gleam_nullable( input_value.type_ref, + type_mapping.InputContext, )) - let value_expr = - encode_input_field_value( - param_name <> "." <> sanitized_name, - gleam_type, - input_value.type_ref, - input_info.field_types, - ) + // Generate code that wraps optional fields in case/Some/None + case gleam_type { + type_mapping.OptionType(inner) -> { + let inner_encoder = + encode_input_field_value( + "val", + inner, + unwrap_option_type(input_value.type_ref), + input_info.field_types, + ) - Ok( - doc.concat([ - doc.from_string("#("), - string_doc(input_value.name), - doc.from_string(", "), - value_expr, - doc.from_string(")"), - ]), - ) + Ok( + doc.concat([ + doc.from_string("{"), + doc.line, + doc.from_string(" case " <> param_name <> "." <> sanitized_name <> " {"), + doc.line, + doc.from_string(" Some(val) -> Some(#("), + string_doc(input_value.name), + doc.from_string(", "), + inner_encoder, + doc.from_string("))"), + doc.line, + doc.from_string(" None -> None"), + doc.line, + doc.from_string(" }"), + doc.line, + doc.from_string("}"), + ]), + ) + } + _ -> { + // Non-optional field: always include + let value_expr = + encode_input_field_value( + param_name <> "." <> sanitized_name, + gleam_type, + input_value.type_ref, + input_info.field_types, + ) + + Ok( + doc.concat([ + doc.from_string("Some(#("), + string_doc(input_value.name), + doc.from_string(", "), + value_expr, + doc.from_string("))"), + ]), + ) + } + } }) |> list.filter_map(fn(r) { r }) let body = - call_doc("json.object", [comma_list("[", field_entries, "]")]) + doc.concat([ + comma_list("[", field_entries, "]"), + doc.line, + doc.from_string("|> list.filter_map(fn(x) {"), + doc.line, + doc.from_string(" case x {"), + doc.line, + doc.from_string(" Some(val) -> Ok(val)"), + doc.line, + doc.from_string(" None -> Error(Nil)"), + doc.line, + doc.from_string(" }"), + doc.line, + doc.from_string("})"), + doc.line, + doc.from_string("|> json.object"), + ]) doc.concat([ doc.from_string("fn " <> serializer_name <> "("), @@ -798,8 +887,11 @@ fn encode_input_field_value( call_doc("json.float", [doc.from_string(field_access)]) type_mapping.BoolType -> call_doc("json.bool", [doc.from_string(field_access)]) + type_mapping.JsonType -> + // JSON scalar: use the json.Json value directly without wrapping + doc.from_string(field_access) type_mapping.DynamicType -> - // Dynamic types in inputs would need custom encoding, use identity for now + // This case handles other custom scalars that map to Dynamic doc.from_string(field_access) type_mapping.ListType(inner) -> { let base_type_name = get_base_type_name(type_ref) @@ -818,6 +910,7 @@ fn encode_input_field_value( type_mapping.IntType -> "json.int" type_mapping.FloatType -> "json.float" type_mapping.BoolType -> "json.bool" + type_mapping.JsonType -> "fn(x) { x }" _ -> "json.string" } call_doc("json.array", [ @@ -844,6 +937,7 @@ fn encode_input_field_value( type_mapping.IntType -> "json.int" type_mapping.FloatType -> "json.float" type_mapping.BoolType -> "json.bool" + type_mapping.JsonType -> "fn(x) { x }" type_mapping.ListType(_) -> { // This is handled by recursion, but for now use a lambda let of_fn = case inner { @@ -896,6 +990,7 @@ fn generate_function( ) use gleam_type <- result.try(type_mapping.graphql_to_gleam( schema_type_ref, + type_mapping.InputContext, )) let param_name = snake_case(var.name) Ok( @@ -925,6 +1020,7 @@ fn generate_function( ) use gleam_type <- result.try(type_mapping.graphql_to_gleam( schema_type_ref, + type_mapping.InputContext, )) let param_name = snake_case(var.name) let value_encoder = @@ -1073,8 +1169,11 @@ fn encode_variable_value( type_mapping.FloatType -> call_doc("json.float", [doc.from_string(var_name)]) type_mapping.BoolType -> call_doc("json.bool", [doc.from_string(var_name)]) + type_mapping.JsonType -> + // JSON scalar: use the json.Json value directly without wrapping + doc.from_string(var_name) type_mapping.DynamicType -> - // Dynamic types in variables would need custom encoding + // This case handles other custom scalars that map to Dynamic doc.from_string(var_name) type_mapping.ListType(inner) -> { let base_type_name = get_base_type_name(type_ref) @@ -1093,6 +1192,7 @@ fn encode_variable_value( type_mapping.IntType -> "json.int" type_mapping.FloatType -> "json.float" type_mapping.BoolType -> "json.bool" + type_mapping.JsonType -> "fn(x) { x }" _ -> "json.string" } call_doc("json.array", [ diff --git a/src/squall/internal/type_mapping.gleam b/src/squall/internal/type_mapping.gleam index e7ab1fd..6787a91 100644 --- a/src/squall/internal/type_mapping.gleam +++ b/src/squall/internal/type_mapping.gleam @@ -5,6 +5,12 @@ import squall/internal/error.{type Error} import squall/internal/parser import squall/internal/schema +// Type context for distinguishing input vs output types +pub type TypeContext { + InputContext + OutputContext +} + // Gleam type representation pub type GleamType { StringType @@ -12,40 +18,49 @@ pub type GleamType { FloatType BoolType DynamicType + JsonType ListType(inner: GleamType) OptionType(inner: GleamType) CustomType(name: String) } // Map GraphQL schema type to Gleam type -pub fn graphql_to_gleam(type_ref: schema.TypeRef) -> Result(GleamType, Error) { +pub fn graphql_to_gleam( + type_ref: schema.TypeRef, + context: TypeContext, +) -> Result(GleamType, Error) { case type_ref { - schema.NamedType(name, kind) -> map_named_type(name, kind) + schema.NamedType(name, kind) -> map_named_type(name, kind, context) schema.ListType(inner) -> { - use inner_gleam <- result.try(graphql_to_gleam(inner)) + use inner_gleam <- result.try(graphql_to_gleam(inner, context)) Ok(ListType(inner_gleam)) } - schema.NonNullType(inner) -> graphql_to_gleam(inner) + schema.NonNullType(inner) -> graphql_to_gleam(inner, context) } } // Map nullable GraphQL type to Gleam type (wraps in Option) pub fn graphql_to_gleam_nullable( type_ref: schema.TypeRef, + context: TypeContext, ) -> Result(GleamType, Error) { case type_ref { - schema.NonNullType(inner) -> graphql_to_gleam(inner) + schema.NonNullType(inner) -> graphql_to_gleam(inner, context) _ -> { - use gleam_type <- result.try(graphql_to_gleam(type_ref)) + use gleam_type <- result.try(graphql_to_gleam(type_ref, context)) Ok(OptionType(gleam_type)) } } } // Map named GraphQL type to Gleam type -fn map_named_type(name: String, kind: schema.TypeKind) -> Result(GleamType, Error) { +fn map_named_type( + name: String, + kind: schema.TypeKind, + context: TypeContext, +) -> Result(GleamType, Error) { case kind { - schema.Scalar -> map_scalar_type(name) + schema.Scalar -> map_scalar_type(name, context) schema.Object -> Ok(CustomType(name)) schema.Interface -> Ok(CustomType(name)) schema.Union -> Ok(CustomType(name)) @@ -56,14 +71,18 @@ fn map_named_type(name: String, kind: schema.TypeKind) -> Result(GleamType, Erro } // Map scalar types -fn map_scalar_type(name: String) -> Result(GleamType, Error) { +fn map_scalar_type(name: String, context: TypeContext) -> Result(GleamType, Error) { case name { "String" -> Ok(StringType) "Int" -> Ok(IntType) "Float" -> Ok(FloatType) "Boolean" -> Ok(BoolType) "ID" -> Ok(StringType) - "JSON" -> Ok(DynamicType) + "JSON" -> + case context { + InputContext -> Ok(JsonType) + OutputContext -> Ok(DynamicType) + } // Unknown scalars default to String _ -> Ok(StringType) } @@ -159,6 +178,13 @@ pub fn is_dynamic_type(gleam_type: GleamType) -> Bool { } } +pub fn is_json_type(gleam_type: GleamType) -> Bool { + case gleam_type { + JsonType -> True + _ -> False + } +} + pub fn is_list_type(gleam_type: GleamType) -> Bool { case gleam_type { ListType(_) -> True @@ -195,6 +221,7 @@ pub fn to_gleam_type_string(gleam_type: GleamType) -> String { FloatType -> "Float" BoolType -> "Bool" DynamicType -> "Dynamic" + JsonType -> "json.Json" ListType(inner) -> "List(" <> to_gleam_type_string(inner) <> ")" OptionType(inner) -> "Option(" <> to_gleam_type_string(inner) <> ")" CustomType(name) -> name diff --git a/test/codegen_test.gleam b/test/codegen_test.gleam index 18efdf7..c08e868 100644 --- a/test/codegen_test.gleam +++ b/test/codegen_test.gleam @@ -1074,3 +1074,279 @@ pub fn generate_query_with_json_scalar_test() { Error(_) -> Nil } } + +// Test: Generate mutation with JSON scalar in InputObject +pub fn generate_mutation_with_json_input_field_test() { + let mutation_source = + " + mutation UpdateSettings($input: SettingsInput!) { + updateSettings(input: $input) { + id + metadata + } + } + " + + let assert Ok(operation) = parser.parse(mutation_source) + + // Define InputObject type with JSON field + let settings_input_fields = [ + schema.InputValue( + "metadata", + schema.NamedType("JSON", schema.Scalar), + None, + ), + schema.InputValue( + "displayName", + schema.NamedType("String", schema.Scalar), + None, + ), + ] + + let settings_fields = [ + schema.Field( + "id", + schema.NonNullType(schema.NamedType("ID", schema.Scalar)), + [], + None, + ), + schema.Field( + "metadata", + schema.NamedType("JSON", schema.Scalar), + [], + None, + ), + ] + + let mock_schema = + schema.Schema( + Some("Query"), + Some("Mutation"), + None, + dict.from_list([ + #("Settings", schema.ObjectType("Settings", settings_fields, None)), + #( + "SettingsInput", + schema.InputObjectType("SettingsInput", settings_input_fields, None), + ), + #( + "Mutation", + schema.ObjectType( + "Mutation", + [ + schema.Field( + "updateSettings", + schema.NamedType("Settings", schema.Object), + [ + schema.InputValue( + "input", + schema.NonNullType(schema.NamedType( + "SettingsInput", + schema.InputObject, + )), + None, + ), + ], + None, + ), + ], + None, + ), + ), + ]), + ) + + let result = + codegen.generate_operation("update_settings", operation, mock_schema, "") + + case result { + Ok(code) -> { + code + |> birdie.snap(title: "Mutation with JSON scalar in InputObject") + } + Error(_) -> Nil + } +} + +// Test: Generate mutation with optional InputObject fields (tests Some/None serializer) +pub fn generate_mutation_with_optional_input_fields_test() { + let mutation_source = + " + mutation CreateProfile($input: ProfileInput!) { + createProfile(input: $input) { + id + displayName + description + } + } + " + + let assert Ok(operation) = parser.parse(mutation_source) + + // Define InputObject type with optional fields (nullable in GraphQL) + let profile_input_fields = [ + schema.InputValue( + "displayName", + schema.NamedType("String", schema.Scalar), + None, + ), + schema.InputValue( + "description", + schema.NamedType("String", schema.Scalar), + None, + ), + schema.InputValue( + "avatar", + schema.NamedType("JSON", schema.Scalar), + None, + ), + ] + + let profile_fields = [ + schema.Field( + "id", + schema.NonNullType(schema.NamedType("ID", schema.Scalar)), + [], + None, + ), + schema.Field( + "displayName", + schema.NamedType("String", schema.Scalar), + [], + None, + ), + schema.Field( + "description", + schema.NamedType("String", schema.Scalar), + [], + None, + ), + ] + + let mock_schema = + schema.Schema( + Some("Query"), + Some("Mutation"), + None, + dict.from_list([ + #("Profile", schema.ObjectType("Profile", profile_fields, None)), + #( + "ProfileInput", + schema.InputObjectType("ProfileInput", profile_input_fields, None), + ), + #( + "Mutation", + schema.ObjectType( + "Mutation", + [ + schema.Field( + "createProfile", + schema.NamedType("Profile", schema.Object), + [ + schema.InputValue( + "input", + schema.NonNullType(schema.NamedType( + "ProfileInput", + schema.InputObject, + )), + None, + ), + ], + None, + ), + ], + None, + ), + ), + ]), + ) + + let result = + codegen.generate_operation("create_profile", operation, mock_schema, "") + + case result { + Ok(code) -> { + code + |> birdie.snap( + title: "Mutation with optional InputObject fields (imports Some, None)", + ) + } + Error(_) -> Nil + } +} + +// Test: Generate query with optional response fields only (should not import Some, None) +pub fn generate_query_with_optional_response_fields_test() { + let query_source = + " + query GetProfile { + profile { + id + displayName + description + } + } + " + + let assert Ok(operation) = parser.parse(query_source) + + // Create mock schema with optional response fields + let profile_fields = [ + schema.Field( + "id", + schema.NonNullType(schema.NamedType("ID", schema.Scalar)), + [], + None, + ), + schema.Field( + "displayName", + schema.NamedType("String", schema.Scalar), + [], + None, + ), + schema.Field( + "description", + schema.NamedType("String", schema.Scalar), + [], + None, + ), + ] + + let mock_schema = + schema.Schema( + Some("Query"), + None, + None, + dict.from_list([ + #("Profile", schema.ObjectType("Profile", profile_fields, None)), + #( + "Query", + schema.ObjectType( + "Query", + [ + schema.Field( + "profile", + schema.NamedType("Profile", schema.Object), + [], + None, + ), + ], + None, + ), + ), + ]), + ) + + let result = + codegen.generate_operation("get_profile", operation, mock_schema, "") + + case result { + Ok(code) -> { + code + |> birdie.snap( + title: "Query with optional response fields (no Some, None imports)", + ) + } + Error(_) -> Nil + } +} diff --git a/test/type_mapping_test.gleam b/test/type_mapping_test.gleam index ac65b86..dce26ed 100644 --- a/test/type_mapping_test.gleam +++ b/test/type_mapping_test.gleam @@ -7,7 +7,7 @@ import squall/internal/type_mapping // Test: Map GraphQL String to Gleam String pub fn map_string_type_test() { let graphql_type = schema.NamedType("String", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -19,7 +19,7 @@ pub fn map_string_type_test() { // Test: Map GraphQL Int to Gleam Int pub fn map_int_type_test() { let graphql_type = schema.NamedType("Int", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -31,7 +31,7 @@ pub fn map_int_type_test() { // Test: Map GraphQL Float to Gleam Float pub fn map_float_type_test() { let graphql_type = schema.NamedType("Float", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -43,7 +43,7 @@ pub fn map_float_type_test() { // Test: Map GraphQL Boolean to Gleam Bool pub fn map_boolean_type_test() { let graphql_type = schema.NamedType("Boolean", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -55,7 +55,7 @@ pub fn map_boolean_type_test() { // Test: Map GraphQL ID to Gleam String pub fn map_id_type_test() { let graphql_type = schema.NamedType("ID", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -67,7 +67,7 @@ pub fn map_id_type_test() { // Test: Map nullable String to Option(String) pub fn map_nullable_string_test() { let graphql_type = schema.NamedType("String", schema.Scalar) - let result = type_mapping.graphql_to_gleam_nullable(graphql_type) + let result = type_mapping.graphql_to_gleam_nullable(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -80,7 +80,7 @@ pub fn map_nullable_string_test() { pub fn map_non_null_string_test() { let inner = schema.NamedType("String", schema.Scalar) let graphql_type = schema.NonNullType(inner) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -96,7 +96,7 @@ pub fn map_non_null_string_test() { pub fn map_list_of_strings_test() { let inner = schema.NamedType("String", schema.Scalar) let graphql_type = schema.ListType(inner) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -112,7 +112,7 @@ pub fn map_non_null_list_non_null_strings_test() { let list_type = schema.ListType(non_null_string) let graphql_type = schema.NonNullType(list_type) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -124,7 +124,7 @@ pub fn map_non_null_list_non_null_strings_test() { // Test: Map custom object type pub fn map_custom_object_type_test() { let graphql_type = schema.NamedType("Character", schema.Object) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -153,16 +153,16 @@ pub fn parse_variable_type_test() { // Test: Unsupported custom scalar pub fn unsupported_scalar_test() { let graphql_type = schema.NamedType("DateTime", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) // Should map unknown scalars to String by default should.be_ok(result) } -// Test: Map GraphQL JSON scalar to Gleam Dynamic -pub fn map_json_type_test() { +// Test: Map GraphQL JSON scalar to Gleam Dynamic (OutputContext) +pub fn map_json_type_output_test() { let graphql_type = schema.NamedType("JSON", schema.Scalar) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -171,23 +171,56 @@ pub fn map_json_type_test() { |> should.be_true() } -// Test: Map nullable JSON to Option(Dynamic) -pub fn map_nullable_json_test() { +// Test: Map GraphQL JSON scalar to Gleam json.Json (InputContext) +pub fn map_json_type_input_test() { let graphql_type = schema.NamedType("JSON", schema.Scalar) - let result = type_mapping.graphql_to_gleam_nullable(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.InputContext) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_json_type(gleam_type) + |> should.be_true() +} + +// Test: Map nullable JSON to Option(Dynamic) (OutputContext) +pub fn map_nullable_json_output_test() { + let graphql_type = schema.NamedType("JSON", schema.Scalar) + let result = type_mapping.graphql_to_gleam_nullable(graphql_type, type_mapping.OutputContext) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_option_type(gleam_type) + |> should.be_true() +} + +// Test: Map nullable JSON to Option(json.Json) (InputContext) +pub fn map_nullable_json_input_test() { + let graphql_type = schema.NamedType("JSON", schema.Scalar) + let result = type_mapping.graphql_to_gleam_nullable(graphql_type, type_mapping.InputContext) should.be_ok(result) let assert Ok(gleam_type) = result type_mapping.is_option_type(gleam_type) |> should.be_true() + + // Inner type should be json.Json + case gleam_type { + type_mapping.OptionType(inner) -> { + type_mapping.is_json_type(inner) + |> should.be_true() + } + _ -> should.equal(True, False) + } } -// Test: Map NonNull JSON to Dynamic (not Option) -pub fn map_non_null_json_test() { +// Test: Map NonNull JSON to Dynamic in OutputContext (not Option) +pub fn map_non_null_json_output_test() { let inner = schema.NamedType("JSON", schema.Scalar) let graphql_type = schema.NonNullType(inner) - let result = type_mapping.graphql_to_gleam(graphql_type) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.OutputContext) should.be_ok(result) let assert Ok(gleam_type) = result @@ -199,6 +232,22 @@ pub fn map_non_null_json_test() { |> should.be_false() } +// Test: Map NonNull JSON to json.Json in InputContext (not Option) +pub fn map_non_null_json_input_test() { + let inner = schema.NamedType("JSON", schema.Scalar) + let graphql_type = schema.NonNullType(inner) + let result = type_mapping.graphql_to_gleam(graphql_type, type_mapping.InputContext) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_json_type(gleam_type) + |> should.be_true() + + type_mapping.is_option_type(gleam_type) + |> should.be_false() +} + // Test: to_gleam_type_string for DynamicType pub fn dynamic_type_string_test() { let gleam_type = type_mapping.DynamicType