paradox-ecto

Generate Ecto schemas and database migrations from Paradox specifications.

Define your types and URI endpoints in .dox, and paradox-ecto generates Ecto schema modules with changesets, JSON serialization, and Dox validation bridging — plus database migrations that evolve incrementally as your spec changes.

Usage

paradox-ecto --title "My App" --uris createUser --uris listUsers

All flags:

Flag Default Description

--title

(required)

The name of your app (used as the Elixir project name)

--uris

(repeatable, required)

URI constant name from the .dox specification (at least one required)

--migrations-dir

(optional)

Persistent directory for incremental migration files (requires --baseline)

--baseline

(optional)

Path to baseline spec JSON for migration diffing (requires --migrations-dir)

Example

Given this .dox specification:

import std

type Pet
  name: Text
  species: Text
  age: Integer?

createPet: URI
  http://localhost/pets POST (Pet. Pet)

listPets: URI
  http://localhost/pets GET (Unit. [Pet])

Run:

paradox-ecto --title "PetShop" --uris createPet --uris listPets

This generates an Ecto schema and migration in .dox/:

Generated Schema

defmodule Dox.Schemas.Pet do
  use Ecto.Schema
  import Ecto.Changeset

  schema "pet" do
    field :name, :string
    field :species, :string
    field :age, :integer, default: nil
    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:name, :species, :age])
    |> validate_required([:name, :species])
    |> validate_with_dox()
  end

  def to_json(struct) do
    %{
      "id" => struct.id,
      "name" => struct.name,
      "species" => struct.species,
      "age" => struct.age,
    }
  end

  def from_params(params) do
    %__MODULE__{}
    |> changeset(params)
  end

  defp validate_with_dox(changeset), do: changeset
end

Generated Migration

defmodule Dox.Repo.Migrations.CreateTables do
  use Ecto.Migration

  def change do
    create table(:"pet") do
      add :name, :text, null: false
      add :species, :text, null: false
      add :age, :integer
      timestamps()
    end
  end
end

How It Works

paradox-ecto operates in four stages:

  1. Parse specification — Reads specification.json produced by paradox generate --spec

  2. Scan Dox modules — Finds valid_<type>/1 validator functions in generated Dox Elixir modules

  3. Resolve URIs — Looks up each --uris name in the spec, extracts input/output types

  4. Generate code — Produces Ecto schemas and migrations for all types referenced in URI io fields

Generated Output

Schemas and migrations are generated into .dox/ as library code:

.dox/
  lib/
    dox/
      schemas/
        <type>.ex           # Ecto schema module per URI-referenced type
  priv/
    repo/
      migrations/
        20240101000000_create_tables.exs   # Database migration

The downstream Mix project includes .dox/lib in its elixirc_paths:

# mix.exs
defp elixirc_paths(_env), do: ["lib", ".dox/lib"]

Type Mapping

Paradox types map to Ecto column types in migrations and schema field types:

Paradox Type Migration Column Schema Field

Text

:text

:string

Integer

:integer

:integer

Boolean

:boolean

:boolean

Double

:float

:float

Key OtherType

references(:other_type)

:integer (with source:)

[Text] (Array)

{:array, :text}

{:array, :string}

Optional (?)

Column without null: false

Field with default: nil

Union types

:string

:string

Wrap types

Resolves to inner type

Resolves to inner type

Foreign Keys

Fields typed as Key OtherType in .dox become foreign key references in migrations and integer fields with source: mapping in schemas:

type Post
  title: Text
  author: Key User

Migration:

add :author, references(:user), null: false

Schema:

field :author_id, :integer, source: :author

Dox Validation Bridging

When a .dox specification defines valid rules for a type, paradox generate --elixir emits a valid_<type>/1 function. paradox-ecto detects these and bridges them into Ecto changesets:

  1. After standard Ecto validations pass, changeset fields are mapped back to a Dox struct

  2. The valid_<type>/1 function is called

  3. Errors are parsed and added to the changeset as field-specific errors

valid Pet
  age > 0 | Age must be positive

This produces a schema where changeset/2 calls Dox.Pets.valid_pet/1 after standard validations.

Incremental Migrations

By default, paradox-ecto generates a single create_tables migration. For production use, pass --migrations-dir and --baseline to enable incremental migration generation that preserves existing data when the spec evolves.

Setup

paradox-ecto --title "My App" \
  --migrations-dir priv/repo/migrations \
  --baseline priv/repo/baseline_spec.json \
  --uris createUser --uris listUsers

On the first run, this generates:

  • priv/repo/migrations/20240101000000_create_tables.exs — initial migration

  • priv/repo/baseline_spec.json — snapshot of the current schema

Both files should be committed to version control.

Schema Evolution

When types.dox changes and paradox-ecto runs again, it diffs the current spec against the baseline and generates a new timestamped ALTER migration:

defmodule Dox.Repo.Migrations.Alter20260326120000 do
  use Ecto.Migration

  def change do
    alter table(:"pet") do
      add :color, :text, null: false
      remove :species, :text, null: false
    end
  end
end

The baseline is updated after each successful generation. All generated migrations are reversible — remove includes the column type for rollback, and modify includes from: for the original type.

Supported Operations

Change Generated Migration

New type added

create table(…​) with all columns

Type removed

drop table(…​)

Field added

alter table with add :col, :type

Field removed

alter table with remove :col, :type (reversible)

Field type changed

alter table with modify :col, :new_type, from: {:old_type}

Nullability changed

alter table with modify :col, :type, null: new, from: {:type, null: old}

FK target changed

remove old FK + add new FK (cannot modify references)

How Diffing Works

The diff pipeline is three pure functions:

  1. Normalize — Convert parsed spec types into a table map of %{table_name ⇒ [%{name, type, nullable, reference}]}

  2. Diff — Compare old and new table maps, producing a list of migration operations

  3. Render — Convert operations into valid Ecto migration source code

The baseline stores the normalized table map as JSON, not the raw spec. This decouples the diff from spec format changes.

Downstream Configuration

Update your Ecto repo config to point at the persistent migrations directory:

# config/dev.exs
config :my_app, MyApp.Repo,
  priv: "priv/repo"

See Also