Extension system

While Plugin architecture compose behaviour within a single factory, extensions sit one level above: they bundle plugins, metadata hooks, Alembic hooks, and CLI commands into a single installable unit.

Extensions make it possible for third-party packages to extend pgcraft, and for pgcraft’s own opt-in subsystems (PostgREST, future auth/RLS) to be cleanly separated from the core.

Quick start

Register an extension on your PGCraftConfig:

from pgcraft.config import PGCraftConfig
from pgcraft.extensions.postgrest import (
    PostgRESTExtension,
)

config = PGCraftConfig()
config.use(PostgRESTExtension())

Pass this config to your factories and to pgcraft_configure_metadata():

from pgcraft.alembic.register import (
    pgcraft_configure_metadata,
)

PGCraftSimple(
    "users", "public", metadata, ...,
    config=config,
)
pgcraft_configure_metadata(metadata, config)

Extension hooks

PGCraftExtension provides five hooks. Override only the ones you need — every hook is a no-op by default.

plugins()

Return a list of Plugin instances that are prepended to every factory’s plugin list.

configure_metadata(metadata)

Register roles, grants, schemas, or other metadata-level objects. Called by pgcraft_configure_metadata().

configure_alembic()

Register custom Alembic renderers or rewriters. Called by pgcraft_alembic_hook().

register_cli(app)

Add subcommands to the pgcraft CLI.

validate(registered_names)

Check that required peer extensions are present. Called after all extensions are loaded.

Inter-extension dependencies

Declare dependencies using the depends_on class variable:

from dataclasses import dataclass
from typing import ClassVar

from pgcraft.extension import PGCraftExtension

@dataclass
class MyExtension(PGCraftExtension):
    name: str = "my-ext"
    depends_on: ClassVar[list[str]] = ["postgrest"]

pgcraft validates that all declared dependencies are present when extensions are resolved. A PGCraftValidationError is raised if any are missing.

Entry point discovery

Third-party packages can register extensions via the pgcraft.extensions entry point group in pyproject.toml:

[project.entry-points."pgcraft.extensions"]
nanoid = "pgcraft_nanoid:NanoIDExtension"

Discovered extensions are automatically loaded unless auto_discover=False is set on the config. Manually registered extensions take precedence over discovered ones with the same name.

Writing an extension

Here are three example tiers, from simple to complex.

Column-level extension (NanoID PK)

An extension that contributes a single plugin to replace the default serial PK with a NanoID:

from dataclasses import dataclass

from pgcraft.extension import PGCraftExtension
from pgcraft.plugin import Plugin


class NanoIDPKPlugin(Plugin):
    """Replace serial PK with a NanoID column."""
    # ... plugin implementation ...


@dataclass
class NanoIDExtension(PGCraftExtension):
    name: str = "nanoid"

    def plugins(self) -> list[Plugin]:
        return [NanoIDPKPlugin()]

Composite extension (audit trail)

An extension that bundles multiple plugins — a shadow table and a trigger that writes to it:

from dataclasses import dataclass

from pgcraft.extension import PGCraftExtension
from pgcraft.plugin import Plugin


@dataclass
class AuditExtension(PGCraftExtension):
    name: str = "audit"

    def plugins(self) -> list[Plugin]:
        return [
            ShadowTablePlugin(),
            ShadowTriggerPlugin(),
        ]

Full subsystem extension (PostgREST)

The built-in PostgREST extension demonstrates a full subsystem: metadata hooks for roles/grants.

from dataclasses import dataclass
from typing import TYPE_CHECKING

from pgcraft.extension import PGCraftExtension

if TYPE_CHECKING:
    from sqlalchemy import MetaData

@dataclass
class PostgRESTExtension(PGCraftExtension):
    name: str = "postgrest"
    schema: str = "api"

    def configure_metadata(
        self, metadata: MetaData,
    ) -> None:
        from pgcraft.models.roles import (
            register_roles,
        )
        register_roles(metadata)

PostgREST extension

pgcraft can generate PostgREST-compatible API views, INSTEAD OF triggers for write operations, and the role/grant declarations that PostgREST expects. Enable the PostgRESTExtension on your config, create a factory with that config, then call PostgRESTView to expose it.

How it works

When PostgRESTView is called it:

  1. Creates a view in the api schema (configurable) that SELECT * s from the backing table.

  2. Registers an APIResource on the metadata so that pgcraft can generate role and grant statements.

  3. Creates INSTEAD OF INSERT / UPDATE / DELETE triggers on the view so PostgREST clients can write through it.

Minimal example

# models.py
from sqlalchemy import (
    Column,
    Integer,
    MetaData,
    Numeric,
    String,
)

from pgcraft.config import PGCraftConfig
from pgcraft.extensions.postgrest import (
    PostgRESTExtension,
    PostgRESTView,
)
from pgcraft.factory import PGCraftSimple
from pgcraft import pgcraft_build_naming_conventions

# Enable PostgREST roles and grants
config = PGCraftConfig()
config.use(PostgRESTExtension())

metadata = MetaData(
    naming_convention=pgcraft_build_naming_conventions(),
)

products = PGCraftSimple(
    tablename="products",
    schemaname="inventory",
    metadata=metadata,
    config=config,
    schema_items=[
        Column("name", String, nullable=False),
        Column("sku", String(32), nullable=False),
        Column(
            "price",
            Numeric(10, 2),
            nullable=False,
        ),
    ],
)

PostgRESTView(source=products)

Remember to pass config to pgcraft_configure_metadata(metadata, config) in your env.py — see Setting up a new project for the full Alembic wiring.

This creates:

  • inventory.products — the backing table with a serial PK.

  • api.products — a view for PostgREST to expose.

  • INSTEAD OF triggers so INSERT / UPDATE / DELETE through the view are forwarded to the backing table.

  • An anon role with SELECT grants on api.products.

Customising grants

By default the anon role gets only SELECT. Pass a grants list to PostgRESTView to allow writes:

# config setup as above
PostgRESTView(
    source=products,
    grants=[
        "select", "insert", "update", "delete",
    ],
)

Grants drive triggers: INSTEAD OF triggers are only created for the DML operations listed in grants. A ["select"]-only view has no triggers and is read-only. A view with ["select", "insert"] gets only an INSERT trigger — no UPDATE or DELETE.

Changing the API schema

The default API schema is api. Override it with the schema parameter:

# config setup as above
PostgRESTView(
    source=products, schema="reporting",
)

PostgREST setup

After generating and applying migrations, point PostgREST at your database. A minimal postgrest.conf:

db-uri = "postgresql://authenticator:changeme@localhost/mydb"
db-schemas = "api"
db-anon-role = "anon"
db-extra-search-path = "public, inventory"

Start the server and query the API:

postgrest postgrest.conf

# List all products
curl -s http://localhost:3000/products \
    | python3 -m json.tool

# Filter
curl -s \
    "http://localhost:3000/products?name=eq.Widget"

# Insert (requires insert grant)
curl -s http://localhost:3000/products \
    -H "Content-Type: application/json" \
    -d '{"name": "Widget", "sku": "W-001", "price": 9.99}'

See the PostgREST documentation for the full query syntax and configuration reference.

Exposing a subset of columns

By default PostgRESTView creates a SELECT * view. Pass a columns list to expose only specific columns through the API — useful when a table has internal columns that should not be visible to API consumers.

from sqlalchemy import (
    Column, MetaData, Numeric, String, Text,
)

from pgcraft.config import PGCraftConfig
from pgcraft.extensions.postgrest import (
    PostgRESTExtension,
    PostgRESTView,
)
from pgcraft.factory import PGCraftSimple
from pgcraft import pgcraft_build_naming_conventions

config = PGCraftConfig()
config.use(PostgRESTExtension())

metadata = MetaData(
    naming_convention=pgcraft_build_naming_conventions(),
)

products = PGCraftSimple(
    tablename="products",
    schemaname="inventory",
    metadata=metadata,
    config=config,
    schema_items=[
        Column("name", String, nullable=False),
        Column("sku", String(32), nullable=False),
        Column(
            "price",
            Numeric(10, 2),
            nullable=False,
        ),
        Column("internal_notes", Text),  # hidden
    ],
)

PostgRESTView(
    source=products,
    columns=["id", "name", "sku", "price"],
)

The generated view selects only the listed columns:

CREATE VIEW api.products AS
SELECT p.id, p.name, p.sku, p.price
FROM inventory.products AS p

The internal_notes column exists in the backing table but is invisible through the PostgREST API. Any column name not found on the table raises ValueError at factory construction time.

Alternatively, use exclude_columns to hide specific columns while including everything else — often more convenient for large tables:

PostgRESTView(
    source=products,
    exclude_columns=["internal_notes"],
)

Joining aggregate views into the API

Create standalone aggregate views with PGCraftView, then join them into an API view using the query= parameter on PostgRESTView.

Each PGCraftView exposes a .table property — a joinable SQLAlchemy selectable — so you can compose joins using standard SQLAlchemy syntax.

Multiple statistics on one dimension

A realistic customer dimension with both order and invoice statistics:

from sqlalchemy import (
    Column,
    Integer,
    MetaData,
    Numeric,
    String,
    func,
    select,
)

from pgcraft.config import PGCraftConfig
from pgcraft.extensions.postgrest import (
    PostgRESTExtension,
    PostgRESTView,
)
from pgcraft.factory import PGCraftSimple
from pgcraft.views.view import PGCraftView
from pgcraft import (
    pgcraft_build_naming_conventions,
)

config = PGCraftConfig()
config.use(PostgRESTExtension())

metadata = MetaData(
    naming_convention=(
        pgcraft_build_naming_conventions()
    ),
)

# -- Table factories ----------------------------

Orders = PGCraftSimple(
    "orders", "public", metadata,
    config=config,
    schema_items=[
        Column(
            "customer_id",
            Integer,
            nullable=False,
        ),
        Column(
            "total",
            Numeric(10, 2),
            nullable=False,
        ),
    ],
)

customers = PGCraftSimple(
    "customers", "public", metadata,
    config=config,
    schema_items=[
        Column("name", String, nullable=False),
        Column("email", String),
    ],
)

# -- Standalone aggregate views -----------------

_orders_t = Orders.table
order_stats = PGCraftView(
    "customer_order_stats", "public", metadata,
    query=select(
        _orders_t.c.customer_id,
        func.count().label("order_count"),
        func.sum(_orders_t.c.total).label(
            "order_total",
        ),
    ).group_by(_orders_t.c.customer_id),
)

# -- API view with joined statistics ------------
# PGCraftView.table is a joinable SQLAlchemy Table.
# Triggers still work -- they operate on the base
# table columns; joined columns are read-only.

_os = order_stats.table

PostgRESTView(
    source=customers,
    grants=[
        "select", "insert", "update", "delete",
    ],
    query=lambda q, t: (
        select(
            t.c.id,
            t.c.name,
            t.c.email,
            _os.c.order_count,
            _os.c.order_total,
        )
        .select_from(t)
        .outerjoin(
            _os, t.c.id == _os.c.customer_id
        )
    ),
)

This creates:

  • public.customers — the backing table (id, name, email).

  • public.customer_order_stats — a standalone aggregate view.

  • api.customers — the API view with a LEFT JOIN to the statistics view.

The query= lambda receives the base SELECT * query and the source table. Return any valid SQLAlchemy Select — add joins, filter columns, or transform freely. INSTEAD OF triggers are still created for the base table columns; joined columns are read-only.

How it works

  1. PGCraftView creates a standalone view from any SQLAlchemy select() expression and exposes .table for use in further joins.

  2. PostgRESTView with query= uses the lambda to customise the view definition. Grants drive which INSTEAD OF triggers are created.

  3. Writable columns are automatically restricted to the base table’s dimension columns — joined columns cannot be written through the API view.

Computed columns

PostgreSQL computed columns (Computed) are derived from other columns in the same row. PostgreSQL evaluates them automatically — they appear in the API view like any other column but cannot be written to.

from sqlalchemy import (
    Column,
    Computed,
    Integer,
    MetaData,
    String,
)

from pgcraft.config import PGCraftConfig
from pgcraft.extensions.postgrest import (
    PostgRESTExtension,
    PostgRESTView,
)
from pgcraft.factory import PGCraftSimple
from pgcraft import pgcraft_build_naming_conventions

config = PGCraftConfig()
config.use(PostgRESTExtension())

metadata = MetaData(
    naming_convention=(
        pgcraft_build_naming_conventions()
    ),
)

products = PGCraftSimple(
    "products", "inventory", metadata,
    config=config,
    schema_items=[
        Column("name", String, nullable=False),
        Column("price", Integer, nullable=False),
        Column("qty", Integer, nullable=False),
        Column(
            "total", Integer,
            Computed("price * qty"),
        ),
    ],
)

PostgRESTView(
    source=products,
    grants=[
        "select", "insert", "update", "delete",
    ],
)

The total column is a generated column — it appears in the API but is computed by PostgreSQL, not writable through the API.