Immigrant Schema Language

Quick Start

Immigrant schema describes your database in a single file. Instead of writing raw DDL, you declare types, tables, and constraints. Immigrant figures out what SQL to generate, and what migrations to run when you change things.

Here is a minimal schema:

scalar user_id = sql"INTEGER";

enum role {
    admin;
    member;
};

table User {
    user_id @primary_key;
    role;
    name: sql"TEXT";
};

This produces a PostgreSQL domain user_id, an enum type role, and a table with three columns. Notice that user_id and role columns don’t need explicit types — immigrant resolves them by name.

Core Concepts

Naming

Every item has a code name (used in schema references) and an optional database name (actual SQL identifier).

table UserProfile "user_profiles" {};
scalar user_id "uid" = sql"INTEGER";
enum status "account_status" { active; inactive; };

If database name is omitted, naming convention preprocessor transforms code names automatically (e.g. PostgreSQL convention turns UserProfile into user_profiles).

Renaming the database name in schema triggers ALTER …​ RENAME in migration. Immigrant tracks identity by code name.

Comments

// comments before items become SQL COMMENT ON statements (see PostgreSQL). /// comments produce no SQL output.

// Doc comment, becomes COMMENT ON
/// Ignored by parser, for schema authors only

Items

Scalars

Scalars define reusable column types. Non-inlined scalars become PostgreSQL DOMAINs with constraints baked in:

scalar user_id = sql"INTEGER";
scalar positive_int = sql"INTEGER" @check(_ > 0);
scalar created_at = sql"TIMESTAMPTZ" @default(NOW());

@check / @default

CHECK constraints and defaults on the domain. _ is placeholder for the value. Both use the expression language, not raw SQL:

scalar positive_int = sql"INTEGER" @check(_ > 0);
scalar bounded = sql"INTEGER" @check "named" (_ > 0 && _ < 1000);
scalar created_at = sql"TIMESTAMPTZ" @default(NOW());

@primary_key / @unique / @index

Propagate to every column using this scalar:

scalar user_id = sql"INTEGER" @index;
scalar email = sql"TEXT" @unique;

Every column of type user_id gets an index, every email column gets a unique constraint.

@inline

Skips domain creation. Raw SQL type and constraints expand at point of use:

scalar non_zero_int = sql"INTEGER" @check(_ != 0) @inline;

Columns using non_zero_int get type INTEGER with CHECK directly on the table. No domain.

@external

Prevents immigrant from generating CREATE/DROP DOMAIN. For types managed outside immigrant:

scalar existing_type = sql"TEXT" @external;

Propagation

Scalar attributes propagate to columns using the scalar:

  • @primary_key, @unique, @index — always propagate, regardless of inline.

  • @default, @check — propagate only when scalar is @inline. Otherwise stay on the domain.

Enums

Enums map to PostgreSQL enum types.

enum user_group {
    admin;
    member;
    guest;
};

Variants can have custom SQL values (useful for renaming without migration pain):

enum user_group {
    // Stored as 'superuser' in database, referenced as 'admin' in schema
    admin "superuser";
    member;
};

Adding a variant appends it with ALTER TYPE …​ ADD VALUE. Removing a variant recreates the type (PostgreSQL limitation).

Structs

Structs become PostgreSQL composite types. When used as a column type, immigrant automatically generates nullability checks for their fields.

struct address {
    street: sql"TEXT";
    city: sql"TEXT";
    zip: sql"TEXT" @check(char_length(_) == 5);
};

table Customer {
    id: sql"INTEGER" @primary_key;
    /// column type is 'address' composite
    address;
};

Struct-level checks can reference fields via _.fieldname:

struct range {
    lo: sql"INTEGER";
    hi: sql"INTEGER";
    @check "lo_le_hi" (_.lo <= _.hi);
};

Annotations

Generic annotations (#…​) attach metadata for code generators. They do not affect SQL output, but are preserved for tools like diesel code generation:

#diesel(type="dt::Text",copy,custom)
scalar user_id = sql"TEXT";

#diesel(derive = "async_graphql::Enum")
enum client_kind { ... };

Annotations can be placed before any item (scalar, enum, struct, table) or before individual columns and enum variants.

Full Example

scalar user_id = sql"TEXT" @check(LEFT(_, 1) == 'U' && char_length(_) == 33);
scalar client_id = sql"TEXT" @check(LEFT(_, 1) == 'C' && char_length(_) == 33);
scalar scope_id = sql"TEXT" @check(!(_ ~~ '% %'));
scalar created_at = sql"TIMESTAMPTZ" @default(NOW());
scalar updated_at = sql"TIMESTAMPTZ" @default(NOW());
scalar expires_in = sql"INTEGER" @check(_ > 0);
scalar name = sql"TEXT";
scalar bool = sql"BOOLEAN" @inline;

enum challenge_kind {
    password;
    gpg;
    totp;
};

enum client_kind {
    confidential;
    public;
};

struct pkce_challenge_data {
    method: sql"TEXT";
    challenge: sql"TEXT";
};

@mixin Timestamps {
    created_at;
    updated_at;
};

table User {
    user_id @primary_key;
    created_at;
};

table Profile {
    // Profile data for a user
    user_id @primary_key ~.cascade User;
    name?;
    picture: sql"TEXT"?;
    updated_at;
};

table UserChallenge {
    user_id ~.cascade User;
    challenge_kind;
    check_data: sql"BYTEA";
    search_key: sql"BYTEA"?;

    @primary_key(user_id, challenge_kind, check_data);
    @unique(challenge_kind, search_key);
};

table Client {
    client_id @primary_key;
    name;
    client_kind;
    uses_client_secret: bool;
    uses_pkce: bool;
    created_at;
};

table ClientRedirectUrl {
    client_id @index ~.cascade Client;
    redirect_url: sql"TEXT";
    public: bool @initialize_as(false);

    @primary_key(client_id, redirect_url);
};

table Audience {
    scope_id @primary_key(audience_id);
    audience_id: sql"TEXT" @index;
    name;
};

table AudienceScope {
    audience_id ~.cascade Audience;
    scope_id;
    @primary_key(audience_id, scope_id);
};

table ClientAllowedScope {
    client_id ~.cascade Client;
    audience_id ~.cascade Audience;
    scope_id;

    @primary_key(client_id, audience_id, scope_id);
    (audience_id, scope_id) ~.cascade AudienceScope;
};

view ActiveUsers = sql"""
    SELECT * FROM {User}
    JOIN {Profile} ON {Profile.user_id} = {User.user_id}
    WHERE {Profile.name} IS NOT NULL
""";