⚠️ Experimental: This project is experimental and under active development. APIs may change without notice.
sqlc plugin for generating type-safe MoonBit code from SQL.
| Backend | Target | Runtime | Dependencies |
|---|---|---|---|
sqlite |
native |
Native binary | mizchi/sqlite |
sqlite_js |
js |
Node.js / Browser | mizchi/sqlite, mizchi/js |
d1 |
js |
Cloudflare Workers | mizchi/cloudflare, mizchi/js |
postgres |
native |
Native binary | mattn/postgres |
postgres_js |
js |
Node.js | mizchi/npm_typed/pg, mizchi/js |
mysql_js |
js |
Node.js | mizchi/js (mysql2 npm package) |
- Generates type-safe MoonBit structs from SQL schemas
- Generates query functions with proper parameter binding
- Custom type overrides via
overridesoption - Supports
:one,:many,:execquery types - Optional validators and JSON Schema generation
Add to your sqlc.yaml:
version: "2"
plugins:
- name: moonbit
wasm:
url: "https://github.com/mizchi/sqlc_gen_moonbit/releases/download/v0.3.2/sqlc-gen-moonbit.wasm"
sha256: "8639024dc5ee7271e1b4a8dfec853e85a72814577d66f30810b64270f7fead05"
sql:
- engine: sqlite
schema: "schema.sql"
queries: "query.sql"
codegen:
- plugin: moonbit
out: "gen"
options:
backend: "sqlite" # or "d1" for Cloudflare D1Check the releases page for the latest version and sha256.
moon update
moon build --target wasm ./cmd/wasm
# Output: target/wasm/release/build/cmd/wasm/wasm.wasmUse local file in sqlc.yaml:
plugins:
- name: moonbit
wasm:
url: "file://./path/to/wasm.wasm"
sha256: "" # Optional for local filesdb/schema.sql:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);db/query.sql:
-- name: GetUser :one
SELECT * FROM users WHERE id = ?;
-- name: ListUsers :many
SELECT * FROM users ORDER BY name;
-- name: CreateUser :exec
INSERT INTO users (name, email) VALUES (?, ?);sqlc generateFor SQLite (native):
Add dependencies to moon.mod.json:
{
"deps": {
"mizchi/sqlite": "0.1.3",
"moonbitlang/x": "0.4.38"
}
}fn main {
let db = @sqlite.sqlite_open_v2(...)
// Create user
@gen.create_user(db, @gen.CreateUserParams::new("Alice", "alice@example.com"))
// List users
let users = @gen.list_users(db)
// Get user by ID
match @gen.get_user(db, @gen.GetUserParams::new(1L)) {
Some(user) => println("Found: " + user.name)
None => println("Not found")
}
}For Cloudflare D1:
Add dependencies to moon.mod.json:
{
"deps": {
"mizchi/cloudflare": "0.1.6",
"mizchi/js": "0.10.10",
"moonbitlang/async": "0.16.0"
}
}///|
pub async fn handler(
db : @cloudflare.D1Database,
) -> Unit raise @cloudflare.D1Error {
// Create user
@gen.create_user(db, @gen.CreateUserParams::new("Alice", "alice@example.com"))
// List users (async)
let users = @gen.list_users(db)
// Get user by ID (async)
match @gen.get_user(db, @gen.GetUserParams::new(1L)) {
Some(user) => println("Found: " + user.name)
None => println("Not found")
}
}For PostgreSQL (JS target with Node.js):
Add dependencies to moon.mod.json:
{
"deps": {
"mizchi/npm_typed": "0.1.2",
"mizchi/js": "0.10.10",
"moonbitlang/x": "0.4.38"
}
}Install npm dependencies:
npm install pg///|
async fn run_tests_inner() -> Unit {
let pool = @pg.Pool::new(
host="localhost",
port=5432,
user="postgres",
password="postgres",
database="mydb",
)
// Create user (async, returns inserted id)
let user_id = @gen.create_user(
pool,
@gen.CreateUserParams::new("Alice", "alice@example.com"),
)
// List users (async)
let users = @gen.list_users(pool)
// Get user by ID (async)
match @gen.get_user(pool, @gen.GetUserParams::new(user_id)) {
Some(user) => println("Found: \{user.name}")
None => println("Not found")
}
pool.end()
}
///|
async fn run_tests() -> Unit noraise {
run_tests_inner() catch {
e => println("Error: \{e}")
}
}
///|
fn main {
@core.run_async(run_tests)
}| Option | Type | Default | Description |
|---|---|---|---|
backend |
"sqlite" | "sqlite_js" | "d1" | "postgres" | "postgres_js" | "mysql_js" |
"sqlite" |
Target backend |
validators |
bool |
false |
Generate validation functions |
json_schema |
bool |
false |
Generate JSON Schema |
overrides |
array |
[] |
Custom type mappings |
Example with all options:
codegen:
- plugin: moonbit
out: "db/gen"
options:
backend: "d1"
validators: true
json_schema: true
overrides:
- column: "users.id"
moonbit_type: "UserId"
- db_type: "uuid"
moonbit_type: "@mypackage.UUID"Override default type mappings for specific columns or database types:
| Field | Type | Description |
|---|---|---|
column |
string |
Column name in table.column format |
db_type |
string |
Database type (e.g., INTEGER, TEXT) |
moonbit_type |
string |
MoonBit type (supports package paths like @pkg.Type) |
Column overrides take precedence over db_type overrides.
For each query, sqlc-gen-moonbit generates:
- Param structs:
GetUserParams,CreateUserParamswith::new()constructor - Row structs:
GetUserRow,ListUsersRowwith fields matching SELECT columns - Query functions:
get_user(),list_users(),create_user()with type-safe parameters - Validators (optional):
GetUserParams::validate()returningResult[Unit, String] - JSON Schema (optional):
sqlc_schema.jsonwith type definitions
| Annotation | Return Type | Description |
|---|---|---|
:one |
T? |
Returns single row or None |
:many |
Array[T] |
Returns all matching rows |
:exec |
Unit |
Executes without returning data |
:execrows |
Int |
Returns number of affected rows |
:execlastid |
Int64 |
Returns last inserted ID (for INSERT ... RETURNING id) |
The d1 backend converts Int64 parameters — limit, offset,
BIGINT columns, and nullable Int64 Some(v) arms — through a
generated d1_bind_int64(v) helper before calling
D1PreparedStatement.bind(...).
This keeps MoonBit JS-target BigInt values away from Cloudflare D1.
Passing a BigInt to D1 can hang the Worker request instead of
throwing, so generated D1 code emits Number(value) for Int64 bind
values. Recording mocks for D1 should therefore expect plain numbers:
assert.deepEqual(call.params, ["mizchi", 100, 0]);Values outside JavaScript's safe integer range may lose precision. This matches D1's practical read-path behavior, where SQLite INTEGER cells come back to JavaScript as numbers. See #22.
You can generate MoonBit code from SQL without sqlc using the CLI tool or the library API.
Clone the repository and use the CLI tool:
# Clone and build
git clone https://github.com/mizchi/sqlc_gen_moonbit
cd sqlc_gen_moonbit
# Show help
moon run tools/codegen --target native -- --help
# Generate code (stdout)
moon run tools/codegen --target native -- queries.sql
# Generate to file with D1 backend
moon run tools/codegen --target native -- -b d1 -o db/gen/sqlc_queries.mbt queries.sql
# Check dependencies
moon run tools/codegen --target native -- --check-deps -b d1 queries.sqlCLI Options:
| Option | Description |
|---|---|
-o, --output <file> |
Output file (default: stdout) |
-c, --config <file> |
Config file (JSON) |
-b, --backend <type> |
Backend: sqlite, sqlite_js, d1, postgres, postgres_js, mysql_js |
--validators |
Generate validation functions |
--json-schema |
Generate JSON schema |
--check-deps |
Check required dependencies in moon.mod.json |
Config file format (config.json):
{
"backend": "sqlite",
"validators": true,
"json_schema": false,
"overrides": [
{ "column": "users.id", "moonbit_type": "UserId" },
{ "db_type": "uuid", "moonbit_type": "@uuid.UUID" }
]
}-- @query GetUser :one
-- @param id INTEGER
-- @returns id INTEGER, name TEXT, email TEXT
SELECT * FROM users WHERE id = ?;
-- @query ListUsers :many
-- @returns id INTEGER, name TEXT, email TEXT
SELECT * FROM users ORDER BY name;
-- @query CreateUser :exec
-- @param name TEXT
-- @param email TEXT
INSERT INTO users (name, email) VALUES (?, ?);Add dependency and use the library directly:
moon add mizchi/sqlc_gen_moonbitCreate tasks/main.mbt:
///|
fn main {
let sql_content = @fs.read_file_to_string("queries.sql") catch {
e => panic()
}
let code = @codegen.generate_from_sql(sql_content)
println(code)
}Add tasks/moon.pkg:
import {
"moonbitlang/x/fs",
"mizchi/sqlc_gen_moonbit/lib/codegen",
}
options(
"is-main": true,
)
Run:
moon run tasks > db/gen/sqlc_queries.mbtexamples/sqlite_native- SQLite with native bindingexamples/sqlite_js- SQLite with JS target (Node.js / Browser)examples/d1- Cloudflare Worker with D1examples/postgres_native- PostgreSQL with native bindingexamples/postgres_js- PostgreSQL with JS target (Node.js)examples/mysql_js- MySQL with JS target (Node.js)
Workers codebases often have extern "js" blocks with inline
db.prepare(...) statements. Migrating one such statement at a
time to the generated bindings is straightforward once you know the
shape: the generated MoonBit function can't be called from an
extern "js" block directly, but it can be called from MoonBit and
the result handed back into a JS-only renderer via a JSON bridge.
The pattern:
- The MoonBit
async fncalls the sqlc-generated query and serialises the rows into a JSON array string. - An
extern "js"function (no SQL, no DB access) takes that JSON plus any rendering context and returns the final string / HTML / markdown.
// 1. Sqlc-generated query lives in the `@db` package.
///|
async fn list_pages_markdown(
binding : String,
handle : String,
limit : Int,
offset : Int,
) -> String raise Error {
let db = d1_binding(binding)
let params = @db.ListPagesParams::new(
handle,
limit.to_int64(),
offset.to_int64(),
)
let rows = @db.list_pages(db, params)
let rows_json = serialise_rows(rows) // builds Json::array(...).stringify()
// 2. JS renderer takes the JSON, no DB access here.
render_pages_markdown(rows_json, handle)
}
///|
extern "js" fn render_pages_markdown(
rows_json : String,
handle : String,
) -> String =
#| (rowsJson, handle) => {
#| const rows = JSON.parse(rowsJson);
#| // ...pure string building (escape, sort, format)...
#| return out;
#| }Notes when migrating an existing inline db.prepare(...) block:
- Drop
.wait()in callers that previously expected a@js_async.Promise[String]— a MoonBitasync fnreturnsStringdirectly. - D1
Int64parameters (limit/offset etc.) bind as JavaScriptNumber, notBigInt, to avoid Cloudflare D1 request hangs. See issue #22. - Keep escaping / URL building / DOM-shaped output in the JS half; only the SQL round-trip moves to MoonBit.
- Wrap row fields in a small
safe_str/fallback_strhelper while sqlc casts cell values without a runtime null check (see issue #5).
See CONTRIBUTING.md for development workflow.
Apache-2.0