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
Plugininstances 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
pgcraftCLI.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:
Creates a view in the
apischema (configurable) thatSELECT *s from the backing table.Registers an
APIResourceon the metadata so that pgcraft can generate role and grant statements.Creates INSTEAD OF
INSERT/UPDATE/DELETEtriggers 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/DELETEthrough the view are forwarded to the backing table.An
anonrole withSELECTgrants onapi.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¶
PGCraftViewcreates a standalone view from any SQLAlchemyselect()expression and exposes.tablefor use in further joins.PostgRESTViewwithquery=uses the lambda to customise the view definition. Grants drive which INSTEAD OF triggers are created.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.