SQL Schema Creator

When to use this skill

When you need to add new SQL tables, triggers, or reference data to the ORE Studio database. This skill complements the Domain Type Creator skill by handling the database schema aspects of domain types.

For architecture overview, schema mental model, and PostgreSQL extensions setup, see the ORE Studio SQL Schema documentation.

Schema organization

The database uses a single public schema with the ores_ prefix pattern for all tables. Component prefixes are combined with ores_ to form the full prefix:

Full Prefix C++ Component Description
ores_dq_ ores.dq Data quality, governance, staging
ores_iam_ ores.iam Identity and Access Management
ores_refdata_ ores.refdata Reference data (currencies, countries, etc.)
ores_assets_ ores.assets Digital assets (images, tags)
ores_variability_ ores.variability Feature flags and configuration
ores_telemetry_ ores.telemetry Telemetry and logging
ores_geo_ ores.geo Geolocation services
ores_utility_ (shared) Shared utility functions
ores_seed_ (shared) Validation helpers for population

For the full schema mental model, see ORE Studio SQL Schema.

How to use this skill

Code generation is mandatory. Always use the ores.codegen project to generate SQL schema files from JSON models. Hand-crafting SQL files is only permitted when the entity pattern cannot be expressed in any existing template (e.g., complex cross-table soft FK validation logic). If you hand-craft a file, update the templates first so the pattern is captured for future use.

Priority order

  1. Use code generation (required): Create a JSON model and run the generator. See ORE Studio Codegen for details.
  2. Update templates first, then generate: If the entity needs a new pattern, extend the appropriate template in library/templates/ before generating.
  3. Hand-craft as last resort: Only when the schema pattern genuinely cannot be expressed via templates (e.g., multi-table soft FK chains). In this case, document why codegen was skipped with a comment in the file header, and ensure the file exactly follows the conventions of codegen output.

Code generation workflow

  1. Create a JSON model in projects/ores.codegen/models/{component}/:
    • For UUID primary key tables: use *_domain_entity.json naming
    • For junction/association tables: use *_junction.json naming
  2. Generate SQL:

    cd projects/ores.codegen
    ./run_generator.sh models/{component}/{entity}_domain_entity.json output/ --profile sql
    
  3. Review the generated output in output/
  4. Copy to projects/ores.sql/create/{component}/
  5. Continue with steps 5-7 below (orchestration, testing, validation)

Manual workflow (last resort)

  1. Gather requirements about the table (name, columns, relationships).
  2. Determine the appropriate component prefix based on the C++ component.
  3. Follow the detailed instructions to create the required SQL files.
  4. Update the corresponding C++ entity file with the new table name.
  5. Update the orchestration scripts to include the new files.
  6. Test the schema creation.
  7. Run schema validation (./projects/ores.sql/utility/validate_schemas.sh).

Naming conventions

All database entities follow a strict naming pattern with component prefixes matching the C++ component names. This ensures consistency between SQL schema and C++ code.

Component prefixes

Prefix C++ Component Description
ores_dq_ ores.dq Data quality, governance, staging
ores_iam_ ores.iam Identity and Access Management
ores_refdata_ ores.refdata Reference data (currencies, countries, etc.)
ores_assets_ ores.assets Digital assets (images, tags)
ores_variability_ ores.variability Feature flags and configuration
ores_telemetry_ ores.telemetry Telemetry and logging
ores_geo_ ores.geo Geolocation services
ores_utility_ (shared) Shared utility functions
ores_seed_ (shared) Validation helpers for population

Entity suffixes

Entity Type Suffix Example
Table _tbl ores_iam_accounts_tbl
View _vw ores_telemetry_stats_daily_vw
Insert trigger _insert_trg ores_iam_accounts_insert_trg
Notify trigger _notify_trg ores_iam_accounts_notify_trg
Insert function _insert_fn ores_iam_accounts_insert_fn()
Notify function _notify_fn ores_iam_accounts_notify_fn()
Upsert function _upsert_fn ores_dq_catalogs_upsert_fn()
Publish function _publish_fn ores_dq_currencies_publish_fn()
Preview function _preview_fn ores_dq_currency_preview_fn()
Assign function _assign_fn ores_iam_role_permissions_assign_fn()
Other functions _fn ores_utility_infinity_timestamp_fn()
Delete rule _delete_rule ores_iam_accounts_delete_rule
Regular index _idx ores_iam_accounts_username_idx
Unique index _uniq_idx ores_iam_accounts_username_uniq_idx
GiST index _gist_idx ores_iam_accounts_validity_gist_idx
RLS policy _policy ores_iam_accounts_tenant_isolation_policy

Entity name conventions

Entity names in tables should use the plural form:

Singular Plural Example Table Name
account accounts ores_iam_accounts_tbl
currency currencies ores_refdata_currencies_tbl
dataset datasets ores_dq_datasets_tbl
scheme schemes ores_dq_coding_schemes_tbl
category categories ores_dq_change_reason_categories_tbl

This convention ensures consistency and makes it clear that tables contain collections of entities.

Full naming patterns

Entity Pattern
Table ores_{component}_{entities}_tbl (plural)
View ores_{component}_{entities}_{qualifier}_vw
Insert trigger ores_{component}_{entities}_insert_trg
Notify trigger ores_{component}_{entities}_notify_trg
Insert function ores_{component}_{entities}_insert_fn()
Notify function ores_{component}_{entities}_notify_fn()
Upsert function ores_{component}_{entities}_upsert_fn()
Publish function ores_{component}_{entities}_publish_fn()
Preview function ores_{component}_{entity}_preview_fn() (singular)
Assign function ores_{component}_{entities}_assign_fn()
Delete rule ores_{component}_{entities}_delete_rule
Column index ores_{component}_{entities}_{column(s)}_idx
Unique index ores_{component}_{entities}_{column(s)}_uniq_idx
Tenant RLS policy ores_{component}_{entities}_tenant_isolation_policy
Party RLS policy ores_{component}_{entities}_party_isolation_policy
Read RLS policy ores_{component}_{entities}_read_policy
Modification RLS ores_{component}_{entities}_modification_policy
Schema file ores_{component}_{entity}_create.sql
Notify file ores_{component}_{entity}_notify_trigger.sql
Drop file ores_{component}_{entity}_drop.sql
Notify drop file ores_{component}_{entity}_notify_trigger_drop.sql
RLS create file {component}_rls_policies_create.sql
RLS drop file {component}_rls_policies_drop.sql
Populate file ores_{component}_{entity}_populate.sql

The key principle is that function names preserve the table name for grep-ability. For example, the table ores_dq_catalogs_tbl has upsert function ores_dq_catalogs_upsert_fn, so searching for "ores_dq_catalogs" finds both the table and its functions.

Idempotency patterns

All create and drop scripts should be idempotent (safe to run multiple times). Follow these patterns to avoid unnecessary NOTICE messages:

Function drops

Drop functions without parameter signatures:

-- GOOD: No NOTICE if function doesn't exist
drop function if exists ores_dq_populate_bundle_fn;

-- BAD: Shows NOTICE if specific signature doesn't exist
drop function if exists ores_dq_populate_bundle_fn(text, text, text);

Omitting the signature allows PostgreSQL to drop all overloads silently.

Unique constraints

Use CREATE UNIQUE INDEX IF NOT EXISTS instead of ALTER TABLE ADD CONSTRAINT:

-- GOOD: Idempotent, no NOTICE on re-run
create unique index if not exists ores_dq_tags_artefact_dataset_name_uniq_idx
on ores_dq_tags_artefact_tbl (dataset_id, name);

-- BAD: Requires DROP/ADD pattern which shows NOTICE
alter table ores_dq_tags_artefact_tbl
drop constraint if exists ores_dq_tags_artefact_dataset_name_uq;
alter table ores_dq_tags_artefact_tbl
add constraint ores_dq_tags_artefact_dataset_name_uq unique (dataset_id, name);

A unique index provides the same constraint enforcement as UNIQUE constraint but supports IF NOT EXISTS for clean idempotency.

Tables and indexes

Use IF NOT EXISTS / IF EXISTS consistently:

-- Create scripts
create table if not exists ores_dq_example_tbl (...);
create index if not exists ores_dq_example_code_idx on ores_dq_example_tbl (code);

-- Drop scripts
drop function if exists ores_dq_example_fn;
drop table if exists ores_dq_example_tbl;  -- Cascades to indexes

Utility and generation scripts

Scripts that don't create schema objects but provide utility functions or generate other SQL files follow a similar pattern:

Script Type Pattern Example
Teardown script {component}_{feature}_teardown.sql teardown_all.sql
Setup script setup_{feature}.sql setup_schema.sql, setup_user.sql

Generation scripts produce other SQL files (e.g., for review before execution). They should:

  • Output to a well-known filename
  • Include comments in generated output explaining how to regenerate
  • Be idempotent (safe to run multiple times)

Existing entities by component

IAM Component (ores_iam_)

Entity Table Name C++ Entity File
accounts ores_iam_accounts_tbl ores.iam/.../account_entity.hpp
roles ores_iam_roles_tbl ores.iam/.../role_entity.hpp
permissions ores_iam_permissions_tbl ores.iam/.../permission_entity.hpp
account_roles ores_iam_account_roles_tbl ores.iam/.../account_role_entity.hpp
role_permissions ores_iam_role_permissions_tbl ores.iam/.../role_permission_entity.hpp
sessions ores_iam_sessions_tbl ores.iam/.../session_entity.hpp
session_stats ores_iam_session_stats_tbl ores.iam/.../session_entity.hpp
login_info ores_iam_login_info_tbl ores.iam/.../login_info_entity.hpp

Schema files: ores_iam_accounts_create.sql, ores_iam_roles_create.sql, ores_iam_permissions_create.sql, etc. Functions: ores_iam_rbac_functions_create.sql (contains RBAC helper functions)

Refdata Component (ores_refdata_)

Entity Table Name C++ Entity File
currencies ores_refdata_currencies_tbl ores.refdata/.../currency_entity.hpp
countries ores_refdata_countries_tbl ores.refdata/.../country_entity.hpp
change_reasons ores_dq_change_reasons_tbl (SQL only - used by triggers)
change_reason_categories ores_dq_change_reason_categories_tbl (SQL only - used by triggers)

Schema files: ores_refdata_currencies_create.sql, ores_refdata_countries_create.sql, ores_dq_change_reasons_create.sql, ores_dq_change_reason_categories_create.sql Functions: ores_dq_change_reason_functions_create.sql (validation functions)

Assets Component (ores_assets_)

Entity Table Name C++ Entity File
images ores_assets_images_tbl ores.assets/.../image_entity.hpp
tags ores_assets_tags_tbl ores.assets/.../tag_entity.hpp
image_tags ores_assets_image_tags_tbl ores.assets/.../image_tag_entity.hpp

Schema files: ores_assets_images_create.sql, ores_assets_tags_create.sql, ores_assets_image_tags_create.sql Functions: ores_assets_images_functions_create.sql (contains ores_assets_load_flag_fn())

Variability Component (ores_variability_)

Entity Table Name C++ Entity File
feature_flags ores_variability_feature_flags_tbl ores.variability/.../feature_flags_entity.hpp

Schema files: ores_variability_feature_flags_create.sql

Telemetry Component (ores_telemetry_)

Entity Table Name C++ Entity File
logs ores_telemetry_logs_tbl (SQL only)

Schema files: ores_telemetry_logs_create.sql Functions: ores_telemetry_stats_functions_create.sql (aggregation functions)

Note: telemetry tables use TimescaleDB hypertables for time-series data.

Geo Component (ores_geo_)

Entity Table Name C++ Entity File
ip2country ores_geo_ip2country_tbl (SQL only)

Schema files: ores_geo_ip2country_create.sql Functions: ores_geo_ip2country_lookup_fn() for IP geolocation lookups (returns country code for IPv4 address)

Utility Functions (ores_utility_)

Shared utility functions used across all components:

Function Description
ores_utility_infinity_timestamp_fn() Returns '9999-12-31 23:59:59'::timestamptz

Schema file: ores_utility_functions_create.sql

Column naming conventions

Column Type Convention Example
Primary key (UUID) id or {entity}_id id, image_id, tag_id
Natural key descriptive name iso_code, alpha2_code
Tenant ID tenant_id tenant_id
Foreign key {referenced_entity}_id account_id, role_id
Version version version
Temporal start valid_from valid_from
Temporal end valid_to valid_to
Audit user modified_by modified_by
Change tracking change_reason_code change_reason_code
Change tracking change_commentary change_commentary
Boolean (as int) descriptive name enabled, locked
Timestamp *_at suffix assigned_at, created_at

The tenant_id column is required for all temporal and artefact tables to ensure proper multi-tenant isolation. It must be included in:

  • The primary key
  • EXCLUDE constraints
  • All queries (version lookup, updates, deletes)

Notification channel naming

Notification channels use the pattern ores_{entity} (plural form):

Entity Channel Name
accounts ores_accounts
roles ores_roles
currencies ores_currencies
countries ores_countries

The notification payload includes:

  • entity: Full entity name (e.g., production.iam.account, production.refdata.currency, metadata.dq.dataset)
  • timestamp: Change timestamp
  • entity_ids: Array of affected entity IDs

SQL file organization

All SQL files are located under projects/ores.sql/ with the following structure:

projects/ores.sql/
├── create/                          # Table creation and trigger scripts
│   ├── {component}/                 # Component subdirectories
│   │   ├── create_{component}.sql   # Component master script
│   │   ├── {component}_{entity}_create.sql
│   │   ├── {component}_{entity}_notify_trigger_create.sql
│   │   └── {component}_rls_policies_create.sql
│   ├── rls/                         # RLS policy orchestration
│   │   └── rls_create.sql           # Master RLS create script
│   └── create.sql                   # Master-of-masters
├── drop/                            # Drop scripts for cleanup
│   ├── {component}/                 # Component subdirectories
│   │   ├── drop_{component}.sql     # Component master script
│   │   ├── {component}_{entity}_drop.sql
│   │   ├── {component}_{entity}_notify_trigger_drop.sql
│   │   └── {component}_rls_policies_drop.sql
│   ├── rls/                         # RLS policy drop orchestration
│   │   └── rls_drop.sql             # Master RLS drop script
│   └── drop.sql                     # Master-of-masters
├── populate/                        # Reference data population
│   ├── {component}/                 # Component subdirectories
│   │   ├── populate_{component}.sql # Component master script
│   │   └── {component}_{entity}_populate.sql
│   ├── data/                        # Static data files (SVG flags, etc.)
│   └── populate.sql                 # Master-of-masters
├── instance/                        # Instance-specific initialization
│   └── init_instance.sql
├── create_database.sql              # Creates database (postgres superuser)
├── setup_schema.sql                 # Sets up schema (ores_ddl_user)
├── teardown_all.sql                 # Complete cluster teardown
└── recreate_database.sql            # Full wipe and rebuild (dev)

Detailed instructions

Step 1: Create the table definition

Create a file projects/ores.sql/create/ores_{component}_{entity}_create.sql.

The file must include:

  1. GPL license header (copy from existing files)
  2. Table definition with temporal support:
-- All tables use the public schema with ores_ prefix
create table if not exists "ores_{component}_{entity}_tbl" (
    "{pk_column}" uuid not null,
    "tenant_id" uuid not null,
    "version" integer not null,
    -- domain-specific columns here
    "modified_by" text not null,
    "change_reason_code" text not null,
    "change_commentary" text not null,
    "valid_from" timestamp with time zone not null,
    "valid_to" timestamp with time zone not null,
    primary key (tenant_id, {pk_column}, valid_from, valid_to),
    exclude using gist (
        tenant_id WITH =,
        {pk_column} WITH =,
        tstzrange(valid_from, valid_to) WITH &&
    ),
    check ("valid_from" < "valid_to"),
    check ("{pk_column}" <> '')  -- for text primary keys
    -- OR for UUID primary keys:
    -- check ("id" <> '00000000-0000-0000-0000-000000000000'::uuid)
);
-- Note: change_reason_code validation is handled by the insert trigger function,
-- not via check constraint. See Step 4 below for the trigger implementation.

Primary key validation constraints

Always add a CHECK constraint to prevent empty or nil primary keys. This provides defense in depth alongside service layer validation:

  • Text primary keys: Use check ("{pk_column}" <> '') to prevent empty strings
  • UUID primary keys: Use check ("id" <> '00000000-0000-0000-0000-000000000000'::uuid) to prevent nil UUIDs

Examples:

-- For a table with text primary key:
create table if not exists "ores_refdata_currencies_tbl" (
    "iso_code" text not null,
    ...
    check ("valid_from" < "valid_to"),
    check ("iso_code" <> '')
);

-- For a table with UUID primary key:
create table if not exists "ores_dq_datasets_tbl" (
    "id" uuid not null,
    ...
    check ("valid_from" < "valid_to"),
    check ("id" <> '00000000-0000-0000-0000-000000000000'::uuid)
);

-- For composite keys, add checks for each key column:
create table if not exists "ores_dq_subject_areas_tbl" (
    "name" text not null,
    "domain_name" text not null,
    ...
    check ("valid_from" < "valid_to"),
    check ("name" <> ''),
    check ("domain_name" <> '')
);

Note: PostgreSQL's NOT NULL constraint only prevents NULL values, not empty strings. The CHECK constraint is required to prevent semantically invalid empty keys.

  1. Unique indexes for current records:
-- Version uniqueness index (required for optimistic concurrency)
create unique index if not exists ores_{component}_{entity}_version_uniq_idx
on "ores_{component}_{entity}_tbl" (tenant_id, {pk_column}, version)
where valid_to = ores_utility_infinity_timestamp_fn();

-- Natural key uniqueness index (if applicable)
create unique index if not exists ores_{component}_{entity}_{column}_uniq_idx
on "ores_{component}_{entity}_tbl" (tenant_id, {column})
where valid_to = ores_utility_infinity_timestamp_fn();

-- Tenant index (required for all tenant-aware tables)
create index if not exists ores_{component}_{entity}_tenant_idx
on "ores_{component}_{entity}_tbl" (tenant_id)
where valid_to = ores_utility_infinity_timestamp_fn();
  1. Insert trigger function for upsert-by-insert with optimistic concurrency:

The trigger function implements:

  • Upsert-by-insert: Inserting a record with the same PK automatically closes the old record and creates a new version
  • Optimistic concurrency: Version conflict detection prevents lost updates
  • Version starts at 1: First version of a record is version 1
  • Forced timestamps: valid_from and valid_to are always set by the trigger
create or replace function ores_{component}_{entity}_insert_fn()
returns trigger as $$
declare
    current_version integer;
begin
    -- Validate tenant_id
    NEW.tenant_id := ores_iam_validate_tenant_fn(NEW.tenant_id);

    select version into current_version
    from "ores_{component}_{entity}_tbl"
    where tenant_id = NEW.tenant_id
      and {pk_column} = NEW.{pk_column}
      and valid_to = ores_utility_infinity_timestamp_fn();

    if found then
        -- This is an update (record with same PK exists)
        if NEW.version != 0 and NEW.version != current_version then
            raise exception 'Version conflict: expected version %, but current version is %',
                NEW.version, current_version
                using errcode = 'P0002';
        end if;
        NEW.version = current_version + 1;

        -- Close the old record
        update "ores_{component}_{entity}_tbl"
        set valid_to = current_timestamp
        where tenant_id = NEW.tenant_id
          and {pk_column} = NEW.{pk_column}
          and valid_to = ores_utility_infinity_timestamp_fn()
          and valid_from < current_timestamp;
    else
        -- This is a new record
        NEW.version = 1;
    end if;

    NEW.valid_from = current_timestamp;
    NEW.valid_to = ores_utility_infinity_timestamp_fn();

    if NEW.modified_by is null or NEW.modified_by = '' then
        NEW.modified_by = current_user;
    end if;

    -- Validate change_reason_code (tenant-aware)
    NEW.change_reason_code := ores_dq_validate_change_reason_fn(NEW.tenant_id, NEW.change_reason_code);

    return NEW;
end;
$$ language plpgsql;
  1. Insert trigger:
create or replace trigger ores_{component}_{entity}_insert_trg
before insert on "ores_{component}_{entity}_tbl"
for each row execute function ores_{component}_{entity}_insert_fn();
  1. Delete rule (soft delete via temporal update):
create or replace rule ores_{component}_{entity}_delete_rule as
on delete to "ores_{component}_{entity}_tbl" do instead
    update "ores_{component}_{entity}_tbl"
    set valid_to = current_timestamp
    where tenant_id = OLD.tenant_id
      and {pk_column} = OLD.{pk_column}
      and valid_to = ores_utility_infinity_timestamp_fn();

Follow the pattern in projects/ores.sql/create/ores_iam_accounts_create.sql.

Step 2: Create notification trigger

Create a notification trigger to enable real-time UI updates when data changes. This is required for all entities that will be displayed in the Qt UI, as it forms the first stage of the event pipeline:

Database Trigger (pg_notify) → Event Source → Event Bus → Clients

Without the trigger, clients won't receive change notifications and their UI will become stale without any indication.

Create projects/ores.sql/create/ores_{component}_{entity}_notify_trigger.sql:

create or replace function ores_{component}_{entity}_notify_fn()
returns trigger as $$
declare
    notification_payload jsonb;
    entity_name text := '{cpp_namespace}.{entity}';
    change_timestamp timestamptz := NOW();
    changed_id text;
begin
    if TG_OP = 'DELETE' then
        changed_id := OLD.{pk_column}::text;
    else
        changed_id := NEW.{pk_column}::text;
    end if;

    notification_payload := jsonb_build_object(
        'entity', entity_name,
        'timestamp', to_char(change_timestamp, 'YYYY-MM-DD HH24:MI:SS'),
        'entity_ids', jsonb_build_array(changed_id)
    );

    perform pg_notify('ores_{entity_plural}', notification_payload::text);
    return null;
end;
$$ language plpgsql;

create or replace trigger ores_{component}_{entity}_notify_trg
after insert or update or delete on ores_{component}_{entity}_tbl
for each row execute function ores_{component}_{entity}_notify_fn();

Where:

  • {cpp_namespace} is the C++ namespace (e.g., iam, refdata, assets)
  • {entity_plural} is the plural form for the notification channel

Follow the pattern in projects/ores.sql/create/ores_iam_accounts_notify_trigger.sql.

Step 3: Create drop scripts

Create projects/ores.sql/drop/ores_{component}_{entity}_drop.sql:

drop trigger if exists ores_{component}_{entity}_insert_trg on "ores_{component}_{entity}_tbl";
drop rule if exists ores_{component}_{entity}_delete_rule on "ores_{component}_{entity}_tbl";
drop function if exists ores_{component}_{entity}_insert_fn();
drop table if exists "ores_{component}_{entity}_tbl";

Also create the notify trigger drop script projects/ores.sql/drop/ores_{component}_{entity}_notify_trigger_drop.sql:

drop trigger if exists ores_{component}_{entity}_notify_trg on "ores_{component}_{entity}_tbl";
drop function if exists ores_{component}_{entity}_notify_fn();

Step 3b: Add RLS policy

Every table with a tenant_id column must have a Row-Level Security (RLS) policy for tenant isolation. Policies are grouped per component in a single file.

Policy types

Type Use Case Mode
Tenant isolation Standard tenant filtering PERMISSIVE
Party isolation Party-scoped data within a tenant RESTRICTIVE
Read (shared data) System tenant data readable by all PERMISSIVE
Modification Write restricted to own tenant PERMISSIVE

Policy naming

Policy names follow the pattern ores_{component}_{entities}_{type}_policy:

Type Suffix Example
Tenant isolation _tenant_isolation_policy ores_refdata_currencies_tenant_isolation_policy
Party isolation _party_isolation_policy ores_refdata_party_counterparties_party_isolation_policy
Read (shared) _read_policy ores_dq_catalogs_read_policy
Modification _modification_policy ores_dq_catalogs_modification_policy

Important: Do NOT include _tbl in policy names. The policy name should match the entity name, not the table name. For example, use ores_iam_accounts_tenant_isolation_policy not ores_iam_accounts_tbl_tenant_isolation_policy.

Standard tenant isolation policy

alter table ores_{component}_{entities}_tbl enable row level security;

create policy ores_{component}_{entities}_tenant_isolation_policy
on ores_{component}_{entities}_tbl
for all using (
    tenant_id = ores_iam_current_tenant_id_fn()
)
with check (
    tenant_id = ores_iam_current_tenant_id_fn()
);

Party isolation policy (RESTRICTIVE)

For tables that need party-level filtering within a tenant, add a RESTRICTIVE policy. RESTRICTIVE policies are ANDed with PERMISSIVE policies (multiple PERMISSIVE policies are ORed). The IS NULL fallback ensures backward compatibility when no party context is set.

create policy ores_{component}_{entities}_party_isolation_policy
on ores_{component}_{entities}_tbl
as restrictive
for all using (
    ores_iam_visible_party_ids_fn() is null
    or party_id = ANY(ores_iam_visible_party_ids_fn())
)
with check (
    ores_iam_visible_party_ids_fn() is null
    or party_id = ANY(ores_iam_visible_party_ids_fn())
);

Warning: Do NOT use two PERMISSIVE policies for tenant + party isolation. PostgreSQL ORs PERMISSIVE policies, so the tenant policy would always pass and the party policy would be ignored.

Shared data pattern (DQ, Geo)

For tables where system tenant data should be readable by all tenants:

create policy ores_{component}_{entities}_read_policy
on ores_{component}_{entities}_tbl
for select using (
    tenant_id = ores_iam_current_tenant_id_fn()
    or tenant_id = ores_iam_system_tenant_id_fn()
);

create policy ores_{component}_{entities}_modification_policy
on ores_{component}_{entities}_tbl
for all
using (tenant_id = ores_iam_current_tenant_id_fn())
with check (tenant_id = ores_iam_current_tenant_id_fn());

File organization

  • Create: Add policies to projects/ores.sql/create/{component}/{component}_rls_policies_create.sql
  • Drop: Add corresponding drops to projects/ores.sql/drop/{component}/{component}_rls_policies_drop.sql
  • Wire create into create/rls/rls_create.sql; wire drop into drop/rls/rls_drop.sql

Drop order is: RLS policies first, then tables. Create order is the reverse: tables first, then RLS policies.

Drop script pattern

-- Drop policies before dropping tables
drop policy if exists ores_{component}_{entities}_tenant_isolation_policy on "ores_{component}_{entities}_tbl";
drop policy if exists ores_{component}_{entities}_party_isolation_policy on "ores_{component}_{entities}_tbl";

Step 4: Create population script (if needed)

For reference data, create projects/ores.sql/populate/ores_{component}_{entity}_populate.sql:

Note: The insert trigger automatically manages version, valid_from, and valid_to. Population scripts should pass 0 for version (which tells the trigger to use default behavior) and can omit the temporal columns since the trigger will set them.

-- Helper function for idempotent inserts
create or replace function ores_seed_{entity}_fn(
    p_field1 text,
    p_field2 text
    -- add parameters as needed
) returns void as $$
begin
    if not exists (
        select 1 from ores_{component}_{entity}_tbl
        where field1 = p_field1
          and valid_to = ores_utility_infinity_timestamp_fn()
    ) then
        insert into ores_{component}_{entity}_tbl (
            id, version, field1, field2,
            modified_by, change_reason_code, change_commentary
        )
        values (
            gen_random_uuid(), 0, p_field1, p_field2,
            'system', 'system.new_record', 'System seed data'
        );
        -- Note: version will be set to 1 by the trigger (first version)
        -- valid_from and valid_to are also set by the trigger
        raise notice 'Created {entity}: %', p_field1;
    else
        raise notice '{entity} already exists: %', p_field1;
    end if;
end;
$$ language plpgsql;

-- Seed data
select ores_seed_{entity}_fn('value1', 'value2');
select ores_seed_{entity}_fn('value3', 'value4');

-- Cleanup helper function
drop function ores_seed_{entity}_fn(text, text);

-- Summary
select '{entity}' as entity, count(*) as count
from ores_{component}_{entity}_tbl
where valid_to = ores_utility_infinity_timestamp_fn();

Follow the pattern in projects/ores.sql/populate/ores_dq_change_reasons_populate.sql.

Step 5: Update C++ entity file

If you have a corresponding C++ entity, update the tablename constant to match:

struct {entity}_entity {
    constexpr static const char* tablename = "ores_{component}_{entity}_tbl";

    // ... fields matching SQL columns
};

Entity files are located at: projects/ores.{component}/include/ores.{component}/repository/{entity}_entity.hpp

Step 6: Update orchestration scripts

  1. Add the creation script to projects/ores.sql/template/create_schema.sql:
-- In appropriate component section
\echo '--- {Component} tables ---'
\ir ../schema/ores_{component}_{entity}_create.sql
\ir ../schema/ores_{component}_{entity}_notify_trigger.sql  -- if applicable

The file is organized in sections:

  • Utility functions
  • Change control tables (dq_change_reason_*)
  • IAM tables
  • Reference data tables
  • Assets tables
  • Variability tables
  • Telemetry tables
  • Geo tables
  • Add the drop script to projects/ores.sql/drop_all.sql (in reverse dependency order):
-- {Component}
\ir ./drop/ores_{component}_{entity}_notify_trigger_drop.sql  -- if applicable
\ir ./drop/ores_{component}_{entity}_drop.sql
  1. If you have population data, add it to projects/ores.sql/populate/populate.sql:
\ir ores_{component}_{entity}_populate.sql

Step 7: Test the schema

  1. Create a test database:
psql -U postgres -v db_name='ores_test_schema' -f projects/ores.sql/create_database.sql
PGPASSWORD='DDL_PASS' psql -U ores_ddl_user -d ores_test_schema -f projects/ores.sql/setup_schema.sql
  1. Verify the table exists:
psql -U ores_cli_user -d ores_test_schema -c "\d ores_{component}_{entity}_tbl"
  1. Test the notification trigger:
psql -U ores -d <database_name> -c "LISTEN ores_{entity_plural}; INSERT INTO ores_{component}_{entity}_tbl (...) VALUES (...);"

Step 8: Run schema validation

Run the schema validation script to check for common issues before committing:

./projects/ores.sql/utility/validate_schemas.sh

The validator checks for:

  • Temporal tables missing required columns (version, modified_by)
  • Functions created without corresponding drop statements
  • Tables created without corresponding drop statements
  • Other schema consistency issues

Note: The validator does not yet check for RLS policy create/drop parity. Policy drops must be verified manually or by reviewing the {component}_rls_policies_drop.sql files.

Important: Fix all issues reported by the validator before committing. This validation also runs in CI and will fail the build if there are warnings.

SQL patterns

Temporal table patterns

All entity tables use bitemporal support with:

  • tenant_id: UUID reference to the tenant (required, part of primary key)
  • valid_from / valid_to: Validity period for the record
  • version: Incrementing version number for optimistic concurrency
  • modified_by: Username or system identifier that made the change
  • change_reason_code: Reference to dq_change_reasons_tbl
  • change_commentary: Free-text explanation of the change
  • GiST exclusion constraint including tenant_id WITH = to prevent overlapping validity periods within the same tenant

Querying current records

Always filter by valid_to = ores_utility_infinity_timestamp_fn() to get current records:

select * from ores_{component}_{entity}_tbl
where valid_to = ores_utility_infinity_timestamp_fn();

Soft delete pattern

Records are never physically deleted. Instead, the delete rule updates valid_to to the current timestamp, creating a historical record.

Foreign key references

For tables with image_id or other foreign keys to temporal tables, reference the ID without temporal constraints (the application layer manages consistency):

"image_id" uuid,  -- Optional FK to ores_assets_images_tbl

Change reason validation

All tables with change_reason_code validate the code via the insert trigger function. The trigger calls ores_dq_validate_change_reason_fn() with the tenant_id to ensure the code exists for that tenant:

-- Inside the insert trigger function:
new.change_reason_code := ores_dq_validate_change_reason_fn(new.tenant_id, new.change_reason_code);

This validates that the change reason code exists in ores_dq_change_reasons_tbl for the specified tenant and raises an exception with SQLSTATE 23503 if invalid.

Using the Code Generator

The code generator can automatically create SQL schema files from JSON model definitions. This is the recommended approach for new tables.

Generator location

projects/ores.codegen/
├── run_generator.sh           # Main entry point
├── src/generator.py           # Generator implementation
├── library/templates/         # Mustache templates
│   ├── sql_schema_domain_entity_create.mustache
│   └── sql_schema_junction_create.mustache
└── models/                    # Model definitions
    └── dq/
        ├── dataset_bundle_domain_entity.json
        └── dataset_bundle_member_junction.json

Model types

Domain Entity models

Use for tables with UUID primary key and natural key constraints. File naming convention: *_domain_entity.json.

Example: ores_dq_dataset_bundles_tbl (UUID PK, code/name natural keys)

{
  "domain_entity": {
    "component": "dq",
    "entity_singular": "dataset_bundle",
    "entity_plural": "dataset_bundles",
    "entity_title": "Dataset Bundle",
    "brief": "A named collection of datasets.",
    "primary_key": {
      "column": "id",
      "type": "uuid"
    },
    "natural_keys": [
      {"column": "code", "type": "text"},
      {"column": "name", "type": "text"}
    ],
    "columns": [
      {"name": "description", "type": "text", "nullable": false}
    ],
    "sql": {
      "tablename": "ores_dq_dataset_bundles_tbl"
    }
  }
}

Junction models

Use for association tables with composite text primary keys. File naming convention: *_junction.json.

Example: ores_dq_dataset_bundle_members_tbl (bundle_code + dataset_code PK)

{
  "junction": {
    "component": "dq",
    "name": "dataset_bundle_members",
    "name_singular": "dataset_bundle_member",
    "brief": "Links a dataset to a bundle.",
    "left": {
      "column": "bundle_code",
      "type": "text"
    },
    "right": {
      "column": "dataset_code",
      "type": "text"
    },
    "columns": [
      {"name": "display_order", "type": "integer", "nullable": false}
    ],
    "sql": {
      "tablename": "ores_dq_dataset_bundle_members_tbl"
    }
  }
}

Running the generator

cd projects/ores.codegen

# Generate SQL schema for a domain entity
./run_generator.sh models/dq/dataset_bundle_domain_entity.json output/ \
    --template sql_schema_domain_entity_create.mustache

# Generate SQL schema for a junction table
./run_generator.sh models/dq/dataset_bundle_member_junction.json output/ \
    --template sql_schema_junction_create.mustache

Generated output

Domain entity template produces:

  • Table with UUID primary key and null-UUID check
  • Unique indexes on natural key columns for active records
  • Version uniqueness index
  • GIST exclusion constraint on (id, temporal range)
  • Insert trigger with version conflict detection
  • Delete rule for soft deletes

Junction template produces:

  • Table with composite primary key (left_code, right_code, valid_from)
  • GIST exclusion on both keys + temporal range
  • Indexes on both foreign key columns
  • Unique index on composite key for active records
  • Non-empty check on change_reason_code
  • Insert trigger with composite key lookup

Workflow

  1. Create a JSON model in projects/ores.codegen/models/{component}/
  2. Run the generator to produce SQL
  3. Review the output in output/
  4. Copy to projects/ores.sql/create/{component}/
  5. Update orchestration scripts (see Step 6)
  6. Test and validate (see Steps 7-8)

Table categories for multi-tenancy

Tables are categorized by how they are populated for new tenants. This affects tenant provisioning and determines which tables contain "true system data" vs "dataset data".

System IAM tables (copied during provisioning)

These tables are copied from tenant 0 (system tenant) when provisioning a new tenant. They contain essential IAM configuration that every tenant needs:

Table Description
ores_iam_permissions_tbl Permission definitions
ores_iam_roles_tbl Role definitions
ores_iam_role_permissions_tbl Role-permission assignments

These are "system IAM tables" - their data is defined in the foundation layer (setup_schema.sql) and copied verbatim to each new tenant. Other IAM tables (accounts, sessions, login_info) are tenant-specific and start empty.

Dataset tables (populated via Data Librarian)

These tables are NOT copied during tenant provisioning. Instead, they are populated via the dataset library using the "Publish Datasets" feature in the Data Librarian window:

Table Description
ores_refdata_currencies_tbl ISO currencies
ores_refdata_countries_tbl ISO countries
ores_refdata_business_centres_tbl Business/trading centres
ores_refdata_account_types_tbl Account type codes
ores_refdata_asset_classes_tbl Asset class codes
ores_refdata_asset_measures_tbl Asset measure codes
ores_refdata_benchmark_rates_tbl Benchmark rate codes
ores_refdata_business_processes_tbl Business process codes
ores_refdata_cashflow_types_tbl Cashflow type codes
ores_refdata_entity_classifications_tbl Entity classification codes
ores_refdata_local_jurisdictions_tbl Local jurisdiction codes
ores_refdata_party_relationships_tbl Party relationship codes
ores_refdata_party_roles_tbl Party role codes
ores_refdata_person_roles_tbl Person role codes
ores_refdata_regulatory_corporate_sectors_tbl Regulatory sector codes
ores_refdata_reporting_regimes_tbl Reporting regime codes
ores_refdata_supervisory_bodies_tbl Supervisory body codes
ores_assets_images_tbl Image assets (flags, icons)

Dataset tables flow: DQ artefact tables → Publish → Production (refdata) tables.

Tenant-specific tables (empty for new tenants)

These tables start empty for new tenants and are populated by user actions:

Table Description
ores_iam_accounts_tbl User accounts
ores_iam_account_roles_tbl User-role assignments
ores_iam_sessions_tbl User sessions
ores_iam_login_info_tbl Login tracking
All ores_dq_*_tbl tables Data quality/governance metadata

Implications for provisioning

When implementing tenant provisioning:

  1. Copy only system IAM tables (permissions, roles, role_permissions)
  2. Do NOT copy refdata tables - tenants publish their own datasets
  3. Tenant-specific tables start empty

When implementing tenant deprovisioning:

  1. Use dynamic table discovery to clean up ALL tenant data
  2. Soft-delete temporal tables (set valid_to to current timestamp)
  3. Hard-delete non-temporal tables

Integration with Domain Type Creator

When creating a new domain type using the Domain Type Creator skill, the SQL schema creation is typically done as part of Step 5 (Create repository entity and mapper). Use this skill to:

  1. Create the table definition matching the entity structure
  2. Add appropriate triggers for versioning and notifications
  3. Update orchestration scripts
  4. Add any reference data population
  5. Ensure the C++ entity tablename matches the SQL table name

Testing with pgTAP

pgTAP is used for database unit testing. Tests verify trigger behavior, validation functions, and constraint enforcement.

Test file organization

Test files are located in projects/ores.sql/test/ and follow the naming pattern {component}_{entity}_test.sql:

projects/ores.sql/test/
├── test.sql                           # Extension setup (create extension pgtap)
├── refdata_validation_test.sql        # Validation function tests
├── refdata_currencies_test.sql        # Currency trigger behavior tests
└── ...

Test file structure

Each test file follows this pattern:

/* License header */

/**
 * pgTAP tests for {entity} behavior.
 *
 * Tests cover:
 * - Feature 1
 * - Feature 2
 *
 * Run with: pg_prove -d {database} test/{testfile}.sql
 */

begin;

select plan({number_of_tests});

-- Test 1: Description
select is(
    (actual_value),
    expected_value,
    'test description'
);

-- Test 2: Exception testing
select throws_ok(
    $$sql statement that should fail$$,
    'SQLSTATE',  -- e.g., '23503' for FK violation
    NULL,        -- message pattern (NULL to ignore)
    'test description'
);

select * from finish();

rollback;

Common test patterns

Testing validation functions

All validation functions are tenant-aware and take tenant_id as the first parameter. Use ores_iam_system_tenant_id_fn() for system tenant data in tests:

-- Test null returns default
select is(
    ores_refdata_validate_rounding_type_fn(ores_iam_system_tenant_id_fn(), NULL),
    'Closest',
    'rounding_type: NULL returns default Closest'
);

-- Test valid value passes through
select is(
    ores_refdata_validate_rounding_type_fn(ores_iam_system_tenant_id_fn(), 'Up'),
    'Up',
    'rounding_type: valid value Up returns itself'
);

-- Test invalid value raises exception
select throws_ok(
    $$select ores_refdata_validate_rounding_type_fn(ores_iam_system_tenant_id_fn(), 'INVALID')$$,
    '23503',
    NULL,
    'rounding_type: invalid value raises 23503 exception'
);

Testing trigger defaults

-- Insert with NULL value
insert into ores_refdata_currencies_tbl (..., rounding_type, ...) values (..., NULL, ...);

-- Verify default was applied
select is(
    (select rounding_type from ores_refdata_currencies_tbl where iso_code = 'TST' ...),
    'Closest',
    'currency insert: NULL rounding_type defaults to Closest'
);

Running tests

Use the run_tests.sh script which reads connection details from environment variables:

# Run all tests using environment variables
ORES_TEST_DB_DATABASE=ores_dev_local1 ./run_tests.sh

# Run a specific test file
ORES_TEST_DB_DATABASE=ores_dev_local1 ./run_tests.sh refdata_validation_test.sql

Environment variables:

Variable Description Default
ORES_TEST_DB_DATABASE Database name (required) -
ORES_TEST_DB_HOST Database host localhost
ORES_TEST_DB_PORT Database port 5432
ORES_TEST_DB_USER Database user postgres

Manual execution

For debugging or running individual tests without the script:

# Run a single test file with pg_prove
pg_prove -d ores_dev_local1 test/refdata_validation_test.sql

# Run directly with psql (shows detailed output)
psql -U postgres -d ores_dev_local1 -f test/refdata_validation_test.sql

Test limitations

Tests run in a single transaction with rollback, which means:

  • Multiple updates to the same entity within one test may fail due to current_timestamp returning the same value (transaction frozen)
  • For version increment tests, use separate transactions or integration tests

Time-Series Tables (TimescaleDB)

When to use hypertables

Use a TimescaleDB hypertable (instead of a plain PostgreSQL table) when:

  • Data is written frequently at high volume (heartbeat samples, telemetry, events)
  • Queries are almost exclusively time-range based (e.g., last N minutes, last session)
  • The data has a clear foreign key to an entity (session, device, account) that acts as a natural partition key for compression and indexing
  • Retention beyond a configurable window is not needed

Plain tables remain preferable for:

  • Low-volume audit/history data (session start/end, account changes)
  • Bitemporal records where valid_from / valid_to are not monotonically increasing
  • Reference data with infrequent writes

Partitioning column

Always choose a timestamp column as the partitioning dimension. In ORE Studio the naming convention is sample_time, event_time, or recorded_at (with type timestamptz).

SELECT create_hypertable('ores_iam_session_samples_tbl', 'sample_time');

Chunk interval guidelines

Data type Recommended interval
High-frequency samples 1 day
Session telemetry 7 days
Coarse metrics 30 days
SELECT set_chunk_time_interval('ores_iam_session_samples_tbl',
    INTERVAL '1 day');

Compression

Enable compression after a short window (3–7 days) to reduce storage. Segment by the foreign-key column (e.g., session_id) so that time-range queries per entity can decompress only the relevant chunks.

ALTER TABLE ores_iam_session_samples_tbl SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'session_id'
);
SELECT add_compression_policy('ores_iam_session_samples_tbl',
    INTERVAL '7 days');

Retention

Drop old chunks automatically. Typical retention periods:

Data type Retention
Session samples 30 days
Telemetry / events 90 days
Coarse daily metrics 365 days
SELECT add_retention_policy('ores_iam_session_samples_tbl',
    INTERVAL '30 days');

Canonical example: ores_iam_session_samples_tbl

CREATE TABLE IF NOT EXISTS "ores_iam_session_samples_tbl" (
    "session_id"     text          not null,
    "sample_time"    timestamptz   not null,
    "bytes_sent"     bigint        not null default 0,
    "bytes_received" bigint        not null default 0,
    "latency_ms"     bigint        not null default 0,
    primary key (session_id, sample_time)
);

SELECT create_hypertable('ores_iam_session_samples_tbl', 'sample_time',
    if_not_exists => TRUE);

CREATE INDEX IF NOT EXISTS
    "ores_iam_session_samples_session_id_sample_time_idx"
    ON "ores_iam_session_samples_tbl" (session_id, sample_time DESC);

ALTER TABLE ores_iam_session_samples_tbl SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'session_id'
);
SELECT add_compression_policy('ores_iam_session_samples_tbl',
    INTERVAL '7 days');
SELECT add_retention_policy('ores_iam_session_samples_tbl',
    INTERVAL '30 days');

Index pattern

Always add a composite index (foreign_key, time DESC) in addition to the primary key. This enables efficient time-range queries filtered by entity:

-- Fetch latest N samples for a session
SELECT * FROM ores_iam_session_samples_tbl
WHERE session_id = $1
ORDER BY sample_time DESC
LIMIT 100;

The DESC ordering matches the most-common access pattern (latest samples first for chart display and monitoring). TimescaleDB's chunk exclusion makes this query efficient even over very large datasets.

Emacs 29.1 (Org mode 9.6.6)