{"id":49194305,"url":"https://github.com/nao1215/sqlode","last_synced_at":"2026-05-09T13:28:52.904Z","repository":{"id":352032549,"uuid":"1208507589","full_name":"nao1215/sqlode","owner":"nao1215","description":"Generate type-safe Gleam code from SQL schemas and queries. sqlc-style workflow with PostgreSQL, MySQL, and SQLite support.","archived":false,"fork":false,"pushed_at":"2026-04-17T15:12:34.000Z","size":685,"stargazers_count":1,"open_issues_count":2,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-17T16:28:35.067Z","etag":null,"topics":["cli","code-generation","codegen","database","erlang","gleam","mysql","postgresql","sql","sqlite","type-safe"],"latest_commit_sha":null,"homepage":"https://hexdocs.pm/sqlode","language":"Gleam","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nao1215.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"nao1215"}},"created_at":"2026-04-12T11:33:39.000Z","updated_at":"2026-04-17T15:12:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nao1215/sqlode","commit_stats":null,"previous_names":["nao1215/sqlode"],"tags_count":9,"template":false,"template_full_name":null,"purl":"pkg:github/nao1215/sqlode","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fsqlode","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fsqlode/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fsqlode/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fsqlode/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nao1215","download_url":"https://codeload.github.com/nao1215/sqlode/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Fsqlode/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32173068,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-23T02:19:40.750Z","status":"ssl_error","status_checked_at":"2026-04-23T02:17:55.737Z","response_time":53,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cli","code-generation","codegen","database","erlang","gleam","mysql","postgresql","sql","sqlite","type-safe"],"created_at":"2026-04-23T09:03:42.109Z","updated_at":"2026-05-09T13:28:52.892Z","avatar_url":"https://github.com/nao1215.png","language":"Gleam","funding_links":["https://github.com/sponsors/nao1215"],"categories":[],"sub_categories":[],"readme":"# sqlode\n\n[![Hex](https://img.shields.io/hexpm/v/sqlode)](https://hex.pm/packages/sqlode)\n[![Hex Downloads](https://img.shields.io/hexpm/dt/sqlode)](https://hex.pm/packages/sqlode)\n[![CI](https://github.com/nao1215/sqlode/actions/workflows/ci.yml/badge.svg)](https://github.com/nao1215/sqlode/actions/workflows/ci.yml)\n[![license](https://img.shields.io/github/license/nao1215/sqlode)](./LICENSE)\n\nsqlode reads SQL schema and query files and generates typed Gleam code. It follows the sqlc workflow: write SQL, run the generator, call the generated functions.\n\nsqlode is inspired by [sqlc](https://sqlc.dev/) but is not a drop-in replacement. Macros use the `sqlode.*` prefix — `sqlc.*` is not accepted.\n\nSupported engines (raw and native): PostgreSQL (`pog`), MySQL 8.0 (`shork`), SQLite (`sqlight`). The per-engine support matrix lives in [`doc/capabilities.md`](doc/capabilities.md).\n\nFirst time here? [`doc/tutorials/getting-started-sqlite.md`](doc/tutorials/getting-started-sqlite.md) walks through a SQLite project end to end, and [`examples/sqlite-basic/`](examples/sqlite-basic/) is the runnable version of the same tutorial. The rest of this README is reference material.\n\n## Quickstart (SQLite)\n\nThe shortest empty-project to typed Gleam path. No daemon, no Docker, no\nescript install — just `gleam` and `sqlode` as a dependency.\n\n```console\ngleam new myapp\ncd myapp\ngleam add sqlode sqlight\ngleam run -m sqlode -- init --engine=sqlite\n```\n\nEdit the generated `db/schema.sql` and `db/query.sql` (the `init` stubs\nalready compile, so it is fine to leave them as-is for the first run):\n\n```sql\n-- db/schema.sql\nCREATE TABLE authors (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT NOT NULL,\n  bio TEXT\n);\n\n-- db/query.sql\n-- name: GetAuthor :one\nSELECT id, name, bio FROM authors WHERE id = ?;\n\n-- name: CreateAuthor :exec\nINSERT INTO authors (name, bio)\nVALUES (sqlode.arg(author_name), sqlode.narg(bio));\n```\n\nSwitch to native mode (so `sqlode` emits a ready-to-call `sqlight`\nadapter) and generate:\n\n```console\nsed -i 's/runtime: \"raw\"/runtime: \"native\"/' sqlode.yaml\ngleam run -m sqlode -- generate\ngleam run\n```\n\nThe generated `src/db/sqlight_adapter.gleam` then exposes\n`get_author(db, params)` / `create_author(db, params)` returning typed\n`Result`s — see the SQLite section under\n[Using the generated adapter](#using-the-generated-adapter) for a full\n`main.gleam` and the\n[`examples/sqlite-basic/`](examples/sqlite-basic/) project for the\nready-to-clone version. The\n[getting-started tutorial](doc/tutorials/getting-started-sqlite.md)\nwalks through every line.\n\nThe rest of this README is the install matrix and the per-feature\nreference. Skip ahead to whichever section you need.\n\n## Targets\n\n- **CLI** (`sqlode generate` etc.): BEAM only (escript). The supported drivers — `pog`, `shork`, `sqlight` — are BEAM-native, so this is intentional.\n- **`sqlode/runtime`**: cross-target. Pure value transformation (`prepare(query, params)` → `#(String, List(Value))`) with no FFI. Importable from a JavaScript-target Gleam app.\n- **Generated modules**: cross-target. They depend only on `sqlode/runtime` and the driver chosen by the consumer; the JS-target story is gated by whether a JavaScript-callable driver is available downstream.\n\n## Getting started\n\n### Install\n\nsqlode ships as an Erlang escript, so most paths need Erlang/OTP on the host. Option D (Docker) bundles Erlang, and option E (mise) manages both the escript and Erlang together.\n\nWhichever install path you pick, your Gleam project still needs `gleam add sqlode` because generated code imports `sqlode/runtime`.\n\n#### A. One-line installer\n\n```console\ncurl -fsSL https://raw.githubusercontent.com/nao1215/sqlode/main/scripts/install.sh | sh\n```\n\nWrites the latest release escript to `$HOME/.local/bin/sqlode` and warns if Erlang/OTP is missing. To review the script first, download it, read it, then `sh install.sh`.\n\nEnvironment variables:\n\n- `SQLODE_VERSION=v0.1.0` pins a release tag instead of `latest`.\n- `SQLODE_INSTALL_DIR=/path/to/bin` installs elsewhere. System paths need `sudo`.\n\nIf `$HOME/.local/bin` is not on your `PATH`, add it:\n\n```console\nexport PATH=\"$HOME/.local/bin:$PATH\"\n```\n\n#### B. Manual escript download\n\nGrab the escript from [GitHub Releases](https://github.com/nao1215/sqlode/releases) and put it on your `PATH`:\n\n```console\nchmod +x sqlode\n./sqlode generate --config=sqlode.yaml\n```\n\n#### C. Run via Gleam\n\n```console\ngleam add sqlode\ngleam run -m sqlode -- generate\n```\n\n#### D. Docker (no Erlang install)\n\n```console\ndocker run --rm -v \"$PWD:/work\" ghcr.io/nao1215/sqlode:latest init --engine=sqlite\ndocker run --rm -v \"$PWD:/work\" ghcr.io/nao1215/sqlode:latest generate\n```\n\nThe container's working directory is `/work`, so mounting your project there lets `init` / `generate` / `verify` write into the host. Swap `:latest` for a version tag (`:0.10.0`) to pin a release. The `:latest` tag appears once the docker workflow has run on `main`; before that, `docker build -t sqlode .` at the repo root produces the same image.\n\n#### E. mise (recommended for Gleam projects)\n\nIf you already use [mise](https://mise.jdx.dev/) for managing Gleam and Erlang:\n\n```console\nmise plugin add sqlode https://github.com/nao1215/sqlode.git#mise-plugin\nmise install sqlode@latest\n```\n\nThis installs the escript and manages versions alongside your Gleam/Erlang toolchain. Pin a version in `.mise.toml`:\n\n```toml\n[tools]\nsqlode = \"0.12.0\"\n```\n\nmise handles `PATH` automatically — no manual exports needed.\n\n### Initialize config\n\n```console\n# standalone CLI\nsqlode init\n\n# or via Gleam\ngleam run -m sqlode -- init\n```\n\nThis creates `sqlode.yaml` along with stub files `db/schema.sql` and `db/query.sql`:\n\n```yaml\nversion: \"2\"\nsql:\n  - schema: \"db/schema.sql\"\n    queries: \"db/query.sql\"\n    engine: \"postgresql\"\n    gen:\n      gleam:\n        out: \"src/db\"\n        runtime: \"raw\"\n```\n\n`schema` and `queries` each take a single path, a list of paths, or a directory (sqlode then picks up every `.sql` in it). An optional `name` on each `sql` block shows up in diagnostics when several blocks are configured.\n\nThe schema parser accepts either a schema snapshot or a migration history (additive and destructive DDL both work). The full supported-statement list is in [Schema DDL scope](#schema-ddl-scope).\n\n### Write SQL\n\nSchema (`db/schema.sql`):\n\n```sql\nCREATE TABLE authors (\n  id BIGSERIAL PRIMARY KEY,\n  name TEXT NOT NULL,\n  bio TEXT,\n  created_at TIMESTAMP NOT NULL\n);\n```\n\nQueries (`db/query.sql`):\n\n```sql\n-- name: GetAuthor :one\nSELECT id, name, bio\nFROM authors\nWHERE id = $1;\n\n-- name: ListAuthors :many\nSELECT id, name\nFROM authors\nORDER BY name;\n\n-- name: CreateAuthor :exec\nINSERT INTO authors (name, bio)\nVALUES (sqlode.arg(author_name), sqlode.narg(bio));\n```\n\n### Generate\n\n```console\n# standalone CLI\nsqlode generate\n\n# or via Gleam\ngleam run -m sqlode -- generate\n```\n\nThis writes `params.gleam` and `queries.gleam` under the configured output directory. `models.gleam` is added when the schema defines tables or when a `:one` / `:many` query returns result columns.\n\n## Generated code\n\n### params.gleam\n\n```gleam\npub type GetAuthorParams {\n  GetAuthorParams(id: Int)\n}\n\npub fn get_author_values(params: GetAuthorParams) -\u003e List(Value) {\n  [runtime.int(params.id)]\n}\n\npub type CreateAuthorParams {\n  CreateAuthorParams(author_name: String, bio: Option(String))\n}\n```\n\n### models.gleam\n\nOne record per table in the schema, plus row types for queries that return results. When a query's columns exactly match a table (same columns, types, nullability, order), sqlode emits an alias instead of a duplicate record.\n\n```gleam\n// Table record (singularized), reusable across queries\npub type Author {\n  Author(id: Int, name: String, bio: Option(String), created_at: String)\n}\n\n// Exact match: alias\npub type GetAuthorRow =\n  Author\n\n// Partial match: separate row type\npub type ListAuthorsRow {\n  ListAuthorsRow(id: Int, name: String)\n}\n```\n\n### queries.gleam\n\nEach query is a `RawQuery(params)`. `all()` / `QueryInfo` enumerate queries without type parameters.\n\n```gleam\npub type QueryInfo {\n  QueryInfo(name: String, sql: String, command: runtime.QueryCommand, param_count: Int)\n}\n\npub fn all() -\u003e List(QueryInfo) { ... }\n\npub fn get_author() -\u003e runtime.RawQuery(params.GetAuthorParams) { ... }\npub fn list_authors() -\u003e runtime.RawQuery(Nil) { ... }\npub fn create_author() -\u003e runtime.RawQuery(params.CreateAuthorParams) { ... }\n```\n\nFor the common case, call the generated `prepare_*` helper. It builds the params record and returns the `(sql, values)` tuple that Gleam database drivers accept directly:\n\n```gleam\nlet #(sql, values) = queries.prepare_get_author(id: 1)\n// sql: \"... WHERE id = $1\"\n```\n\n`sqlode.slice` works the same way — pass a `List`, the SQL expands to the right number of placeholders:\n\n```gleam\nlet #(sql, values) = queries.prepare_get_authors_by_ids(ids: [1, 2, 3])\n// sql: \"... WHERE id IN ($1, $2, $3)\"\n```\n\nIf you need the `RawQuery` descriptor (caching, batching, custom wrappers), the low-level shape is still there:\n\n```gleam\nlet q = queries.get_author()\nlet #(sql, values) = runtime.prepare(q, params.GetAuthorParams(id: 1))\n```\n\nThe placeholder dialect (`$1` / `?`) is baked into the `RawQuery`, so `runtime.prepare` does not take it as an argument.\n\n## Runtime modes\n\nThe `runtime` option controls what code sqlode emits.\n\n| Mode | Generated files | DB driver | Use case |\n|------|----------------|-----------|----------|\n| `raw` | queries, params, models | — | You run the queries yourself |\n| `native` | queries, params, models, adapter | pog / sqlight / shork | Full adapter: bind params, decode rows |\n\nsqlode itself must be a runtime dependency (not just dev) because the generated code imports `sqlode/runtime`. `native` mode also needs a driver:\n\n```console\ngleam add sqlode\ngleam add pog       # PostgreSQL native\ngleam add sqlight   # SQLite native\ngleam add shork     # MySQL native\n```\n\n### Self-contained generation (`vendor_runtime`)\n\n`gen.gleam.vendor_runtime: true` copies the `sqlode/runtime` module into the output directory as `runtime.gleam` and rewrites the generated imports to match. The generated package then only needs sqlode as a dev dependency. Native adapters still need their driver.\n\n```yaml\ngen:\n  gleam:\n    out: \"src/db\"\n    runtime: \"raw\"\n    vendor_runtime: true\n```\n\nShared-runtime is smaller and updates with `gleam update sqlode`; vendored is self-contained but has to be regenerated to pick up runtime changes.\n\n## Adapter generation\n\nWith `runtime: \"native\"`, sqlode generates an adapter that wraps [pog](https://hexdocs.pm/pog/) (PostgreSQL), [sqlight](https://hexdocs.pm/sqlight/) (SQLite), or [shork](https://hexdocs.pm/shork/) (MySQL 8.0). The three adapters have the same shape; MySQL routes `:execrows` through `SELECT ROW_COUNT()` and `:execlastid` through `SELECT LAST_INSERT_ID()` under the hood.\n\nOut of scope today: MariaDB is not separately validated — the `mysql` engine targets MySQL 8.0. `:execresult` is rejected on every native target; use `:exec`, `:execrows`, or `:execlastid`. `BLOB` / `BINARY` round-trip through `shork_ffi.coerce` (the same identity FFI shork's value constructors use), so no shork API extension is needed.\n\n```yaml\ngen:\n  gleam:\n    out: \"src/db\"\n    runtime: \"native\"\n```\n\nAn adapter function handles parameter binding, execution, and decoding:\n\n```gleam\n// pog_adapter.gleam (generated)\npub fn get_author(db: pog.Connection, p: params.GetAuthorParams)\n  -\u003e Result(Option(models.GetAuthorRow), pog.QueryError)\n```\n\n### Using the generated adapter\n\n#### SQLite example\n\n```gleam\nimport db/params\nimport db/sqlight_adapter\nimport gleam/io\nimport gleam/option\nimport sqlight\n\npub fn main() {\n  let assert Ok(db) = sqlight.open(\":memory:\")\n\n  // Create table\n  let assert Ok(_) = sqlight.exec(\n    \"CREATE TABLE authors (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      name TEXT NOT NULL,\n      bio TEXT\n    );\",\n    db,\n  )\n\n  // :exec — returns Result(Nil, sqlight.Error)\n  let assert Ok(_) = sqlight_adapter.create_author(\n    db,\n    params.CreateAuthorParams(\n      author_name: \"Alice\",\n      bio: option.Some(\"Author bio\"),\n    ),\n  )\n\n  // :one — returns Result(Option(Row), sqlight.Error)\n  let assert Ok(option.Some(author)) = sqlight_adapter.get_author(\n    db,\n    params.GetAuthorParams(id: 1),\n  )\n  io.debug(author.name)  // \"Alice\"\n\n  // :many — returns Result(List(Row), sqlight.Error)\n  let assert Ok(authors) = sqlight_adapter.list_authors(db)\n  io.debug(authors)  // [ListAuthorsRow(id: 1, name: \"Alice\")]\n}\n```\n\n#### PostgreSQL example\n\n```gleam\nimport db/params\nimport db/pog_adapter\nimport gleam/io\nimport gleam/option\nimport pog\n\npub fn main() {\n  let db = pog.default_config()\n    |\u003e pog.host(\"localhost\")\n    |\u003e pog.database(\"mydb\")\n    |\u003e pog.connect()\n\n  // :one — returns Result(Option(Row), pog.QueryError)\n  let assert Ok(option.Some(author)) = pog_adapter.get_author(\n    db,\n    params.GetAuthorParams(id: 1),\n  )\n  io.debug(author.name)\n\n  // :many — returns Result(List(Row), pog.QueryError)\n  let assert Ok(authors) = pog_adapter.list_authors(db)\n  io.debug(authors)\n}\n```\n\n#### MySQL examples\n\nMySQL works in both modes. `raw` returns the prepared SQL plus encoded params; `native` generates a `mysql_adapter` that wraps `shork`.\n\n##### MySQL raw mode\n\n```yaml\nsql:\n  - engine: \"mysql\"\n    schema: \"db/schema.sql\"\n    queries: \"db/query.sql\"\n    gen:\n      gleam:\n        out: \"src/db\"\n        runtime: \"raw\"\n```\n\n```sql\n-- name: GetAuthor :one\nSELECT id, email, display_name\nFROM authors\nWHERE id = ?;\n```\n\n```gleam\nimport db/params\nimport db/queries\nimport sqlode/runtime\n\npub fn fetch(id: Int) -\u003e #(String, List(runtime.Value)) {\n  runtime.prepare(queries.get_author(), params.GetAuthorParams(id:))\n}\n```\n\n##### MySQL native mode\n\n```yaml\nsql:\n  - engine: \"mysql\"\n    schema: \"db/schema.sql\"\n    queries: \"db/query.sql\"\n    gen:\n      gleam:\n        out: \"src/db\"\n        runtime: \"native\"\n```\n\n```gleam\nimport db/mysql_adapter\nimport db/params\nimport gleam/option\nimport shork\n\npub fn main() {\n  let assert Ok(db) = shork.connect(shork.default_config())\n\n  // :execlastid — returns the AUTO_INCREMENT id of the new row.\n  let assert Ok(id) =\n    mysql_adapter.create_author(\n      db,\n      params.CreateAuthorParams(\n        email: \"alice@example.com\",\n        display_name: \"Alice\",\n        bio: option.None,\n        is_active: True,\n        avatar: option.None,\n      ),\n    )\n\n  // :one — returns Result(Option(Row), shork.QueryError).\n  let assert Ok(option.Some(author)) =\n    mysql_adapter.get_author(db, params.GetAuthorParams(id:))\n  let _ = author.display_name\n  Nil\n}\n```\n\n#### Return types by annotation\n\n| Annotation | sqlight return type | pog return type | shork return type |\n|---|---|---|---|\n| `:one` | `Result(Option(Row), sqlight.Error)` | `Result(Option(Row), pog.QueryError)` | `Result(Option(Row), shork.QueryError)` |\n| `:many` | `Result(List(Row), sqlight.Error)` | `Result(List(Row), pog.QueryError)` | `Result(List(Row), shork.QueryError)` |\n| `:exec` | `Result(Nil, sqlight.Error)` | `Result(Nil, pog.QueryError)` | `Result(Nil, shork.QueryError)` |\n| `:execrows` | `Result(Int, sqlight.Error)` | `Result(Int, pog.QueryError)` | `Result(Int, shork.QueryError)` |\n| `:execlastid` | `Result(Int, sqlight.Error)` | `Result(Int, pog.QueryError)` | `Result(Int, shork.QueryError)` |\n\n`:batchone`, `:batchmany`, `:batchexec`, and `:copyfrom` are not implemented and fail generation — see [Planned annotations](#planned-annotations).\n\n`:execresult` is `raw` only. Native rejects it because it is indistinguishable from `:execrows` once rows are decoded.\n\n## Query annotations\n\n| Annotation | Description |\n|---|---|\n| `:one` | Returns at most one row |\n| `:many` | Returns zero or more rows |\n| `:exec` | Returns nothing |\n| `:execresult` | Returns the execution result (raw runtime only) |\n| `:execrows` | Returns the number of affected rows |\n| `:execlastid` | Returns the last inserted ID |\n\n### Planned annotations\n\nReserved for future work; any use fails generation today.\n\n| Annotation | Planned behavior |\n|---|---|\n| `:batchone` | Batch variant of `:one` |\n| `:batchmany` | Batch variant of `:many` |\n| `:batchexec` | Batch variant of `:exec` |\n| `:copyfrom` | Bulk insert |\n\n## Query macros\n\n| Macro | Description |\n|---|---|\n| `sqlode.arg(name)` | Names a parameter |\n| `sqlode.narg(name)` | Names a nullable parameter |\n| `sqlode.slice(name)` | Expands to a list parameter for IN clauses |\n| `sqlode.embed(table)` | Embeds all columns of a table into the result |\n| `@name` | Shorthand for `sqlode.arg(name)` |\n\n### Skipping a query\n\nPrefix with `-- sqlode:skip` to exclude a query from generation — useful when the SQL uses syntax sqlode cannot yet parse.\n\n```sql\n-- sqlode:skip\n-- name: ComplexQuery :many\nSELECT ...;\n```\n\n### sqlode.slice example\n\n```sql\n-- name: GetAuthorsByIds :many\nSELECT id, name FROM authors\nWHERE id IN (sqlode.slice(ids));\n```\n\nGenerates a parameter with type `List(Int)`:\n\n```gleam\npub type GetAuthorsByIdsParams {\n  GetAuthorsByIdsParams(ids: List(Int))\n}\n```\n\n\u003e [!IMPORTANT]\n\u003e `sqlode.slice(...)` is only supported on PostgreSQL because the\n\u003e generated native SQLite (`sqlight`) and MySQL (`shork`) adapters\n\u003e cannot bind array parameters at runtime. `sqlode generate` /\n\u003e `sqlode verify` reject any block that targets `sqlite` or `mysql`\n\u003e with a slice macro present.\n\u003e\n\u003e If you need IN-clause expansion against SQLite/MySQL, drop the\n\u003e macro and either:\n\u003e\n\u003e - inline the placeholders yourself in the SQL and bind one\n\u003e   parameter per element from your application code, or\n\u003e - move the query to `runtime: raw` and call `runtime.prepare`\n\u003e   followed by your driver's parameter-list call directly, where\n\u003e   you control how the list flattens to scalar bindings.\n\n### sqlode.embed example\n\n```sql\n-- name: GetBookWithAuthor :one\nSELECT sqlode.embed(authors), books.title\nFROM books\nJOIN authors ON books.author_id = authors.id\nWHERE books.id = $1;\n```\n\nThe embedded table becomes a nested field:\n\n```gleam\npub type GetBookWithAuthorRow {\n  GetBookWithAuthorRow(authors: Author, title: String)\n}\n```\n\n## JOIN support\n\nColumns from JOINed tables are resolved against their source tables:\n\n```sql\n-- name: GetBookWithAuthor :one\nSELECT books.title, authors.name\nFROM books\nJOIN authors ON books.author_id = authors.id;\n```\n\n`books.title` and `authors.name` end up correctly typed in the generated row.\n\n## RETURNING clause\n\nPostgreSQL `RETURNING` columns become the result type:\n\n```sql\n-- name: CreateAuthor :one\nINSERT INTO authors (name, bio) VALUES ($1, $2)\nRETURNING id, name;\n```\n\n```gleam\npub type CreateAuthorRow {\n  CreateAuthorRow(id: Int, name: String)\n}\n```\n\n## CTE (WITH clause)\n\nCommon Table Expressions are supported — sqlode strips the CTE prefix and infers types from the main query:\n\n```sql\n-- name: GetRecentAuthors :many\nWITH filtered AS (\n  SELECT id FROM authors WHERE id \u003e 0\n)\nSELECT authors.id, authors.name\nFROM authors\nJOIN filtered ON authors.id = filtered.id;\n```\n\n## Type mapping\n\n| SQL type | Gleam type |\n|---|---|\n| INT, INTEGER, SMALLINT, BIGINT, SERIAL, BIGSERIAL | Int |\n| FLOAT, DOUBLE, REAL, NUMERIC, DECIMAL, MONEY | Float |\n| BOOLEAN, BOOL | Bool |\n| TEXT, VARCHAR, CHAR | String |\n| BYTEA, BLOB, BINARY | BitArray |\n| TIMESTAMP, DATETIME | String |\n| DATE | String |\n| TIME, TIMETZ, INTERVAL | String |\n| UUID | String |\n| JSON, JSONB | String |\n| `TYPE[]`, `TYPE ARRAY` | `List(TYPE)` |\n| CITEXT, INET, CIDR, MACADDR, XML, BIT, TSVECTOR, TSQUERY | String |\n| POINT, LINE, LSEG, BOX, PATH, POLYGON, CIRCLE | String |\n| PostgreSQL ENUM | Generated custom type (with to_string/from_string helpers) |\n\nNullable columns (no `NOT NULL`) are wrapped in `Option(T)`.\n\n## Overrides\n\nEach `sql` block can carry type overrides and column renames:\n\n```yaml\nsql:\n  - schema: \"db/schema.sql\"\n    queries: \"db/query.sql\"\n    engine: \"postgresql\"\n    gen:\n      gleam:\n        out: \"src/db\"\n    overrides:\n      types:\n        - db_type: \"uuid\"\n          gleam_type: \"String\"\n        - column: \"users.id\"\n          gleam_type: \"String\"\n      renames:\n        - table: \"authors\"\n          column: \"bio\"\n          rename_to: \"biography\"\n```\n\nTwo targeting modes:\n\n- `db_type` — every column of a given database type (e.g. every `uuid` becomes `String`).\n- `column` — a specific column via `table.column` (e.g. only `users.id`).\n\nColumn-level overrides win over `db_type` overrides.\n\n### Custom type aliases\n\nA non-primitive `gleam_type` (e.g. `UserId` instead of `Int`) keeps the name in generated record fields but, by default, encodes and decodes through the underlying primitive. That works when the mapped type is a transparent alias:\n\n```gleam\n// OK: transparent alias — the underlying primitive encoders apply\n// directly because `UserId` is just `Int` at runtime.\npub type UserId = Int\n```\n\nFor opaque domain types (single-constructor wrappers around the underlying value), declare explicit `encode` / `decode` codec hooks alongside the override. The generated code then routes the value through the user-supplied pair instead of calling the primitive encoder on the wrapped form:\n\n```yaml\noverrides:\n  types:\n    - db_type: \"int\"\n      gleam_type: \"myapp/types.UserId\"\n      encode: \"user_id_to_int\"\n      decode: \"int_to_user_id\"\n```\n\n```gleam\n// myapp/types.gleam\npub opaque type UserId {\n  UserId(Int)\n}\n\npub fn user_id_to_int(id: UserId) -\u003e Int {\n  let UserId(inner) = id\n  inner\n}\n\npub fn int_to_user_id(value: Int) -\u003e UserId {\n  UserId(value)\n}\n```\n\n`encode` and `decode` MUST be specified together — providing only one half is rejected at config-load time. Hook function names are resolved relative to the module of `gleam_type`: with `gleam_type: \"myapp/types.UserId\"` the generated code calls `types.user_id_to_int(value)` (the trailing-segment alias of the existing type import), so the user only needs the type itself in scope.\n\nWithout codec hooks, sqlode emits a warning at generation time pointing at the transparent-alias requirement; the warning is suppressed when codec hooks are provided.\n\nsqlode checks that `gleam_type` starts with an uppercase letter and `encode` / `decode` start with a lowercase letter (or underscore).\n\n### Semantic type mappings\n\nBy default UUID, JSON, DATE, TIME, TIMESTAMP become `String`. `type_mapping` opts into richer aliases:\n\n```yaml\ngen:\n  gleam:\n    out: \"src/db\"\n    type_mapping: \"rich\"\n```\n\n| SQL type | `string` (default) | `rich` | `strong` |\n|----------|-------------------|--------|----------|\n| TIMESTAMP / DATETIME | `String` | `SqlTimestamp` | `SqlTimestamp(String)` |\n| DATE | `String` | `SqlDate` | `SqlDate(String)` |\n| TIME / TIMETZ | `String` | `SqlTime` | `SqlTime(String)` |\n| UUID | `String` | `SqlUuid` | `SqlUuid(String)` |\n| JSON / JSONB | `String` | `SqlJson` | `SqlJson(String)` |\n\n`rich` is a plain `String` alias — readable in signatures, not enforced by the compiler. `strong` emits a single-constructor wrapper with an `*_to_string` helper; `SqlUuid` and `String` are then distinct at compile time, and adapters wrap / unwrap values automatically.\n\nExample with `type_mapping: \"strong\"`:\n\n```gleam\n// Generated in models.gleam\npub type SqlUuid {\n  SqlUuid(String)\n}\n\npub fn sql_uuid_to_string(value: SqlUuid) -\u003e String {\n  let SqlUuid(inner) = value\n  inner\n}\n```\n\n## Limitations\n\nsqlode is still early. A few constraints to check before adopting it; most are tracked for future releases.\n\n### Parameter type inference\n\nsqlode infers a parameter's type from its surrounding SQL. Four contexts are recognised today:\n\n1. `INSERT INTO t (col) VALUES ($1)` — parameter inherits `col`'s type.\n2. `WHERE col = $1` (and `!=`, `\u003c`, `\u003c=`, `\u003e`, `\u003e=`).\n3. `WHERE col IN ($1, $2, ...)` and `sqlode.slice($1)`.\n4. `$1::int` / `CAST($1 AS int)` — explicit cast.\n\nAnywhere else, sqlode fails generation with:\n\n\u003e `Query \"Name\": could not infer type for parameter $N. Use a type cast (e.g. $N::int) to specify the type`\n\nCases that need an explicit cast today: scalar arithmetic (`price + $1`), parameters inside `CASE WHEN` branches whose other branches are also parameters, and function arguments sqlode does not yet recognise. Pin the type with `$N::int` (PostgreSQL) or `CAST($N AS INTEGER)` (SQLite).\n\n### Schema DDL scope\n\nThe schema parser accepts both schema snapshots and migration histories (including destructive DDL). Supported statements:\n\n- `CREATE TABLE`, `CREATE VIEW`, `CREATE TYPE` (enum)\n- `ALTER TABLE ... ADD COLUMN` / `DROP COLUMN`\n- `ALTER TABLE ... RENAME TO` / `RENAME COLUMN`\n- `ALTER TABLE ... ALTER COLUMN TYPE` / `SET NOT NULL` / `DROP NOT NULL`\n- `DROP TABLE`, `DROP VIEW`, `DROP TYPE`\n\nAnything else (`CREATE INDEX`, transaction blocks, comments) is silently skipped.\n\n### View resolution\n\n`CREATE VIEW ... AS SELECT ...` columns resolve against the base tables so generated models have real types. By default sqlode fails generation when any view column cannot be resolved — a partially resolved view is almost always a sign that the schema and the config have drifted, and silently dropping columns lets that drift reach generated code.\n\nIf you need the old warn-and-continue behaviour, set `strict_views: false`:\n\n```yaml\nsql:\n  - schema: \"db/schema.sql\"\n    queries: \"db/query.sql\"\n    engine: \"postgresql\"\n    gen:\n      gleam:\n        out: \"src/db\"\n        strict_views: false\n```\n\nUnresolvable columns are then printed to stderr and dropped (or the whole view is dropped if nothing resolves).\n\n### Custom types: transparent aliases by default, opaque via codec hooks\n\nSee [Custom type aliases](#custom-type-aliases). Without codec hooks, the mapped `gleam_type` MUST be a transparent alias (`pub type UserId = Int`) — the generated code calls primitive encoders directly on the value. Opaque single-constructor types (`pub opaque type UserId { UserId(Int) }`) are supported via the explicit `encode` / `decode` hooks documented there.\n\n## Config options\n\n### emit_sql_as_comment\n\nAttach the original SQL as a comment on each generated adapter function.\n\n```yaml\ngen:\n  gleam:\n    out: \"src/db\"\n    emit_sql_as_comment: true\n```\n\n### emit_exact_table_names\n\nKeep table names as-is instead of singularising. `authors` stays `pub type Authors { ... }` (default would be `Author`).\n\n```yaml\ngen:\n  gleam:\n    out: \"src/db\"\n    emit_exact_table_names: true\n```\n\n## CLI\n\n```\n# Standalone escript\nsqlode generate [--config=./sqlode.yaml]\nsqlode verify   [--config=./sqlode.yaml]\nsqlode init     [--output=./sqlode.yaml]\n\n# Via Gleam\ngleam run -m sqlode -- generate [--config=./sqlode.yaml]\ngleam run -m sqlode -- verify   [--config=./sqlode.yaml]\ngleam run -m sqlode -- init     [--output=./sqlode.yaml]\n```\n\n### `sqlode verify`\n\n`verify` is the static check lane for CI. It loads the project like `generate` does — schema parsing, query parsing, analyser pass — but writes no files and collects every failure into a single report instead of short-circuiting on the first error.\n\n```\n$ sqlode verify\nVerifying config: sqlode.yaml\n[src/db] query \"FilterAuthors\" has 4 inferred parameter(s), exceeds query_parameter_limit 3\n```\n\nNon-zero exit on any finding, so it gates generation in CI:\n\n```yaml\n- run: sqlode verify\n- run: sqlode generate\n```\n\nPer-block policies `verify` honours:\n\n- `strict_views` — promote view-resolution warnings to findings (same as `generate`).\n- `query_parameter_limit` — per-query cap on inferred parameters, mirroring sqlc's option. Unset means no limit.\n\n## Migrating from sqlc\n\nsqlode follows sqlc conventions, so most SQL files move over untouched. The differences:\n\n| | sqlc | sqlode |\n|---|---|---|\n| Install | Standalone binary (`brew install sqlc`) | Escript or `gleam add sqlode` |\n| Config | `sqlc.yaml` / `sqlc.json` | `sqlode.yaml` (v2 format only), also accepts `sqlc.yaml` / `sqlc.yml` / `sqlc.json` on autodiscovery |\n| Generate | `sqlc generate` | `sqlode generate` |\n| Init | `sqlc init` | `sqlode init` |\n| Vet/Verify | `sqlc vet`, `sqlc verify` | `sqlode verify` (static analysis + `query_parameter_limit`) |\n| Target language | Go, Python, Kotlin, etc. | Gleam |\n| Runtime | Generated code is self-contained | Generated code imports `sqlode/runtime` by default; set `vendor_runtime: true` to vendor a copy and drop the runtime dependency (see [Self-contained generation](#self-contained-generation-vendor_runtime)) |\n\n### Migration steps\n\n1. Install sqlode — see [Install](#install).\n2. Keep your existing `sqlc.yaml` / `sqlc.yml` / `sqlc.json`. `sqlode generate` auto-discovers them in the current directory when no `--config` is passed (search order: `sqlode.yaml`, `sqlode.yml`, `sqlc.yaml`, `sqlc.yml`, `sqlc.json`; pass `--config=\u003cpath\u003e` if more than one exists). If you prefer a dedicated file, copy the config to `sqlode.yaml`. Either way, keep `version: \"2\"` and the `sql` blocks and replace the `gen` section:\n\n   ```yaml\n   gen:\n     gleam:\n       out: \"src/db\"\n       runtime: \"raw\"   # or \"native\"\n   ```\n\n3. Swap `sqlc.arg` / `sqlc.narg` / `sqlc.slice` / `sqlc.embed` for the `sqlode.*` versions in your `.sql` files. The `@name` shorthand is unchanged.\n4. Run `sqlode generate` (or `gleam run -m sqlode -- generate`).\n\n### Unsupported sqlc features\n\n- `sqlc.yaml` v1 format\n- `vet` and `verify` commands\n- `emit_json_tags` and other sqlc-specific emit options not listed above\n\n## Writing custom adapters\n\n`sqlode generate` produces adapters for the engines we ship native code\nfor (PostgreSQL via `pog`, SQLite via `sqlight`, MySQL via raw / shork).\nFor other backends — an in-memory test database, a SQLite-WASM build, a\nquery-log middleware, etc. — wire the runtime against your own driver.\n\nThe contract is small:\n\n1. Build a `runtime.RawQuery(p)` with the SQL string the engine expects,\n   the parameter encoder, and the slice metadata function.\n2. Call `runtime.prepare(query, params)` to get back the final SQL and\n   the flattened `List(Value)`.\n3. Hand `(sql, values)` to your driver.\n\nFor tests and adapter wiring, use `runtime.raw_query_for_test/7` instead\nof poking the `RawQuery(...)` constructor directly. The named helper\nmakes the intent obvious and is documented as test-only:\n\n```gleam\nimport sqlode/runtime\n\nlet query =\n  runtime.raw_query_for_test(\n    name: \"GetUser\",\n    sql: \"SELECT * FROM users WHERE id = \" \u003c\u003e runtime.param_marker(1),\n    command: runtime.QueryOne,\n    param_count: 1,\n    placeholder_style: runtime.DollarNumbered,\n    encode: fn(id) { [runtime.int(id)] },\n    slice_info: fn(_) { [] },\n  )\nlet #(sql, values) = runtime.prepare(query, 42)\n// sql    == \"SELECT * FROM users WHERE id = $1\"\n// values == [SqlInt(42)]\n```\n\n`test/runtime_property_test.gleam` carries longer worked examples\n(slice expansion across all three placeholder styles, the `IN (NULL)`\nrewrite for empty slices). Each example is a self-contained\n`raw_query_for_test` invocation that exercises a real `prepare` codepath\nwithout touching the codegen pipeline.\n\nIf your `slices` metadata might be malformed (negative length,\nout-of-range index), use `runtime.expand_slice_placeholders_checked`\ninstead of `expand_slice_placeholders` — the panicking variant is\ncorrect for codegen-driven flows but the `_checked` variant is the\nright tool when the input comes from runtime sources.\n\n## License\n\n[MIT](./LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Fsqlode","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnao1215%2Fsqlode","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Fsqlode/lists"}