Phoenix + CI Cookbook

Copy Markdown View Source

0.3 made --infer Phoenix-ready (DATABASE_URL / SSL). This cookbook covers the day-to-day Mix workflow: migrate before generate/check, recommended aliases, CI with mix squirrelix.check, and intentional coexistence with Ecto.

Squirrelix is not an Ecto Repo wrapper. Use Ecto for schemas and migrations; put typed queries in sql/ and call the generated modules with a Postgrex.conn().

Dependencies

Keep Squirrelix as a dev/test Mix tool and Postgrex as a runtime dependency (Phoenix apps already depend on Postgrex via Ecto SQL):

def deps do
  [
    {:squirr_elix, "~> 0.5.0", only: [:dev, :test], runtime: false},
    {:postgrex, "~> 0.22"}
  ]
end

Include :test so mix squirrelix.check works under MIX_ENV=test in CI.

Layout in a Phoenix app

Place sql/ directories next to the contexts that own the queries — the same convention as Getting Started:

lib/
└── my_app/
    └── accounts/
        ├── sql/
        │   ├── find_user.sql
        │   └── list_users.sql
        └── sql.ex              # generated: MyApp.Accounts.SQL

Ecto schemas and migrations stay where Phoenix puts them (lib/my_app/accounts/user.ex, priv/repo/migrations/). Squirrelix never reads those modules; it only needs the database schema to exist when you run --infer.

DATABASE_URL and connection config

Squirrelix does not read config/*.exs or your Ecto.Repo settings. Point it at the same database Ecto uses via DATABASE_URL, PG* variables, or Mix flags.

Precedence (highest first): flags → --urlDATABASE_URLPG* → defaults.

Phoenix-style local / CI usage:

export DATABASE_URL=postgres://postgres:postgres@localhost:5432/my_app_dev
mix squirrelix.gen --infer

Hosted or SSL databases:

export DATABASE_URL=postgres://user:pass@host:5432/database?sslmode=require
mix squirrelix.gen --infer

If your dev.exs hardcodes Repo hostname and database without DATABASE_URL, export matching PG* variables (or a URL) before generating — see Configuration.

Prefer secrets in the environment (DATABASE_URL / PGPASSWORD) over --password or passwords embedded in shell history.

Migrate, then generate or check

--infer prepares each .sql file against a live database. Tables and columns must already exist, or you get MissingPostgresTable / MissingPostgresColumn.

Recommended local loop:

mix ecto.migrate
mix squirrelix.gen --infer

Then commit both the .sql sources and the generated sql.ex files.

Check without writing:

mix ecto.migrate
mix squirrelix.check --infer

Wire migrate-then-gen/check into mix.exs so the schema is always applied first:

defp aliases do
  [
    setup: ["deps.get", "ecto.setup", "sql.gen"],
    "ecto.setup": ["ecto.create", "ecto.migrate"],
    "ecto.reset": ["ecto.drop", "ecto.setup"],
    test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
    "sql.gen": ["ecto.migrate", "squirrelix.gen --infer"],
    "sql.check": ["ecto.migrate", "squirrelix.check --infer"]
  ]
end

Usage:

mix sql.gen      # migrate + regenerate sql.ex
mix sql.check    # migrate + verify sql.ex is current

Ensure DATABASE_URL or PG* is set in the shell (or CI env:) so --infer reaches the same database ecto.migrate just updated.

Calling generated modules

Generated functions take a Postgrex.conn() as the first argument — a pool pid, named process, or other value accepted by Postgrex.query!/3 / Postgrex.query/3. They do not take an Ecto.Repo module.

alias MyApp.Accounts.SQL

# Raising API
rows = SQL.find_user(conn, 42)
# => [%{id: 42, name: "Ada", email: "ada@example.com"}]

# Soft companion — {:ok, result} | {:error, Exception.t()}
{:ok, rows} = SQL.find_user_ok(conn, 42)
{:ok, 1} = SQL.delete_user_ok(conn, 42)

Soft companions (<name>_ok/arity) are additive: use them when you want ok/error tuples (for example in contexts that pattern-match on failure) without changing the raising API.

A common Phoenix setup is a supervised Postgrex pool configured from the same URL / credentials as MyApp.Repo, then pass that pool into generated functions. Sharing one Ecto transaction with Squirrelix queries is out of scope — there is no first-class Repo integration (see below).

Coexistence with Ecto (intentional)

ConcernTool
Migrations, schema modules, changesetsEcto
Typed, file-based SQL queriesSquirrelix (sql/sql.ex)
Runtime execution of generated queriesPostgrex (Postgrex.conn())

This split is intentional:

  • Squirrelix embraces plain .sql files and Postgrex — the same model as Gleam Squirrel.
  • First-class Ecto Repo integration / query macros are an explicit non-goal (see ROADMAP).
  • You can use both in one app: Ecto for writes and schema evolution, Squirrelix for read-heavy or carefully reviewed SQL that benefits from typed codegen.

Do not expect generated modules to accept MyApp.Repo, participate in Repo.transaction/2 automatically, or replace Ecto.Query. If you need that integration layer, build a thin wrapper in your app — it is not part of Squirrelix.

Dialyzer and generated modules

Generated sql.ex modules are meant to be Dialyzer-friendly under typical app flags (:underspecs, :error_handling, :unknown, :unmatched_returns). Public @specs are precise for call sites; shared decode helpers omit underspec'd contracts. Enabling :overspecs / :specdiffs may warn about intentional precision (row types vs Map.new success typing) — see Types → Dialyzer.

Squirrelix does not depend on Dialyxir. Add Dialyxir in your Phoenix app if you want Dialyzer in CI:

{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}

CI with mix squirrelix.check

Commit generated sql.ex files. In CI, apply migrations, then fail the job when SQL and generated Elixir drift apart.

Copy-pasteable workflows live in examples/github-actions/ (Postgres + --infer, and an offline metadata variant). Drop squirrelix-check.yml into your app’s .github/workflows/ or merge the job into an existing pipeline.

Example GitHub Actions job fragment (Postgres service + Mix):

services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_app_test
    ports:
      - 5432:5432
    options: >-
      --health-cmd "pg_isready -U postgres"
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5

env:
  MIX_ENV: test
  DATABASE_URL: postgres://postgres:postgres@localhost:5432/my_app_test

steps:
  - uses: actions/checkout@v4
  # setup-beam, mix deps.get, compile, … as usual

  - name: Create and migrate database
    run: |
      mix ecto.create --quiet
      mix ecto.migrate

  - name: Check Squirrelix generated modules
    run: mix squirrelix.check --infer

Notes:

  • MIX_ENV=test requires squirr_elix in the :test dependency list.
  • Prefer DATABASE_URL (or the sql.check alias) so connection settings stay consistent with hosted Phoenix configs.
  • Generation is project-wide atomic: any query error or write-prepare failure refuses the whole generate; check fails globally on errors or drift. See Configuration.
  • Prefer live --infer when CI can run Postgres. Prefer a committed metadata file from --write-metadata when jobs cannot reach the database — see Configuration and squirrelix-check-offline.yml.

A shorter variant when aliases are defined:

- run: mix ecto.create --quiet
- run: mix sql.check

Troubleshooting

SymptomLikely causeFix
MissingPostgresTable / columnMigrations not appliedmix ecto.migrate before --infer
CannotConnectToPostgresWrong host/ creds / SSLAlign DATABASE_URL / PG* with Repo; see Configuration
OutdatedFile in CIForgot to regenerateRun mix sql.gen (or mix squirrelix.gen --infer) and commit sql.ex
Check passes locally, fails in CIDifferent database / envUse the same URL shape; create + migrate in CI before check

Next steps