diff --git a/README.md b/README.md index 18731e5..0580eea 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # mochi_codegen -Code generation (TypeScript types, GraphQL SDL, Gleam types) and CLI for mochi GraphQL. +Code generation (TypeScript types, GraphQL SDL, Gleam types + resolver stubs) and CLI for mochi GraphQL. ## Installation @@ -16,10 +16,43 @@ mochi_codegen = { git = "https://github.com/qwexvf/mochi_codegen", ref = "main" ## CLI ```sh -gleam run -m mochi_codegen/cli -- init # create mochi.config.json +gleam run -m mochi_codegen/cli -- init # create mochi.config.yaml gleam run -m mochi_codegen/cli -- generate # generate from config ``` +## Config (`mochi.config.yaml`) + +```yaml +schema: "graphql/*.graphql" + +# Optional: generate resolver boilerplate from .gql client operation files +operations_input: "src/graphql/**/*.gql" + +output: + typescript: "src/generated/types.ts" # TypeScript type definitions + gleam_types: "src/api/domain/" # Gleam domain types (dir = one file per schema) + resolvers: "src/api/schema/" # Gleam resolver stubs (preserved on regen) + operations: "src/api/schema/" # Gleam operation resolvers (from .gql files) + sdl: null # Normalised SDL (omit to skip) + +gleam: + types_module_prefix: "api/domain" + resolvers_module_prefix: "api/schema" + type_suffix: "_types" + resolver_suffix: "_resolvers" + generate_docs: true +``` + +Output paths ending in `/` produce one file per schema file. Paths without `/` merge all schemas into a single file. + +### Write policies + +| Output | Policy | +|--------|--------| +| `typescript`, `sdl` | Always overwrite | +| `gleam_types` | Overwrite only when content changed | +| `resolvers`, `operations` | Never overwrite existing functions — only appends new stubs | + ## Programmatic Usage ```gleam @@ -30,21 +63,9 @@ let sdl = mochi_codegen.to_sdl(schema) let html = mochi_codegen.graphiql("/graphql") ``` -## Config (`mochi.config.json`) - -```json -{ - "schema": "schema.graphql", - "output": { - "typescript": "src/generated/types.ts", - "sdl": null - } -} -``` - ## License Apache-2.0 --- -Built with the help of [Claude Code](https://claude.ai/code). \ No newline at end of file +Built with the help of [Claude Code](https://claude.ai/code). diff --git a/gleam.toml b/gleam.toml index 13ca3cb..f7aba25 100644 --- a/gleam.toml +++ b/gleam.toml @@ -5,7 +5,7 @@ licences = ["Apache-2.0"] repository = { type = "github", user = "qwexvf", repo = "mochi" } [dependencies] -gleam_stdlib = ">= 1.0.0 and < 2.0.0" +gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" simplifile = ">= 2.0.0 and < 3.0.0" mochi = { git = "https://github.com/qwexvf/mochi", ref = "main" } diff --git a/manifest.toml b/manifest.toml index 30eb956..eb3c88a 100644 --- a/manifest.toml +++ b/manifest.toml @@ -8,12 +8,12 @@ packages = [ { name = "gleeunit", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "254B697FE72EEAD7BF82E941723918E421317813AC49923EE76A18C788C61E72" }, { name = "mochi", version = "0.3.1", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "git", repo = "https://github.com/qwexvf/mochi", commit = "19f87a112fda422b571705b8a71c3d90d285d8e5" }, { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, - { name = "taffy", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "simplifile"], source = "git", repo = "https://github.com/qwexvf/taffy", commit = "b468f9eeb2c73ed2d259eeee96a747d2d73208fe" }, + { name = "taffy", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "simplifile"], source = "git", repo = "https://github.com/qwexvf/taffy", commit = "ce8539513a410002b91e7feca9949c2f7df4cc95" }, ] [requirements] gleam_json = { version = ">= 3.0.0 and < 4.0.0" } -gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } mochi = { git = "https://github.com/qwexvf/mochi", ref = "main" } simplifile = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/src/mochi_codegen/cli.gleam b/src/mochi_codegen/cli.gleam index 7501d4b..097f5c5 100644 --- a/src/mochi_codegen/cli.gleam +++ b/src/mochi_codegen/cli.gleam @@ -4,11 +4,13 @@ import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string +import mochi/parser as mochi_parser import mochi/schema.{type Schema} import mochi/sdl_ast.{type SDLDocument, SDLDocument} import mochi/sdl_parser import mochi_codegen/config import mochi_codegen/gleam as gleam_gen +import mochi_codegen/operation_gen import mochi_codegen/sdl import mochi_codegen/typescript import simplifile @@ -140,6 +142,7 @@ fn run_generate(args: List(String)) -> Result(String, CliError) { gleam_config, conf.gleam.type_suffix, conf.gleam.resolver_suffix, + conf.operations_input, )) case messages { @@ -163,6 +166,7 @@ fn run_direct(args: List(String)) -> Result(String, CliError) { typescript: option.from_result(cli_config.typescript_output), gleam_types: option.from_result(cli_config.gleam_output), resolvers: option.from_result(cli_config.resolvers_output), + operations: None, sdl: option.from_result(cli_config.sdl_output), ) @@ -172,6 +176,7 @@ fn run_direct(args: List(String)) -> Result(String, CliError) { gleam_config, "_types", "_resolvers", + None, )) case messages { @@ -192,6 +197,7 @@ fn generate_from_paths( gleam_config: gleam_gen.GleamGenConfig, type_suffix: String, resolver_suffix: String, + operations_input: option.Option(String), ) -> Result(List(String), CliError) { use merged <- result.try(read_and_merge_schemas(paths)) @@ -286,6 +292,40 @@ fn generate_from_paths( } }) + // Operations — read .gql operation files, generate resolver boilerplate + use messages <- result.try(case operations_input, output.operations { + Some(input_glob), Some(out_path) -> { + use op_paths <- result.try(expand_globs([input_glob])) + list.try_fold(op_paths, messages, fn(msgs, op_path) { + use content <- result.try( + simplifile.read(op_path) + |> result.map_error(fn(_) { + FileReadError(op_path, "File system error") + }), + ) + use ops_doc <- result.try( + mochi_parser.parse(content) + |> result.map_error(fn(e) { ParseError(format_op_parse_error(e)) }), + ) + let generated = operation_gen.generate(ops_doc, merged) + let filename = schema_stem(op_path) <> resolver_suffix <> ".gleam" + let dest = out_path <> filename + use _ <- result.try(ensure_dir(out_path)) + use written <- result.try(write_with_policy( + dest, + generated, + MergeNewFunctions, + )) + let msg = case written { + True -> "Generated operations: " <> dest + False -> "Generated operations (up to date): " <> dest + } + Ok([msg, ..msgs]) + }) + } + _, _ -> Ok(messages) + }) + // SDL — always single file (merging makes sense here) use messages <- result.try(case output.sdl { None -> Ok(messages) @@ -888,6 +928,19 @@ fn format_error(err: CliError) -> String { } } +fn format_op_parse_error(err: mochi_parser.ParseError) -> String { + case err { + mochi_parser.UnexpectedToken(expected, _, pos) -> + "Unexpected token at line " + <> int.to_string(pos.line) + <> ", expected " + <> expected + mochi_parser.UnexpectedEOF(expected) -> + "Unexpected end of file, expected " <> expected + mochi_parser.LexError(_) -> "Lexer error" + } +} + fn format_parse_error(err: sdl_parser.SDLParseError) -> String { case err { sdl_parser.SDLLexError(_) -> "Lexer error" diff --git a/src/mochi_codegen/config.gleam b/src/mochi_codegen/config.gleam index 584adde..f3a002f 100644 --- a/src/mochi_codegen/config.gleam +++ b/src/mochi_codegen/config.gleam @@ -40,6 +40,8 @@ pub type Config { Config( /// Glob pattern(s) or explicit path(s) to GraphQL schema files. schema: List(String), + /// Glob pattern(s) for GraphQL operation files (.gql) to generate resolvers from. + operations_input: Option(String), /// Output configuration output: OutputConfig, /// Gleam-specific codegen options @@ -56,6 +58,8 @@ pub type OutputConfig { gleam_types: Option(String), /// Gleam resolver stubs (file or directory) resolvers: Option(String), + /// Gleam operation-based resolver boilerplate (directory only) + operations: Option(String), /// Normalised SDL output (file only) sdl: Option(String), ) @@ -91,10 +95,12 @@ pub fn is_dir_output(path: String) -> Bool { pub fn default() -> Config { Config( schema: ["schema.graphql"], + operations_input: None, output: OutputConfig( typescript: Some("src/generated/types.ts"), gleam_types: Some("src/generated/"), resolvers: Some("src/generated/"), + operations: None, sdl: None, ), gleam: GleamConfig( @@ -118,11 +124,15 @@ pub fn to_yaml(config: Config) -> String { <> "\n" } + let operations_input_yaml = + opt_yaml_field("operations_input", config.operations_input) + let output_yaml = "output:\n" <> opt_yaml_field(" typescript", config.output.typescript) <> opt_yaml_field(" gleam_types", config.output.gleam_types) <> opt_yaml_field(" resolvers", config.output.resolvers) + <> opt_yaml_field(" operations", config.output.operations) <> opt_yaml_field(" sdl", config.output.sdl) let resolver_imports_yaml = case config.gleam.resolver_imports { @@ -152,7 +162,13 @@ pub fn to_yaml(config: Config) -> String { <> bool_to_yaml(config.gleam.generate_docs) <> "\n" - schema_yaml <> "\n" <> output_yaml <> "\n" <> gleam_yaml + schema_yaml + <> "\n" + <> operations_input_yaml + <> "\n" + <> output_yaml + <> "\n" + <> gleam_yaml } fn opt_yaml_field(key: String, value: Option(String)) -> String { @@ -184,7 +200,12 @@ fn decode_config(doc: taffy.Value) -> Result(Config, String) { use schema <- result.try(decode_schema(doc)) use output <- result.try(decode_output(doc)) use gleam <- result.try(decode_gleam(doc)) - Ok(Config(schema:, output:, gleam:)) + Ok(Config( + schema:, + operations_input: opt_string(doc, "operations_input"), + output:, + gleam:, + )) } fn decode_schema(doc: taffy.Value) -> Result(List(String), String) { @@ -220,6 +241,7 @@ fn decode_output(doc: taffy.Value) -> Result(OutputConfig, String) { typescript: opt_string(output, "typescript"), gleam_types: opt_string(output, "gleam_types"), resolvers: opt_string(output, "resolvers"), + operations: opt_string(output, "operations"), sdl: opt_string(output, "sdl"), )) } diff --git a/src/mochi_codegen/gleam.gleam b/src/mochi_codegen/gleam.gleam index dfdae9a..af96274 100644 --- a/src/mochi_codegen/gleam.gleam +++ b/src/mochi_codegen/gleam.gleam @@ -2,10 +2,10 @@ import gleam/list import gleam/option.{Some} import gleam/string import mochi/sdl_ast.{ - type ArgumentDef, type EnumTypeDef, type EnumValueDef, type FieldDef, - type InputFieldDef, type InputObjectTypeDef, type InterfaceTypeDef, - type ObjectTypeDef, type SDLDocument, type SDLType, type ScalarTypeDef, - type TypeDef, type UnionTypeDef, + type EnumTypeDef, type EnumValueDef, type FieldDef, type InputFieldDef, + type InputObjectTypeDef, type InterfaceTypeDef, type ObjectTypeDef, + type SDLDocument, type SDLType, type ScalarTypeDef, type TypeDef, + type UnionTypeDef, } pub type GleamGenConfig { @@ -152,12 +152,7 @@ fn collect_types_in_def(td: TypeDef) -> List(String) { case td { sdl_ast.ObjectTypeDefinition(obj) -> list.flat_map(obj.fields, fn(f) { - let return_types = collect_named_in_sdl_type(f.field_type) - let arg_types = - list.flat_map(f.arguments, fn(a: ArgumentDef) { - collect_named_in_sdl_type(a.arg_type) - }) - list.append(return_types, arg_types) + collect_named_in_sdl_type(f.field_type) }) sdl_ast.InputObjectTypeDefinition(input) -> list.flat_map(input.fields, fn(f) { @@ -295,19 +290,7 @@ fn generate_resolvers_impl( let imports = option_import <> extra_imports <> type_imports <> "\n" - let resolvers = - doc.definitions - |> list.filter_map(fn(def) { - case def { - sdl_ast.TypeDefinition(sdl_ast.ObjectTypeDefinition(obj)) -> - Ok(generate_object_resolvers(obj, config)) - _ -> Error(Nil) - } - }) - |> list.filter(fn(s) { s != "" }) - |> string.join("\n\n") - - header <> imports <> resolvers + header <> imports } fn resolver_needs_option(doc: SDLDocument) -> Bool { @@ -459,71 +442,6 @@ fn generate_scalar_type(scalar: ScalarTypeDef, config: GleamGenConfig) -> String doc <> "pub type " <> scalar.name <> " =\n String" } -// Resolver generators - -fn generate_object_resolvers( - obj: ObjectTypeDef, - config: GleamGenConfig, -) -> String { - case obj.name { - "Query" | "Mutation" | "Subscription" -> - generate_root_resolvers(obj, config) - _ -> "" - } -} - -fn generate_root_resolvers(obj: ObjectTypeDef, config: GleamGenConfig) -> String { - let header = "// " <> obj.name <> " resolvers\n" - - let resolvers = - obj.fields - |> list.map(fn(field) { generate_field_resolver(obj.name, field, config) }) - |> string.join("\n\n") - - header <> resolvers -} - -fn generate_field_resolver( - _parent_name: String, - field: FieldDef, - config: GleamGenConfig, -) -> String { - let fn_name = "resolve_" <> to_snake_case(field.name) - let return_type = sdl_type_to_gleam(field.field_type) - - let args_params = case field.arguments { - [] -> "" - args -> - ", " - <> { - args - |> list.map(fn(arg) { - to_snake_case(arg.name) <> ": " <> sdl_type_to_gleam(arg.arg_type) - }) - |> string.join(", ") - } - } - - let doc = case config.generate_docs, field.description { - True, Some(desc) -> "/// " <> desc <> "\n" - _, _ -> "" - } - - doc - <> "pub fn " - <> fn_name - <> "(ctx: ExecutionContext" - <> args_params - <> ") -> Result(" - <> return_type - <> ", String) {\n" - <> " // TODO: Implement resolver\n" - <> " Error(\"Not implemented: " - <> fn_name - <> "\")\n" - <> "}" -} - // Type conversion helpers /// Convert an SDL type to Gleam. Top-level nullable types are wrapped in Option. diff --git a/src/mochi_codegen/operation_gen.gleam b/src/mochi_codegen/operation_gen.gleam new file mode 100644 index 0000000..02fb252 --- /dev/null +++ b/src/mochi_codegen/operation_gen.gleam @@ -0,0 +1,818 @@ +import gleam/list +import gleam/option.{None, Some} +import gleam/result +import gleam/string +import mochi/ast +import mochi/sdl_ast.{type SDLDocument} + +pub fn generate(ops_doc: ast.Document, schema_doc: SDLDocument) -> String { + let operations = + list.filter_map(ops_doc.definitions, fn(def) { + case def { + ast.OperationDefinition(op) -> Ok(op) + _ -> Error(Nil) + } + }) + + case operations { + [] -> "" + ops -> { + let needs_dict = + list.any(ops, fn(op) { + has_input_arg(op, schema_doc) || count_vars(op) >= 2 + }) + let needs_list = list.any(ops, has_list_return(_, schema_doc)) + let needs_result = list.any(ops, fn(op) { count_vars(op) >= 2 }) + let encoder_types = collect_encoder_types(ops, schema_doc) + let needs_dynamic = needs_dict || encoder_types != [] + let needs_none = list.any(ops, has_nullable_input_field(_, schema_doc)) + + let imports = + [ + case needs_dict { + True -> Some("import gleam/dict") + False -> None + }, + case needs_dynamic { + True -> Some("import gleam/dynamic.{type Dynamic}") + False -> None + }, + case needs_dict { + True -> Some("import gleam/dynamic/decode") + False -> None + }, + case needs_list { + True -> Some("import gleam/list") + False -> None + }, + case needs_none { + True -> Some("import gleam/option.{None}") + False -> None + }, + case needs_result { + True -> Some("import gleam/result") + False -> None + }, + Some("import mochi/query"), + Some("import mochi/schema"), + Some("import mochi/types"), + ] + |> list.filter_map(fn(x) { option.to_result(x, Nil) }) + |> string.join("\n") + + let #(queries, mutations, subscriptions) = partition_ops(ops) + + let query_section = case queries { + [] -> "" + qs -> + "// ── Queries " + <> string.repeat("─", 65) + <> "\n\n" + <> string.join( + list.map(qs, fn(op) { generate_op(op, schema_doc) }), + "\n\n", + ) + <> "\n" + } + + let mutation_section = case mutations { + [] -> "" + ms -> + "\n// ── Mutations " + <> string.repeat("─", 63) + <> "\n\n" + <> string.join( + list.map(ms, fn(op) { generate_op(op, schema_doc) }), + "\n\n", + ) + <> "\n" + } + + let sub_section = case subscriptions { + [] -> "" + ss -> + "\n// ── Subscriptions " + <> string.repeat("─", 59) + <> "\n\n" + <> string.join( + list.map(ss, fn(op) { generate_op(op, schema_doc) }), + "\n\n", + ) + <> "\n" + } + + let register = generate_register(ops) + let encoder_section = generate_encoders(encoder_types) + + "// Generated by mochi_codegen — edit resolve: bodies only\n\n" + <> imports + <> "\n\n" + <> query_section + <> mutation_section + <> sub_section + <> encoder_section + <> "\n// ── Register " + <> string.repeat("─", 64) + <> "\n\n" + <> register + <> "\n" + } + } +} + +fn generate_op(op: ast.Operation, schema_doc: SDLDocument) -> String { + let #(op_type, vars, sel) = case op { + ast.Operation( + operation_type: t, + variable_definitions: v, + selection_set: s, + .., + ) -> #(t, v, s) + ast.ShorthandQuery(selection_set: s) -> #(ast.Query, [], s) + } + + let root_field = get_root_field(sel) + let suffix = op_type_suffix(op_type) + let fn_nm = to_snake_case(root_field) <> "_" <> suffix + + let return_type_str = lookup_return_type(root_field, op_type, schema_doc) + let return_sdl_type = lookup_return_sdl_type(root_field, op_type, schema_doc) + let is_list = case return_sdl_type { + Some(t) -> sdl_is_list(t) + None -> False + } + let inner_name = case return_sdl_type { + Some(t) -> sdl_inner_type_name(t) + None -> "Unknown" + } + let is_scalar_return = is_scalar(inner_name) + + let args_str = generate_args(vars) + let decode_str = generate_decode(vars, schema_doc) + let resolve_str = generate_resolve(vars, root_field, schema_doc) + let encode_str = case is_scalar_return { + True -> "fn(v) { types.to_dynamic(v) }" + False -> + case is_list { + True -> + "fn(items) { types.to_dynamic(list.map(items, " + <> to_snake_case(inner_name) + <> "_to_dynamic)) }" + False -> to_snake_case(inner_name) <> "_to_dynamic" + } + } + + let add_fn = case op_type { + ast.Query -> "query.query_with_args" + ast.Mutation -> "query.mutation" + ast.Subscription -> "query.subscription_with_args" + } + + case op_type { + ast.Subscription -> + "fn " + <> fn_nm + <> "(_db: a) {\n" + <> " " + <> add_fn + <> "(\n" + <> " name: \"" + <> root_field + <> "\",\n" + <> " args: [" + <> args_str + <> "],\n" + <> " returns: " + <> return_type_str + <> ",\n" + <> " decode: " + <> decode_str + <> ",\n" + <> " topic: fn(_, _ctx) {\n" + <> " // TODO: return subscription topic string\n" + <> " Error(\"Not implemented: " + <> root_field + <> " topic\")\n" + <> " },\n" + <> " encode: " + <> encode_str + <> ",\n" + <> " )\n" + <> "}" + _ -> + "fn " + <> fn_nm + <> "(_db: a) {\n" + <> " " + <> add_fn + <> "(\n" + <> " name: \"" + <> root_field + <> "\",\n" + <> " args: [" + <> args_str + <> "],\n" + <> " returns: " + <> return_type_str + <> ",\n" + <> " decode: " + <> decode_str + <> ",\n" + <> " resolve: " + <> resolve_str + <> ",\n" + <> " encode: " + <> encode_str + <> ",\n" + <> " )\n" + <> "}" + } +} + +// ── Args list ───────────────────────────────────────────────────────────────── + +fn generate_args(vars: List(ast.VariableDefinition)) -> String { + vars + |> list.map(fn(v) { + "query.arg(\"" + <> v.variable + <> "\", " + <> ast_type_to_schema_type(v.type_) + <> ")" + }) + |> string.join(", ") +} + +// ── Decode block ────────────────────────────────────────────────────────────── + +fn generate_decode( + vars: List(ast.VariableDefinition), + schema_doc: SDLDocument, +) -> String { + case vars { + [] -> "fn(_args) { Ok(Nil) }" + [v] -> { + let type_name = ast_inner_type_name(v.type_) + case is_input_type(type_name, schema_doc) { + True -> generate_input_decode_block(v.variable, type_name, schema_doc) + False -> { + let helper = scalar_to_helper(type_name) + "fn(args) { query." <> helper <> "(args, \"" <> v.variable <> "\") }" + } + } + } + vs -> { + let lines = + list.map(vs, fn(v) { + let type_name = ast_inner_type_name(v.type_) + case is_input_type(type_name, schema_doc) { + True -> + generate_multi_input_decode_line( + v.variable, + type_name, + schema_doc, + ) + False -> { + let helper = scalar_to_helper(type_name) + " use " + <> to_snake_case(v.variable) + <> " <- result.try(query." + <> helper + <> "(args, \"" + <> v.variable + <> "\"))" + } + } + }) + let names = + list.map(vs, fn(v) { to_snake_case(v.variable) }) |> string.join(", ") + let tuple = case vs { + [_] -> names + _ -> "#(" <> names <> ")" + } + "fn(args: dict.Dict(String, Dynamic)) {\n" + <> string.join(lines, "\n") + <> "\n Ok(" + <> tuple + <> ")\n }" + } + } +} + +fn generate_input_decode_block( + arg_name: String, + type_name: String, + schema_doc: SDLDocument, +) -> String { + let fields = find_input_fields(type_name, schema_doc) + let field_lines = + list.map(fields, fn(f) { + let is_nonnull = sdl_is_nonnull(f.field_type) + let inner = sdl_inner_type_name(f.field_type) + let dec = scalar_to_decode_fn(inner) + case is_nonnull { + True -> + " use " + <> to_snake_case(f.name) + <> " <- decode.field(\"" + <> f.name + <> "\", " + <> dec + <> ")" + False -> + " use " + <> to_snake_case(f.name) + <> " <- decode.optional_field(\"" + <> f.name + <> "\", None, types.nullable(" + <> dec + <> "))" + } + }) + |> string.join("\n") + + let names = + list.map(fields, fn(f) { to_snake_case(f.name) }) |> string.join(", ") + let tuple = case fields { + [_] -> names + _ -> "#(" <> names <> ")" + } + + "fn(args: dict.Dict(String, Dynamic)) {\n" + <> " case dict.get(args, \"" + <> arg_name + <> "\") {\n" + <> " Ok(input_dyn) -> {\n" + <> " let decoder = {\n" + <> field_lines + <> "\n" + <> " decode.success(" + <> tuple + <> ")\n" + <> " }\n" + <> " case decode.run(input_dyn, decoder) {\n" + <> " Ok(input) -> Ok(input)\n" + <> " Error(_) -> Error(\"Invalid " + <> type_name + <> "\")\n" + <> " }\n" + <> " }\n" + <> " Error(_) -> Error(\"Missing input argument\")\n" + <> " }\n" + <> " }" +} + +fn generate_multi_input_decode_line( + arg_name: String, + type_name: String, + schema_doc: SDLDocument, +) -> String { + let fields = find_input_fields(type_name, schema_doc) + let field_lines = + list.map(fields, fn(f) { + let is_nonnull = sdl_is_nonnull(f.field_type) + let inner = sdl_inner_type_name(f.field_type) + let dec = scalar_to_decode_fn(inner) + case is_nonnull { + True -> + " use " + <> to_snake_case(f.name) + <> " <- decode.field(\"" + <> f.name + <> "\", " + <> dec + <> ")" + False -> + " use " + <> to_snake_case(f.name) + <> " <- decode.optional_field(\"" + <> f.name + <> "\", None, types.nullable(" + <> dec + <> "))" + } + }) + |> string.join("\n") + let names = + list.map(fields, fn(f) { to_snake_case(f.name) }) |> string.join(", ") + let tuple = case fields { + [_] -> names + _ -> "#(" <> names <> ")" + } + " use " + <> to_snake_case(arg_name) + <> " <- result.try(case dict.get(args, \"" + <> arg_name + <> "\") {\n" + <> " Ok(dyn_) -> {\n" + <> " let decoder = {\n" + <> field_lines + <> "\n" + <> " decode.success(" + <> tuple + <> ")\n" + <> " }\n" + <> " case decode.run(dyn_, decoder) {\n" + <> " Ok(val) -> Ok(val)\n" + <> " Error(_) -> Error(\"Invalid " + <> type_name + <> "\")\n" + <> " }\n" + <> " }\n" + <> " Error(_) -> Error(\"Missing input argument\")\n" + <> " })" +} + +// ── Resolve lambda ──────────────────────────────────────────────────────────── + +fn generate_resolve( + vars: List(ast.VariableDefinition), + field_name: String, + schema_doc: SDLDocument, +) -> String { + let todo_body = + " // TODO: implement " + <> field_name + <> " resolver\n" + <> " Error(\"Not implemented: " + <> field_name + <> "\")" + + case vars { + [] -> "fn(_args, _ctx) {\n" <> todo_body <> "\n }" + [v] -> { + let type_name = ast_inner_type_name(v.type_) + case is_input_type(type_name, schema_doc) { + True -> { + let fields = find_input_fields(type_name, schema_doc) + let names = + list.map(fields, fn(f) { "_" <> to_snake_case(f.name) }) + |> string.join(", ") + let pat = case fields { + [_] -> names + _ -> "#(" <> names <> ")" + } + "fn(input, _ctx) {\n" + <> " let " + <> pat + <> " = input\n" + <> todo_body + <> "\n }" + } + False -> + "fn(_" + <> to_snake_case(v.variable) + <> ", _ctx) {\n" + <> todo_body + <> "\n }" + } + } + vs -> { + let names = + list.map(vs, fn(v) { "_" <> to_snake_case(v.variable) }) + |> string.join(", ") + let pat = "#(" <> names <> ")" + "fn(input, _ctx) {\n" + <> " let " + <> pat + <> " = input\n" + <> todo_body + <> "\n }" + } + } +} + +// ── Encoders ────────────────────────────────────────────────────────────────── + +fn collect_encoder_types( + ops: List(ast.Operation), + schema_doc: SDLDocument, +) -> List(String) { + list.filter_map(ops, fn(op) { + let #(op_type, sel) = case op { + ast.Operation(operation_type: t, selection_set: s, ..) -> #(t, s) + ast.ShorthandQuery(s) -> #(ast.Query, s) + } + let field = get_root_field(sel) + case lookup_return_sdl_type(field, op_type, schema_doc) { + Some(t) -> { + let name = sdl_inner_type_name(t) + case is_scalar(name) { + True -> Error(Nil) + False -> Ok(name) + } + } + None -> Error(Nil) + } + }) + |> list.unique +} + +fn generate_encoders(type_names: List(String)) -> String { + case type_names { + [] -> "" + names -> + "\n// ── Encoders " + <> string.repeat("─", 64) + <> "\n\n" + <> string.join( + list.map(names, fn(name) { + "fn " + <> to_snake_case(name) + <> "_to_dynamic(item: a) -> dynamic.Dynamic {\n" + <> " types.to_dynamic(item)\n" + <> "}" + }), + "\n\n", + ) + <> "\n" + } +} + +// ── Register ────────────────────────────────────────────────────────────────── + +fn generate_register(ops: List(ast.Operation)) -> String { + let adds = + list.filter_map(ops, fn(op) { + case op { + ast.Operation(operation_type: t, selection_set: sel, ..) -> { + let field = get_root_field(sel) + let fn_nm = to_snake_case(field) <> "_" <> op_type_suffix(t) + let add = case t { + ast.Query -> "add_query" + ast.Mutation -> "add_mutation" + ast.Subscription -> "add_subscription" + } + Ok(" |> query." <> add <> "(" <> fn_nm <> "(db))") + } + _ -> Error(Nil) + } + }) + |> string.join("\n") + + "pub fn register(\n" + <> " builder: query.SchemaBuilder,\n" + <> " db: a,\n" + <> ") -> query.SchemaBuilder {\n" + <> " builder\n" + <> adds + <> "\n}" +} + +// ── Schema lookups ──────────────────────────────────────────────────────────── + +fn lookup_return_type( + field_name: String, + op_type: ast.OperationType, + schema_doc: SDLDocument, +) -> String { + case lookup_return_sdl_type(field_name, op_type, schema_doc) { + Some(t) -> sdl_type_to_schema_type(t) + None -> "schema.Named(\"TODO\")" + } +} + +fn lookup_return_sdl_type( + field_name: String, + op_type: ast.OperationType, + schema_doc: SDLDocument, +) -> option.Option(sdl_ast.SDLType) { + let root_name = case op_type { + ast.Query -> "Query" + ast.Mutation -> "Mutation" + ast.Subscription -> "Subscription" + } + + schema_doc.definitions + |> list.find_map(fn(def) { + case def { + sdl_ast.TypeDefinition(sdl_ast.ObjectTypeDefinition(obj)) + if obj.name == root_name + -> + obj.fields + |> list.find_map(fn(f) { + case f.name == field_name { + True -> Ok(f.field_type) + False -> Error(Nil) + } + }) + _ -> Error(Nil) + } + }) + |> option.from_result +} + +fn find_input_fields( + type_name: String, + schema_doc: SDLDocument, +) -> List(sdl_ast.InputFieldDef) { + schema_doc.definitions + |> list.find_map(fn(def) { + case def { + sdl_ast.TypeDefinition(sdl_ast.InputObjectTypeDefinition(i)) + if i.name == type_name + -> Ok(i.fields) + _ -> Error(Nil) + } + }) + |> result.unwrap([]) +} + +fn is_input_type(name: String, schema_doc: SDLDocument) -> Bool { + list.any(schema_doc.definitions, fn(def) { + case def { + sdl_ast.TypeDefinition(sdl_ast.InputObjectTypeDefinition(i)) -> + i.name == name + _ -> False + } + }) +} + +// ── Type conversions ────────────────────────────────────────────────────────── + +fn ast_type_to_schema_type(t: ast.Type) -> String { + case t { + ast.NamedType(name) -> "schema.Named(\"" <> name <> "\")" + ast.NonNullType(inner) -> + "schema.NonNull(" <> ast_type_to_schema_type(inner) <> ")" + ast.ListType(inner) -> + "schema.List(" <> ast_type_to_schema_type(inner) <> ")" + } +} + +fn sdl_type_to_schema_type(t: sdl_ast.SDLType) -> String { + case t { + sdl_ast.NamedType(name) -> "schema.Named(\"" <> name <> "\")" + sdl_ast.NonNullType(inner) -> + "schema.NonNull(" <> sdl_type_to_schema_type(inner) <> ")" + sdl_ast.ListType(inner) -> + "schema.List(" <> sdl_type_to_schema_type(inner) <> ")" + } +} + +fn ast_inner_type_name(t: ast.Type) -> String { + case t { + ast.NamedType(name) -> name + ast.NonNullType(inner) -> ast_inner_type_name(inner) + ast.ListType(inner) -> ast_inner_type_name(inner) + } +} + +fn sdl_inner_type_name(t: sdl_ast.SDLType) -> String { + case t { + sdl_ast.NamedType(name) -> name + sdl_ast.NonNullType(inner) -> sdl_inner_type_name(inner) + sdl_ast.ListType(inner) -> sdl_inner_type_name(inner) + } +} + +fn sdl_is_list(t: sdl_ast.SDLType) -> Bool { + case t { + sdl_ast.ListType(_) -> True + sdl_ast.NonNullType(inner) -> sdl_is_list(inner) + sdl_ast.NamedType(_) -> False + } +} + +fn sdl_is_nonnull(t: sdl_ast.SDLType) -> Bool { + case t { + sdl_ast.NonNullType(_) -> True + _ -> False + } +} + +fn scalar_to_helper(type_name: String) -> String { + case type_name { + "ID" -> "get_id" + "String" -> "get_string" + "Int" -> "get_int" + "Float" -> "get_float" + "Boolean" -> "get_bool" + _ -> "get_string" + } +} + +fn scalar_to_decode_fn(type_name: String) -> String { + case type_name { + "ID" | "String" -> "decode.string" + "Int" -> "decode.int" + "Float" -> "decode.float" + "Boolean" -> "decode.bool" + _ -> "decode.string" + } +} + +fn is_scalar(name: String) -> Bool { + case name { + "String" | "Int" | "Float" | "Boolean" | "ID" -> True + _ -> False + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn get_root_field(sel: ast.SelectionSet) -> String { + case sel.selections { + [ast.FieldSelection(field), ..] -> field.name + _ -> "unknown" + } +} + +fn op_type_suffix(t: ast.OperationType) -> String { + case t { + ast.Query -> "query" + ast.Mutation -> "mutation" + ast.Subscription -> "subscription" + } +} + +fn partition_ops( + ops: List(ast.Operation), +) -> #(List(ast.Operation), List(ast.Operation), List(ast.Operation)) { + list.fold(ops, #([], [], []), fn(acc, op) { + let t = case op { + ast.Operation(operation_type: t, ..) -> t + ast.ShorthandQuery(_) -> ast.Query + } + let #(qs, ms, ss) = acc + case t { + ast.Query -> #(list.append(qs, [op]), ms, ss) + ast.Mutation -> #(qs, list.append(ms, [op]), ss) + ast.Subscription -> #(qs, ms, list.append(ss, [op])) + } + }) +} + +fn has_input_arg(op: ast.Operation, schema_doc: SDLDocument) -> Bool { + let vars = case op { + ast.Operation(variable_definitions: v, ..) -> v + ast.ShorthandQuery(_) -> [] + } + list.any(vars, fn(v) { + is_input_type(ast_inner_type_name(v.type_), schema_doc) + }) +} + +fn has_list_return(op: ast.Operation, schema_doc: SDLDocument) -> Bool { + let #(op_type, sel) = case op { + ast.Operation(operation_type: t, selection_set: s, ..) -> #(t, s) + ast.ShorthandQuery(s) -> #(ast.Query, s) + } + let field = get_root_field(sel) + case lookup_return_sdl_type(field, op_type, schema_doc) { + Some(t) -> sdl_is_list(t) + None -> False + } +} + +fn count_vars(op: ast.Operation) -> Int { + case op { + ast.Operation(variable_definitions: v, ..) -> list.length(v) + ast.ShorthandQuery(_) -> 0 + } +} + +fn has_nullable_input_field(op: ast.Operation, schema_doc: SDLDocument) -> Bool { + let vars = case op { + ast.Operation(variable_definitions: v, ..) -> v + ast.ShorthandQuery(_) -> [] + } + list.any(vars, fn(v) { + let type_name = ast_inner_type_name(v.type_) + case is_input_type(type_name, schema_doc) { + False -> False + True -> + list.any(find_input_fields(type_name, schema_doc), fn(f) { + !sdl_is_nonnull(f.field_type) + }) + } + }) +} + +const gleam_keywords = [ + "as", "assert", "auto", "case", "const", "delegate", "derive", "echo", "else", + "fn", "if", "implement", "import", "in", "let", "macro", "opaque", "panic", + "pub", "test", "todo", "type", "use", +] + +fn to_snake_case(input: String) -> String { + let snake = + input + |> string.to_graphemes + |> list.index_map(fn(char, idx) { + case is_uppercase(char), idx { + True, 0 -> string.lowercase(char) + True, _ -> "_" <> string.lowercase(char) + False, _ -> char + } + }) + |> string.join("") + case list.contains(gleam_keywords, snake) { + True -> snake <> "_" + False -> snake + } +} + +fn is_uppercase(char: String) -> Bool { + char == string.uppercase(char) && char != string.lowercase(char) +} diff --git a/test/cli_subcommands_test.gleam b/test/cli_subcommands_test.gleam index a1cb4f7..70c992d 100644 --- a/test/cli_subcommands_test.gleam +++ b/test/cli_subcommands_test.gleam @@ -92,10 +92,12 @@ pub fn generate_with_valid_schema_succeeds_test() { let conf = config.Config( schema: [schema_path], + operations_input: option.None, output: config.OutputConfig( typescript: option.Some(ts_path), gleam_types: option.None, resolvers: option.None, + operations: option.None, sdl: option.None, ), gleam: config.GleamConfig( diff --git a/test/codegen_gleam_test.gleam b/test/codegen_gleam_test.gleam index cd9bffa..675e37f 100644 --- a/test/codegen_gleam_test.gleam +++ b/test/codegen_gleam_test.gleam @@ -339,10 +339,9 @@ pub fn generate_query_resolvers_test() { let config = codegen.default_config() let output = codegen.generate_resolvers(doc, config) - should.be_true(contains(output, "// Query resolvers")) - should.be_true(contains(output, "pub fn resolve_users")) - should.be_true(contains(output, "pub fn resolve_user")) - should.be_true(contains(output, "ctx: ExecutionContext")) + should.be_false(contains(output, "pub fn resolve_users")) + should.be_false(contains(output, "pub fn resolve_user")) + should.be_false(contains(output, "ctx: ExecutionContext")) } pub fn generate_mutation_resolvers_test() { @@ -379,10 +378,7 @@ pub fn generate_mutation_resolvers_test() { let config = codegen.default_config() let output = codegen.generate_resolvers(doc, config) - should.be_true(contains(output, "// Mutation resolvers")) - should.be_true(contains(output, "pub fn resolve_create_user")) - should.be_true(contains(output, "name: String")) - should.be_true(contains(output, "/// Create a new user")) + should.be_false(contains(output, "pub fn resolve_create_user")) } pub fn generate_resolvers_with_todo_test() { @@ -399,8 +395,7 @@ pub fn generate_resolvers_with_todo_test() { let config = codegen.default_config() let output = codegen.generate_resolvers(doc, config) - should.be_true(contains(output, "// TODO: Implement resolver")) - should.be_true(contains(output, "Error(\"Not implemented")) + should.be_false(contains(output, "ctx: ExecutionContext")) } pub fn generate_resolvers_imports_types_module_test() { diff --git a/test/config_test.gleam b/test/config_test.gleam index 130578f..07bfcf7 100644 --- a/test/config_test.gleam +++ b/test/config_test.gleam @@ -90,10 +90,12 @@ pub fn roundtrip_custom_config_test() { let conf = config.Config( schema: ["src/api.graphql"], + operations_input: None, output: config.OutputConfig( typescript: Some("out/types.ts"), gleam_types: None, resolvers: Some("out/resolvers.gleam"), + operations: None, sdl: Some("out/schema.graphql"), ), gleam: config.GleamConfig( @@ -182,6 +184,7 @@ pub fn to_yaml_omits_null_outputs_test() { typescript: None, gleam_types: None, resolvers: None, + operations: None, sdl: None, ), ) diff --git a/test/operation_gen_test.gleam b/test/operation_gen_test.gleam new file mode 100644 index 0000000..21dce63 --- /dev/null +++ b/test/operation_gen_test.gleam @@ -0,0 +1,89 @@ +import gleam/string +import gleeunit/should +import mochi/parser +import mochi/sdl_ast +import mochi/sdl_parser +import mochi_codegen/operation_gen + +fn parse_ops(src: String) { + let assert Ok(doc) = parser.parse(src) + doc +} + +fn parse_schema(src: String) -> sdl_ast.SDLDocument { + let assert Ok(doc) = sdl_parser.parse_sdl(src) + doc +} + +fn contains(haystack: String, needle: String) -> Bool { + string.contains(haystack, needle) +} + +pub fn single_scalar_query_generates_query_with_args_test() { + let ops = parse_ops("query GetUser($id: ID!) { user(id: $id) { id } }") + let schema = + parse_schema("type Query { user(id: ID!): User } type User { id: ID! }") + let out = operation_gen.generate(ops, schema) + out |> contains("query.query_with_args") |> should.be_true + out |> contains("name: \"user\"") |> should.be_true + out |> contains("query.get_id(args, \"id\")") |> should.be_true +} + +pub fn single_input_type_mutation_generates_input_decode_test() { + let ops = + parse_ops( + "mutation CreatePost($input: PostInput!) { createPost(input: $input) { id } }", + ) + let schema = + parse_schema( + "type Mutation { createPost(input: PostInput!): Post } +type Post { id: ID! } +input PostInput { title: String! body: String! }", + ) + let out = operation_gen.generate(ops, schema) + out |> contains("dict.get(args, \"input\")") |> should.be_true + out |> contains("decode.run") |> should.be_true + out |> contains("decode.field(\"title\"") |> should.be_true +} + +pub fn multi_var_scalar_mutation_generates_result_try_test() { + let ops = + parse_ops( + "mutation Follow($userId: ID!, $targetId: ID!) { follow(userId: $userId, targetId: $targetId) { id } }", + ) + let schema = + parse_schema( + "type Mutation { follow(userId: ID!, targetId: ID!): User } +type User { id: ID! }", + ) + let out = operation_gen.generate(ops, schema) + out |> contains("import gleam/result") |> should.be_true + out + |> contains("result.try(query.get_id(args, \"userId\"))") + |> should.be_true + out + |> contains("result.try(query.get_id(args, \"targetId\"))") + |> should.be_true + out |> contains("Ok(#(user_id, target_id))") |> should.be_true +} + +pub fn multi_var_with_input_type_uses_decode_not_get_string_test() { + let ops = + parse_ops( + "mutation CreatePost($userId: ID!, $input: PostInput!) { createPost(userId: $userId, input: $input) { id } }", + ) + let schema = + parse_schema( + "type Mutation { createPost(userId: ID!, input: PostInput!): Post } +type Post { id: ID! } +input PostInput { title: String! body: String! }", + ) + let out = operation_gen.generate(ops, schema) + out |> contains("import gleam/result") |> should.be_true + out + |> contains("result.try(query.get_id(args, \"userId\"))") + |> should.be_true + out |> contains("dict.get(args, \"input\")") |> should.be_true + out |> contains("decode.run") |> should.be_true + out |> contains("get_string(args, \"input\")") |> should.be_false +}