Skip to content

feat: generate mochi resolver boilerplate from .gql operation files#1

Merged
qwexvf merged 5 commits intomainfrom
feat/operation-resolver-gen
Apr 19, 2026
Merged

feat: generate mochi resolver boilerplate from .gql operation files#1
qwexvf merged 5 commits intomainfrom
feat/operation-resolver-gen

Conversation

@qwexvf
Copy link
Copy Markdown
Owner

@qwexvf qwexvf commented Apr 19, 2026

Summary

  • Adds operation_gen.gleam — reads .gql client operation files, cross-references the SDL schema, and emits complete mochi field-builder boilerplate
  • Adds operations_input and output.operations config fields to wire up the new generator
  • Removes the old SDL-based resolve_* stub generation (generate_object_resolvers) which emitted non-compiling stubs using undefined ExecutionContext types
  • Fixes collect_types_in_def to not include mutation argument types in cross-file imports (was causing unused-import errors in schema_types.gleam)

Generated output

Given a .gql file with queries/mutations/subscriptions, codegen produces per .gql file:

// Generated by mochi_codegen — edit resolve: bodies only

import gleam/dict
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import mochi/query
import mochi/schema
import mochi/types
import pog

fn coupons_query(_db: pog.Connection) {
  query.query_with_args(
    name: "coupons",
    args: [query.arg("storeId", schema.NonNull(schema.Named("ID")))],
    returns: schema.NonNull(schema.List(schema.NonNull(schema.Named("Coupon")))),
    decode: fn(args) { query.get_id(args, "storeId") },
    resolve: fn(_store_id, _ctx) {
      // TODO: implement coupons resolver
      Error("Not implemented: coupons")
    },
    encode: fn(items) { types.to_dynamic(list.map(items, coupon_to_dynamic)) },
  )
}

// ...

pub fn register(builder: query.SchemaBuilder, db: pog.Connection) -> query.SchemaBuilder {
  builder
  |> query.add_query(coupons_query(db))
  |> query.add_mutation(create_coupon_mutation(db))
}

Config

operations_input: "../../apps/web/src/lib/graphql/gql/*.gql"
output:
  operations: "src/api/schema/"

Write policy

Uses MergeNewFunctions — re-running codegen only appends resolver functions not already present in the file. Existing resolve implementations are never overwritten.

Test plan

  • gleam test — 45 tests pass
  • Run codegen against a project with .gql files and confirm generated files compile

🤖 Generated with Claude Code

Reads GraphQL client operation files (.gql), cross-references the SDL
schema, and emits complete mochi field-builder boilerplate — decode
blocks, resolve stubs, encoder stubs, and a register() function.
Developer only fills in the resolve: lambda body.

New config keys:
  operations_input: "src/graphql/**/*.gql"
  output:
    operations: "src/api/schema/"

Also removes the old SDL-based resolve_* stub generation which emitted
non-compiling stubs using undefined ExecutionContext types. Fixes
collect_types_in_def to not include mutation argument types in
cross-file imports (was causing unused-import errors in schema_types.gleam).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@qwexvf
Copy link
Copy Markdown
Owner Author

qwexvf commented Apr 19, 2026

Code review

Found 3 issues:

  1. import pog is emitted unconditionally into every generated file, but pog is not a dependency of mochi_codegen and is not declared in gleam.toml. Any user not using pog will get uncompilable generated output.

Some("import mochi/schema"),
Some("import mochi/types"),
Some("import pog"),
]
|> list.filter_map(fn(x) { option.to_result(x, Nil) })
|> string.join("\n")

  1. The multi-var decode branch (vs -> in generate_decode) unconditionally calls scalar_to_helper for every variable without checking is_input_type. For a mutation like mutation($id: ID!, $input: CreateUserInput!), the input type variable gets query.get_string(args, "input") instead of proper object decoding, producing uncompilable generated code. The single-var branch handles this correctly.

}
}
}
vs -> {
let lines =
list.map(vs, fn(v) {
let type_name = ast_inner_type_name(v.type_)
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 }"
}
}
}

  1. needs_result is computed as count_vars >= 2 && !has_input_arg, but the multi-var decode block always emits result.try calls regardless. An operation with 2+ variables where at least one is an input type generates code that uses result.try without import gleam/result, causing a compile error.

})
let needs_list = list.any(ops, has_list_return(_, schema_doc))
let needs_result =
list.any(ops, fn(op) {
count_vars(op) >= 2 && !has_input_arg(op, schema_doc)
})
let encoder_types = collect_encoder_types(ops, schema_doc)
let needs_dynamic = needs_dict || encoder_types != []

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

qwexvf added 4 commits April 19, 2026 14:34
In generate_decode, the multi-variable branch was calling scalar_to_helper
for all variables, causing input object types to be decoded via get_string
instead of dict.get + decode.run. Added generate_multi_input_decode_line
to handle input types correctly in the multi-var case.

Also removes the unused ArgumentDef import from gleam.gleam (was left
behind when collect_types_in_def stopped using argument types).

Adds operation_gen_test.gleam covering scalar queries, single-input
mutations, multi-scalar mutations, and the multi-var+input regression.
@qwexvf qwexvf merged commit 3e01df7 into main Apr 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant