From fe2f8fb6ea57a4c343ccde77456b2c0e861c5c50 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Fri, 24 Oct 2025 10:05:53 -0700 Subject: [PATCH] improve optional input field handling and JSON scalar type mapping Changes input object serialization to omit optional fields when None, rather than serializing them as null. Input serializers now use Some/None pattern matching to conditionally include fields in the generated JSON object. Adds context-aware type mapping where JSON scalars map to json.Json in input contexts and Dynamic in output contexts. This ensures proper type safety when passing JSON values to mutations while maintaining flexibility when receiving JSON from queries. Optimizes imports to only include Some/None constructors when input types have optional fields, reducing unnecessary imports for queries with optional response fields. --- ...tation_with_input_object_variable.accepted | 26 +- ..._with_json_scalar_in_input_object.accepted | 97 ++++++ ...on_with_nested_input_object_types.accepted | 51 ++-- ...bject_fields_(imports_some,_none).accepted | 110 +++++++ .../query_with_json_scalar_field.accepted | 2 + ...se_fields_(no_some,_none_imports).accepted | 76 +++++ src/squall/internal/codegen.gleam | 170 ++++++++--- src/squall/internal/type_mapping.gleam | 47 ++- test/codegen_test.gleam | 276 ++++++++++++++++++ test/type_mapping_test.gleam | 89 ++++-- 10 files changed, 855 insertions(+), 89 deletions(-) create mode 100644 birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted create mode 100644 birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted create mode 100644 birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted 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 -- 2.51.2