Built-in dimensions

pgcraft ships with three dimension types, each backed by a different storage strategy. All three share the same plugin-driven pipeline and expose a unified PostgREST API view.

Choose the type that matches your data:

  • Simple – one table, direct CRUD. Best for reference data.

  • Append-Only (SCD Type 2) – full change history via an append-only log. Best for slowly changing dimensions.

  • EAV – sparse attributes stored as rows and pivoted back to columns. Best for highly dynamic or optional fields.

All dimension types support declarative PGCraftCheck, PGCraftIndex, and PGCraftFK items in schema_items. See Constraints and indices for a full walkthrough with generated SQL.

Simple dimension

A single backing table with a corresponding API view. Suitable for reference data and simple lookups that don’t need change history.

Example configuration:

users = PGCraftSimple(
    tablename="users",
    schemaname="public",
    metadata=metadata,
    schema_items=[
        Column("name", String, nullable=False),
        Column("email", String),
    ],
)

PostgRESTView(source=users)

Usage – all operations go through the API view:

-- All operations go through the API view.
-- The INSTEAD OF triggers route them to the backing table.

INSERT INTO api.users (name, email)
VALUES ('Alice', 'alice@example.com');

INSERT INTO api.users (name, email)
VALUES ('Bob', 'bob@example.com');

UPDATE api.users
SET email = 'alice@newdomain.com'
WHERE id = 1;

DELETE FROM api.users
WHERE id = 2;

Schema

A single backing table (public.users) with a thin api.users view on top.

digraph { rankdir=LR; node [shape=plaintext fontname="Helvetica" fontsize=11]; edge [fontname="Helvetica" fontsize=9]; public_users [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightblue"><b>public.users</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">name</td><td align="left">STRING <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">email</td><td align="left">STRING</td></tr> </table> >]; api_users [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightyellow"><b>api.users (view)</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">name</td><td align="left">VARCHAR <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">email</td><td align="left">VARCHAR</td></tr> </table> >]; api_users -> public_users [style=dashed label="SELECT *"]; }

=# \d public.users
                                 Table "public.users"
 Column |       Type        | Collation | Nullable |              Default
--------+-------------------+-----------+----------+-----------------------------------
 id     | integer           |           | not null | nextval('users_id_seq'::regclass)
 name   | character varying |           | not null |
 email  | character varying |           |          |
Indexes:
    "pk__users__id" PRIMARY KEY, btree (id)

Sample queries

=# SELECT * FROM public.users;
 id | name  |       email
----+-------+-------------------
  1 | Alice | alice@example.com
  2 | Bob   | bob@example.com
(2 rows)

The API view exposes the same columns.

=# SELECT * FROM api.users;
 id | name  |       email
----+-------+-------------------
  1 | Alice | alice@example.com
  2 | Bob   | bob@example.com
(2 rows)

Append-only dimension (SCD Type 2)

Tracks full change history using an append-only attributes log. Every update creates a new row in the attributes table; the root table points to the latest version. A join view presents the current state. Ideal for slowly changing dimensions where audit trails matter.

Example configuration:

employees = PGCraftAppendOnly(
    tablename="employees",
    schemaname="private",
    metadata=metadata,
    schema_items=[
        Column("name", String, nullable=False),
        Column("department", String),
    ],
)

PostgRESTView(source=employees)

Usage – inserts and updates go through the API view; the triggers manage the internal tables:

-- All operations go through the API view.
-- The triggers manage the root and attributes tables for you.

INSERT INTO api.employees (name, department)
VALUES ('Alice', 'Engineering');

INSERT INTO api.employees (name, department)
VALUES ('Bob', 'Marketing');

-- Alice moves to Management.  This appends a new row to the
-- attributes table rather than updating in place.
UPDATE api.employees
SET department = 'Management'
WHERE id = 1;

Schema

An append-only attributes table (private.employees_attributes) stores every version of each row. A root table (private.employees_root) points to the current version. Two views join these into a flat shape: private.employees and api.employees.

digraph { rankdir=LR; node [shape=plaintext fontname="Helvetica" fontsize=11]; edge [fontname="Helvetica" fontsize=9]; private_employees_attributes [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightblue"><b>private.employees_attributes</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">name</td><td align="left">STRING <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">department</td><td align="left">STRING</td></tr> </table> >]; private_employees_root [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightblue"><b>private.employees_root</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">employees_attributes_id</td><td align="left">INTEGER <i><font color="gray40">FK</font></i></td></tr> </table> >]; private_employees [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightyellow"><b>private.employees (view)</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">updated_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">name</td><td align="left">VARCHAR <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">department</td><td align="left">VARCHAR</td></tr> </table> >]; api_employees [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightyellow"><b>api.employees (view)</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">updated_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">name</td><td align="left">VARCHAR <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">department</td><td align="left">VARCHAR</td></tr> </table> >]; private_employees_root -> private_employees_attributes; private_employees -> private_employees_root [style=dashed]; private_employees -> private_employees_attributes [style=dashed]; api_employees -> private_employees [style=dashed label="SELECT *"]; }

=# \d private.employees_attributes
                                           Table "private.employees_attributes"
   Column   |           Type           | Collation | Nullable |                          Default
------------+--------------------------+-----------+----------+-----------------------------------------------------------
 id         | integer                  |           | not null | nextval('private.employees_attributes_id_seq'::regclass)
 created_at | timestamp with time zone |           |          | '2026-03-15 23:17:26.081837+00'::timestamp with time zone
 name       | character varying        |           | not null |
 department | character varying        |           |          |
Indexes:
    "pk__employees_attributes__id" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "private.employees_root" CONSTRAINT "fk__employees_root__employees_attributes_id__employees_dee9c74f" FOREIGN KEY (employees_attributes_id) REFERENCES private.employees_attributes(id)
=# \d private.employees_root
                                                    Table "private.employees_root"
         Column          |           Type           | Collation | Nullable |                          Default
-------------------------+--------------------------+-----------+----------+-----------------------------------------------------------
 id                      | integer                  |           | not null | nextval('private.employees_root_id_seq'::regclass)
 created_at              | timestamp with time zone |           |          | '2026-03-15 23:17:26.081837+00'::timestamp with time zone
 employees_attributes_id | integer                  |           |          |
Indexes:
    "pk__employees_root__id" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "fk__employees_root__employees_attributes_id__employees_dee9c74f" FOREIGN KEY (employees_attributes_id) REFERENCES private.employees_attributes(id)

Sample queries

Each change appends a new row. Row 3 records Alice’s department change.

=# SELECT * FROM private.employees_attributes ORDER BY id;
 id |          created_at           | name  | department
----+-------------------------------+-------+-------------
  1 | 2026-03-15 23:17:26.081837+00 | Alice | Engineering
  2 | 2026-03-15 23:17:26.081837+00 | Bob   | Marketing
  3 | 2026-03-15 23:17:26.081837+00 | Alice | Management
(3 rows)

Points to the latest attribute row via the foreign key.

=# SELECT * FROM private.employees_root;
 id |          created_at           | employees_attributes_id
----+-------------------------------+-------------------------
  1 | 2026-03-15 23:17:26.081837+00 |                       3
  2 | 2026-03-15 23:17:26.081837+00 |                       2
(2 rows)

Joins root with the pointed-to attribute row, showing the latest values.

=# SELECT * FROM api.employees;
 id |          created_at           |          updated_at           | name  | department
----+-------------------------------+-------------------------------+-------+------------
  1 | 2026-03-15 23:17:26.081837+00 | 2026-03-15 23:17:26.081837+00 | Alice | Management
  2 | 2026-03-15 23:17:26.081837+00 | 2026-03-15 23:17:26.081837+00 | Bob   | Marketing
(2 rows)

EAV dimension (Entity-Attribute-Value)

Stores attributes as rows rather than columns, using typed value columns (string_value, integer_value, etc.) with a check constraint enforcing exactly one non-null value per row. A pivot view reconstructs the familiar columnar layout. Ideal for sparse or highly dynamic attributes where most entities only have a subset of possible fields.

Example configuration:

products = PGCraftEAV(
    tablename="products",
    schemaname="private",
    metadata=metadata,
    schema_items=[
        Column("color", String),
        Column("weight", Float),
        Column("is_active", Boolean),
        Column("price", Integer),
    ],
)

PostgRESTView(source=products)

Usage – the API view looks like a normal table; the triggers decompose columns into EAV rows behind the scenes:

-- All operations go through the API view.
-- The triggers decompose each column into attribute rows
-- in the underlying EAV tables.

INSERT INTO api.products (color, weight, is_active, price)
VALUES ('red', 2.5, TRUE, 999);

INSERT INTO api.products (color, weight, is_active, price)
VALUES ('blue', 1.0, TRUE, 499);

-- The pivot view reconstructs columns, so SELECTs look normal:
SELECT * FROM api.products;

Schema

An entity table (private.products_entity) holds one row per logical entity. An attribute table (private.products_attribute) stores each field as a separate row with typed value columns. Two pivot views reconstruct the columnar layout: private.products and api.products.

digraph { rankdir=LR; node [shape=plaintext fontname="Helvetica" fontsize=11]; edge [fontname="Helvetica" fontsize=9]; private_products_attribute [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightblue"><b>private.products_attribute</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">entity_id</td><td align="left">INTEGER <i><font color="gray40">FK NOT NULL</font></i></td></tr> <tr><td align="left">attribute_name</td><td align="left">TEXT <i><font color="gray40">NOT NULL</font></i></td></tr> <tr><td align="left">string_value</td><td align="left">STRING</td></tr> <tr><td align="left">float_value</td><td align="left">FLOAT</td></tr> <tr><td align="left">boolean_value</td><td align="left">BOOLEAN</td></tr> <tr><td align="left">integer_value</td><td align="left">INTEGER</td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> </table> >]; private_products_entity [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightblue"><b>private.products_entity</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> </table> >]; private_products [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightyellow"><b>private.products (view)</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">color</td><td align="left">VARCHAR</td></tr> <tr><td align="left">weight</td><td align="left">FLOAT</td></tr> <tr><td align="left">is_active</td><td align="left">BOOLEAN</td></tr> <tr><td align="left">price</td><td align="left">INTEGER</td></tr> </table> >]; api_products [label=< <table border="1" cellborder="0" cellspacing="0" cellpadding="4"> <tr><td colspan="2" bgcolor="lightyellow"><b>api.products (view)</b></td></tr> <tr><td align="left">id</td><td align="left">INTEGER <i><font color="gray40">PK</font></i></td></tr> <tr><td align="left">created_at</td><td align="left">DATETIME</td></tr> <tr><td align="left">color</td><td align="left">VARCHAR</td></tr> <tr><td align="left">weight</td><td align="left">FLOAT</td></tr> <tr><td align="left">is_active</td><td align="left">BOOLEAN</td></tr> <tr><td align="left">price</td><td align="left">INTEGER</td></tr> </table> >]; private_products_attribute -> private_products_entity; private_products -> private_products_entity [style=dashed]; private_products -> private_products_attribute [style=dashed]; api_products -> private_products [style=dashed label="SELECT *"]; }

=# \d private.products_attribute
                                              Table "private.products_attribute"
     Column     |           Type           | Collation | Nullable |                          Default
----------------+--------------------------+-----------+----------+-----------------------------------------------------------
 id             | integer                  |           | not null | nextval('private.products_attribute_id_seq'::regclass)
 entity_id      | integer                  |           | not null |
 attribute_name | text                     |           | not null |
 string_value   | character varying        |           |          |
 float_value    | double precision         |           |          |
 boolean_value  | boolean                  |           |          |
 integer_value  | integer                  |           |          |
 created_at     | timestamp with time zone |           |          | '2026-03-15 23:17:26.286727+00'::timestamp with time zone
Indexes:
    "pk__products_attribute__id" PRIMARY KEY, btree (id)
Check constraints:
    "products_attribute_one_value_ck" CHECK (num_nonnulls(string_value, float_value, boolean_value, integer_value) = 1)
Foreign-key constraints:
    "fk__products_attribute__entity_id__products_entity" FOREIGN KEY (entity_id) REFERENCES private.products_entity(id) ON DELETE CASCADE
=# \d private.products_entity
                                             Table "private.products_entity"
   Column   |           Type           | Collation | Nullable |                          Default
------------+--------------------------+-----------+----------+-----------------------------------------------------------
 id         | integer                  |           | not null | nextval('private.products_entity_id_seq'::regclass)
 created_at | timestamp with time zone |           |          | '2026-03-15 23:17:26.286727+00'::timestamp with time zone
Indexes:
    "pk__products_entity__id" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "private.products_attribute" CONSTRAINT "fk__products_attribute__entity_id__products_entity" FOREIGN KEY (entity_id) REFERENCES private.products_entity(id) ON DELETE CASCADE

Sample queries

One row per logical entity.

=# SELECT * FROM private.products_entity;
 id |          created_at
----+-------------------------------
  1 | 2026-03-15 23:17:26.286727+00
  2 | 2026-03-15 23:17:26.286727+00
(2 rows)

Each attribute is a separate row. The check constraint ensures exactly one value column is non-null per row.

=# SELECT * FROM private.products_attribute ORDER BY id;
 id | entity_id | attribute_name | string_value | float_value | boolean_value | integer_value |          created_at
----+-----------+----------------+--------------+-------------+---------------+---------------+-------------------------------
  1 |         1 | color          | red          |             |               |               | 2026-03-15 23:17:26.286727+00
  2 |         1 | weight         |              |         2.5 |               |               | 2026-03-15 23:17:26.286727+00
  3 |         1 | is_active      |              |             | t             |               | 2026-03-15 23:17:26.286727+00
  4 |         1 | price          |              |             |               |           999 | 2026-03-15 23:17:26.286727+00
  5 |         2 | color          | blue         |             |               |               | 2026-03-15 23:17:26.286727+00
  6 |         2 | weight         |              |           1 |               |               | 2026-03-15 23:17:26.286727+00
  7 |         2 | is_active      |              |             | t             |               | 2026-03-15 23:17:26.286727+00
  8 |         2 | price          |              |             |               |           499 | 2026-03-15 23:17:26.286727+00
(8 rows)

Pivots attribute rows back into columns. This is what the API exposes.

=# SELECT * FROM api.products;
 id |          created_at           | color | weight | is_active | price
----+-------------------------------+-------+--------+-----------+-------
  2 | 2026-03-15 23:17:26.286727+00 | blue  |      1 | t         |   499
  1 | 2026-03-15 23:17:26.286727+00 | red   |    2.5 | t         |   999
(2 rows)