diff --git a/birdie_snapshots/query_with_json_scalar_field.accepted b/birdie_snapshots/query_with_json_scalar_field.accepted new file mode 100644 index 0000000..8f655c5 --- /dev/null +++ b/birdie_snapshots/query_with_json_scalar_field.accepted @@ -0,0 +1,71 @@ +--- +version: 1.4.1 +title: Query with JSON scalar field +--- +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} +import gleam/dynamic.{type Dynamic} + +pub type Profile { + Profile(id: String, display_name: Option(String), metadata: Option(Dynamic)) +} + +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 metadata <- decode.field("metadata", decode.optional(decode.dynamic)) + decode.success(Profile(id: id, display_name: display_name, metadata: metadata)) +} + +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 metadata } }" + 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 f175f2a..b264992 100644 --- a/src/squall/internal/codegen.gleam +++ b/src/squall/internal/codegen.gleam @@ -129,12 +129,28 @@ fn detect_option_usage_in_gleam_type( | type_mapping.IntType | type_mapping.FloatType | type_mapping.BoolType + | type_mapping.DynamicType | type_mapping.CustomType(_) -> False type_mapping.ListType(inner) -> detect_option_usage_in_gleam_type(inner) type_mapping.OptionType(_inner) -> True } } +fn detect_dynamic_usage_in_gleam_type( + gleam_type: type_mapping.GleamType, +) -> Bool { + case gleam_type { + type_mapping.StringType + | type_mapping.IntType + | type_mapping.FloatType + | type_mapping.BoolType + | type_mapping.CustomType(_) -> False + type_mapping.DynamicType -> True + type_mapping.ListType(inner) -> detect_dynamic_usage_in_gleam_type(inner) + type_mapping.OptionType(inner) -> detect_dynamic_usage_in_gleam_type(inner) + } +} + /// Detect if Option types are used in any field fn detect_option_usage(fields: List(#(String, schema.TypeRef))) -> Bool { fields @@ -152,8 +168,25 @@ fn detect_option_usage(fields: List(#(String, schema.TypeRef))) -> Bool { }) } -/// Generate imports section with conditional Option import -fn imports_doc(needs_option: Bool) -> Document { +/// Detect if Dynamic types are used in any field +fn detect_dynamic_usage(fields: List(#(String, schema.TypeRef))) -> Bool { + fields + |> list.fold(False, fn(acc, field) { + let #(_field_name, type_ref) = field + + // Convert to GleamType + case type_mapping.graphql_to_gleam_nullable(type_ref) { + Ok(gleam_type) -> { + let needs_dynamic = detect_dynamic_usage_in_gleam_type(gleam_type) + acc || needs_dynamic + } + Error(_) -> acc + } + }) +} + +/// Generate imports section with conditional Option and Dynamic imports +fn imports_doc(needs_option: Bool, needs_dynamic: Bool) -> Document { let core_imports = [ "import gleam/dynamic/decode", "import gleam/http", @@ -170,7 +203,13 @@ fn imports_doc(needs_option: Bool) -> Document { False -> [] } + let dynamic_imports = case needs_dynamic { + True -> ["import gleam/dynamic.{type Dynamic}"] + False -> [] + } + list.append(core_imports, optional_imports) + |> list.append(dynamic_imports) |> list.map(doc.from_string) |> doc.join(with: doc.line) } @@ -281,9 +320,10 @@ pub fn generate_operation( ]) let needs_option = detect_option_usage(all_field_types) + let needs_dynamic = detect_dynamic_usage(all_field_types) // Build imports - let imports = imports_doc(needs_option) + let imports = imports_doc(needs_option, needs_dynamic) // Combine all code using doc combinators // Order: imports, input types, nested types, response type, response decoder, function @@ -598,6 +638,7 @@ fn generate_field_decoder_with_schema( type_mapping.IntType -> "decode.int" type_mapping.FloatType -> "decode.float" type_mapping.BoolType -> "decode.bool" + type_mapping.DynamicType -> "decode.dynamic" type_mapping.ListType(inner) -> { let inner_decoder = generate_field_decoder_with_schema_inner(inner, type_ref, schema_types) @@ -655,6 +696,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.DynamicType -> "decode.dynamic" type_mapping.ListType(inner) -> "decode.list(" <> generate_field_decoder(inner) <> ")" type_mapping.OptionType(inner) -> @@ -756,6 +798,9 @@ 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.DynamicType -> + // Dynamic types in inputs would need custom encoding, use identity for now + doc.from_string(field_access) type_mapping.ListType(inner) -> { let base_type_name = get_base_type_name(type_ref) case dict.get(schema_types, base_type_name) { @@ -1028,6 +1073,9 @@ 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.DynamicType -> + // Dynamic types in variables would need custom encoding + doc.from_string(var_name) type_mapping.ListType(inner) -> { let base_type_name = get_base_type_name(type_ref) case dict.get(schema_types, base_type_name) { diff --git a/src/squall/internal/type_mapping.gleam b/src/squall/internal/type_mapping.gleam index 3deab86..e7ab1fd 100644 --- a/src/squall/internal/type_mapping.gleam +++ b/src/squall/internal/type_mapping.gleam @@ -11,6 +11,7 @@ pub type GleamType { IntType FloatType BoolType + DynamicType ListType(inner: GleamType) OptionType(inner: GleamType) CustomType(name: String) @@ -62,6 +63,7 @@ fn map_scalar_type(name: String) -> Result(GleamType, Error) { "Float" -> Ok(FloatType) "Boolean" -> Ok(BoolType) "ID" -> Ok(StringType) + "JSON" -> Ok(DynamicType) // Unknown scalars default to String _ -> Ok(StringType) } @@ -150,6 +152,13 @@ pub fn is_bool_type(gleam_type: GleamType) -> Bool { } } +pub fn is_dynamic_type(gleam_type: GleamType) -> Bool { + case gleam_type { + DynamicType -> True + _ -> False + } +} + pub fn is_list_type(gleam_type: GleamType) -> Bool { case gleam_type { ListType(_) -> True @@ -185,6 +194,7 @@ pub fn to_gleam_type_string(gleam_type: GleamType) -> String { IntType -> "Int" FloatType -> "Float" BoolType -> "Bool" + DynamicType -> "Dynamic" 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 bbb5e7f..18efdf7 100644 --- a/test/codegen_test.gleam +++ b/test/codegen_test.gleam @@ -1005,3 +1005,72 @@ pub fn generate_query_with_all_non_nullable_fields_test() { Error(_) -> Nil } } + +// Test: Generate query with JSON scalar field +pub fn generate_query_with_json_scalar_test() { + let query_source = + " + query GetProfile { + profile { + id + displayName + metadata + } + } + " + + let assert Ok(operation) = parser.parse(query_source) + + // Create mock schema with JSON scalar field + 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("metadata", schema.NamedType("JSON", 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 JSON scalar field") + } + Error(_) -> Nil + } +} diff --git a/test/type_mapping_test.gleam b/test/type_mapping_test.gleam index 199b00a..ac65b86 100644 --- a/test/type_mapping_test.gleam +++ b/test/type_mapping_test.gleam @@ -158,3 +158,51 @@ pub fn unsupported_scalar_test() { // 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() { + let graphql_type = schema.NamedType("JSON", schema.Scalar) + let result = type_mapping.graphql_to_gleam(graphql_type) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_dynamic_type(gleam_type) + |> should.be_true() +} + +// Test: Map nullable JSON to Option(Dynamic) +pub fn map_nullable_json_test() { + let graphql_type = schema.NamedType("JSON", schema.Scalar) + let result = type_mapping.graphql_to_gleam_nullable(graphql_type) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_option_type(gleam_type) + |> should.be_true() +} + +// Test: Map NonNull JSON to Dynamic (not Option) +pub fn map_non_null_json_test() { + let inner = schema.NamedType("JSON", schema.Scalar) + let graphql_type = schema.NonNullType(inner) + let result = type_mapping.graphql_to_gleam(graphql_type) + + should.be_ok(result) + let assert Ok(gleam_type) = result + + type_mapping.is_dynamic_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 + let type_string = type_mapping.to_gleam_type_string(gleam_type) + + should.equal(type_string, "Dynamic") +}