Analytical Schema Conventions¶
Version: 1.0 Status: Complete Audience: Data engineers, DBAs, schema designers Date: January 12, 2026
Overview¶
This document defines naming conventions and patterns for analytical tables in FraiseQL.
Core Principle: No joins. All fact tables use the same pattern: measures (SQL columns) + dimensions (dimensions JSONB column).
Table Naming Conventions¶
Fact Tables (tf_)¶
Prefix: tf_ (table fact)
Pattern: tf_<domain>_<noun>
Purpose: Raw, finest-granularity transactional data
Examples:
tf_sales- Sales transactions (one row per sale)tf_events- Application events (one row per event)tf_api_requests- API usage (one row per request)tf_user_sessions- Session analytics (one row per session)tf_orders- Order transactionstf_payments- Payment records
Structure:
CREATE TABLE tf_sales (
id BIGSERIAL PRIMARY KEY,
-- Measures (SQL columns)
revenue DECIMAL(10,2) NOT NULL,
quantity INT NOT NULL,
cost DECIMAL(10,2) NOT NULL,
-- Dimensions (JSONB)
dimensions JSONB NOT NULL,
-- Denormalized filters (indexed)
customer_id UUID NOT NULL,
product_id UUID NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
Pre-Aggregated Fact Tables (Different Granularity)¶
Important: Pre-aggregated tables are just fact tables at a different granularity. Use tf_ prefix with a descriptive suffix indicating the granularity.
Pattern: tf_<domain>_<granularity> or tf_<domain>_by_<dimension>
Purpose: Pre-computed aggregates for faster queries on common patterns
Examples:
tf_sales_daily- Daily sales rollup (same astf_salesbut at day granularity)tf_sales_by_category- Sales grouped by categorytf_events_monthly- Monthly event aggregatestf_api_requests_hourly- Hourly API usage
Structure (identical to fact tables):
-- Pre-aggregated fact table at daily granularity
CREATE TABLE tf_sales_daily (
id BIGSERIAL PRIMARY KEY,
day DATE NOT NULL UNIQUE, -- Granularity dimension
-- Pre-aggregated measures
revenue DECIMAL(10,2) NOT NULL, -- SUM(revenue)
quantity INT NOT NULL, -- SUM(quantity)
transaction_count INT NOT NULL, -- COUNT(*)
-- Dimensions (same JSONB pattern as tf_sales)
dimensions JSONB NOT NULL,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
Note: Use tf_ for all fact tables regardless of granularity. Expose them to GraphQL through a v_/tv_ read view (with an id column and a data JSONB column) so they follow the standard query-source conventions.
Dimension Tables (td_)¶
Prefix: td_ (table dimension)
Pattern: td_<noun>
Purpose: Reference data for ETL denormalization (NOT used at query time)
Important: FraiseQL does NOT join these tables. They are used by ETL to populate dimensions JSONB in fact tables.
Examples:
td_products- Product catalogtd_customers- Customer master datatd_locations- Geographic hierarchytd_categories- Category taxonomy
Structure (regular table, not fact pattern):
CREATE TABLE td_products (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
-- No data JSONB column needed
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Column Naming Conventions¶
Measures (SQL Columns)¶
Pattern: Descriptive noun, numeric type Purpose: Fast aggregation (10-100x faster than JSONB)
Examples:
revenue DECIMAL(10,2)- Monetary valuequantity INT- Count of itemsduration_ms BIGINT- Duration in millisecondserror_count INT- Number of errorsfile_size_bytes BIGINT- File sizeresponse_time_ms INT- Response time
Naming Rules:
- Use snake_case
- Include units if ambiguous (
_ms,_bytes,_pct) - Be specific (
revenuenotamount,quantitynotcount)
Dimensions (JSONB Paths)¶
Column Name: dimensions on the fact table (surfaced to GraphQL through the view's data JSONB)
Path Pattern: Snake_case keys in JSONB
Examples:
{
"category": "Electronics",
"region": "North America",
"product_type": "Laptop",
"customer_segment": "Enterprise",
"payment_method": "Credit Card",
"shipping_method": "Express"
}
Nested Paths:
{
"customer": {
"segment": "Enterprise",
"industry": "Technology"
},
"product": {
"category": "Electronics",
"subcategory": "Computers"
}
}
Access Pattern:
-- Top-level
dimensions->>'category'
-- Nested
dimensions#>>'{customer,segment}'
Denormalized Filters (Indexed SQL Columns)¶
Pattern: Foreign key or frequently-filtered attribute Purpose: Fast WHERE filtering (avoid JSONB for high-selectivity filters)
Examples:
customer_id UUID- Foreign keyproduct_id UUID- Foreign keyoccurred_at TIMESTAMPTZ- Temporal filterstatus VARCHAR(50)- Status filter (e.g., 'completed', 'cancelled')priority INT- Priority level
Why Denormalized?:
-- ✅ FAST: Indexed SQL column
WHERE customer_id = 'uuid-123' -- Uses B-tree index
-- ❌ SLOW: JSONB filter
WHERE dimensions->>'customer_id' = 'uuid-123' -- GIN index slower for exact match
ETL Responsibility¶
Critical: FraiseQL does NOT manage ETL. DBA/data team handles:
- Creating fact/aggregate tables with proper structure
- Populating tables with denormalized data
- Maintaining dimension tables (
td_*) for lookup - Refreshing aggregate tables via scheduled jobs
Example ETL Flow:
-- Step 1: Staging table receives raw data
INSERT INTO staging_sales (transaction_id, product_id, customer_id, revenue)
VALUES ('txn-001', 'prod-123', 'cust-456', 99.99);
-- Step 2: ETL enriches and denormalizes
INSERT INTO tf_sales (
id, revenue, quantity, cost,
dimensions, -- ← Denormalized from td_products, td_customers
customer_id, product_id, occurred_at
)
SELECT
gen_random_uuid(),
s.revenue, s.quantity, s.cost,
jsonb_build_object(
'product_category', p.category,
'product_name', p.name,
'customer_segment', c.segment,
'customer_region', c.region
) AS dimensions, -- ← Denormalization happens here
s.customer_id, s.product_id, s.occurred_at
FROM staging_sales s
JOIN td_products p ON s.product_id = p.id -- ← ETL time join
JOIN td_customers c ON s.customer_id = c.id;
-- Step 3: Clean staging
TRUNCATE staging_sales;
Index Recommendations¶
Fact Tables¶
-- Denormalized filter columns (B-tree)
CREATE INDEX idx_sales_customer ON tf_sales(customer_id);
CREATE INDEX idx_sales_product ON tf_sales(product_id);
CREATE INDEX idx_sales_occurred ON tf_sales(occurred_at);
CREATE INDEX idx_sales_status ON tf_sales(status);
-- JSONB dimensions (GIN, PostgreSQL only)
CREATE INDEX idx_sales_dimensions_gin ON tf_sales USING GIN(dimensions);
-- Specific JSONB path (faster than GIN for exact lookups)
CREATE INDEX idx_sales_category ON tf_sales ((dimensions->>'category'));
-- Composite indexes for common query patterns
CREATE INDEX idx_sales_customer_occurred
ON tf_sales(customer_id, occurred_at DESC);
Pre-Aggregated Fact Tables¶
-- Granularity dimension (unique)
CREATE UNIQUE INDEX idx_sales_daily_day ON tf_sales_daily(day);
-- JSONB dimensions (if still grouping within aggregates)
CREATE INDEX idx_sales_daily_dimensions_gin ON tf_sales_daily USING GIN(dimensions);
Don't Over-Index:
- Every index slows INSERT/UPDATE
- Monitor query patterns before adding indexes
- Use composite indexes for common filter combinations
FraiseQL Schema Definition¶
Exposing a Fact Table Through a Read View¶
FraiseQL queries fact tables through a v_/tv_ read view, never the tf_ table
directly. The view exposes a public id column plus a data JSONB column built with
jsonb_build_object(...), alongside any measure and dimension columns you want to
aggregate. Define that view in SQL:
CREATE VIEW v_sales AS
SELECT
s.id,
s.revenue,
s.quantity,
s.cost,
s.customer_id,
s.product_id,
s.occurred_at,
s.dimensions->>'category' AS category,
s.dimensions->>'region' AS region,
s.dimensions->>'product_name' AS product_name,
s.dimensions->>'customer_segment' AS customer_segment,
jsonb_build_object(
'id', s.id,
'revenue', s.revenue,
'quantity', s.quantity,
'cost', s.cost,
'category', s.dimensions->>'category',
'region', s.dimensions->>'region',
'product_name', s.dimensions->>'product_name',
'customer_segment', s.dimensions->>'customer_segment',
'occurred_at', s.occurred_at
) AS data
FROM tf_sales s;
Then bind a @fraiseql.type to that view and write a @fraiseql.query resolver that
reads it through the CQRS repository:
import fraiseql
from fraiseql.types import ID
from uuid import UUID
@fraiseql.type(sql_source="v_sales", jsonb_column="data")
class Sales:
id: ID
# Measures (SQL columns)
revenue: float
quantity: int
cost: float
# Dimensions (from dimensions JSONB, surfaced as view columns)
category: str # dimensions->>'category'
region: str # dimensions->>'region'
product_name: str # dimensions->>'product_name'
customer_segment: str # dimensions->>'customer_segment'
# Denormalized filters
customer_id: UUID
product_id: UUID
occurred_at: str
@fraiseql.query
async def sales(info) -> list[Sales]:
"""Read sales rows from the v_sales view."""
db = info.context["db"]
return await db.find("v_sales")
Runtime Auto-Aggregation¶
There is no compile step and no fact_table=True flag. When a GraphQL query selects
aggregate fields on a view-backed type, FraiseQL derives the GROUP BY and aggregate
SQL at runtime and executes it directly against your v_/tv_ view.
The runtime auto-aggregation allowlist is:
SUM, AVG, COUNT, MIN, MAX, ARRAY_AGG, STRING_AGG, BOOL_AND,
BOOL_OR, JSON_AGG, JSONB_AGG.
STDDEV and VARIANCE are not runtime-derived. If you need them, compute them with
raw SQL inside the view (they are valid PostgreSQL aggregates). The same applies to any
GROUP BY / HAVING / FILTER (WHERE …) shaping you want baked into the read view.
Pre-Aggregating in the View (PostgreSQL)¶
For rollups, write the GROUP BY directly in the view SQL — temporal bucketing with
DATE_TRUNC, HAVING filters on aggregates, and grouping on dimension columns are all
standard PostgreSQL:
CREATE VIEW v_sales_by_day AS
SELECT
DATE_TRUNC('day', occurred_at)::date AS day,
dimensions->>'category' AS category,
dimensions->>'region' AS region,
COUNT(*) AS transaction_count,
SUM(revenue) AS revenue_sum,
AVG(revenue) AS revenue_avg,
SUM(quantity) AS quantity_sum
FROM tf_sales
GROUP BY 1, 2, 3
HAVING SUM(revenue) > 0;
Best Practices¶
DO ✅¶
- Use
tf_prefix for all fact tables (any granularity) - Use
td_prefix for dimension tables (ETL reference data) - Store measures as SQL columns (fast aggregation)
- Store dimensions in the
dimensionsJSONB column (flexibility) - Expose fact tables to GraphQL through a
v_/tv_read view (with anidand adataJSONB column) - Index denormalized filter columns
- Create pre-aggregated fact tables (or pre-aggregating views) for common query patterns
- Use temporal bucketing (
DATE_TRUNC) for time-series analysis - Name pre-aggregated tables clearly:
tf_sales_daily,tf_events_monthly
DON'T ❌¶
- Don't store measures in JSONB (10-100x slower)
- Don't use fact tables for low-volume reference data
- Don't update fact records (they should be immutable)
- Don't join tables at query time (FraiseQL doesn't support joins)
- Don't over-index (slows writes)
- Don't mix OLTP and OLAP patterns in same table
Related Specifications¶
- Fact-Dimension Pattern (
../architecture/analytics/fact-dimension-pattern.md) - Detailed pattern explanation - Schema Conventions (
schema-conventions.md) - General schema patterns - Aggregation Operators (
aggregation-operators.md) - Available aggregate functions