Spry LogoOpsfolio
Data Suppliers Guidelines

Database Naming Guidelines

Comprehensive naming conventions for SQL database objects in surveilr. Establishes core principles, the views-over-tables architecture, and practical guidelines for tables, views, columns, indexes, and constraints to ensure consistency and maintainability.

Purpose

This guide establishes clear, consistent naming conventions for all SQL database objects in surveilr. Good naming improves readability, reduces cognitive overhead, and supports long-term maintainability. Poor naming creates confusion, makes debugging harder, and degrades code quality over time.

These standards specifically define the Netspective Operational Truth naming pattern to ensure long-term schema stability through a "Physical-Hidden / Logical-Public" architecture.


Core Principles (Never Break These)

Core Principles

  1. Tables and views are singular nouns. No plurals.
  2. No prefixes or suffixes. No t_, v_, *_table, or *_view.
  3. Lowercase with underscores only. No quotes, no camelCase, no mixed case.
  4. Names describe meaning, not implementation. Don't leak technical details.
  5. Avoid vague, generic terms. No data, details, info, or other non-specific words.
  6. Favor views over tables. Physical tables are internal storage artifacts. Views define the public and stable query surface.

The Core Question Method

Before naming any database object, answer these questions:

For a table or view

"What is one row?" Name that thing.

For a column

"What is one value?" Name that value.

The complexity test

If you can't answer in one sentence, the object is probably doing too much. Refactor before naming.


Why Favor Views Over Tables

Tables should be treated as internal storage artifacts, while views define the public and stable query surface. This structural "friction" ensures that internal refactoring doesn't affect downstream queries and keeps query patterns centralized.

This architectural principle gives us:

  • Long-term flexibility to evolve physical schemas without breaking consumers
  • Centralized access logic - all query patterns defined in one place
  • Schema evolution safety - internal refactoring doesn't affect downstream queries
  • Better developer ergonomics - clean, readable queries become the default

The Internal/Hidden Strategy

To reinforce the "Views Over Tables" behavior structurally, we adopt a naming convention that mirrors the long-standing Linux and Unix convention where a leading dot indicates something is hidden or internal (e.g., .git, .env, or .ssh).

Friction as a Feature

  • Intentional Analogy: These tables are not "forbidden," but they are clearly not the primary interface.
  • Self-Documenting: Engineers intuitively understand that a dotted name signals "you probably shouldn't be touching this directly."
  • Structural Guardrail: The dot is not allowed in unquoted SQL identifiers, making direct access noisy and inconvenient.
  • Path of Least Resistance: Developers naturally gravitate toward the clean, unquoted view names because they are easier to work with.

Cross Platform Multi Database Implementation Results

The following documentation demonstrates the "Operational Truth" strategy. By using ".tablename" in quotes, we ensure that raw tables are visually and syntactically distinct from public reporting views.

SQLite: Persistent Storage

In SQLite, the leading dot creates a clear separation. Most GUI tools will sort these to the top, signaling they are internal implementation details.

Implementation:

-- Hidden Physical Table
CREATE TABLE ".products" (
    "product_id" INTEGER PRIMARY KEY,
    "sku" TEXT UNIQUE,
    "unit_price" DECIMAL(10,2)
);

-- Logical Public View
CREATE VIEW product_catalog AS
SELECT sku, unit_price FROM ".products";

DuckDB: Analytical Transformation

DuckDB uses the hidden tables in SQLite as a source for high-speed analytical transformations.

Implementation:


-- ===============================================================================
-- APPLICATION: Idempotent JSON Extraction & Persistent Views (DuckDB + SQLite)
-- PATTERN: schema.".table_name"
-- FIX: Removed ON CONFLICT for SQLite compatibility
-- ===============================================================================


-- 1. SETUP & ATTACH
-- surveilr handles the engine, but we ensure extensions are ready
INSTALL sqlite;
LOAD sqlite;

-- Attach target SQLite database
ATTACH 'inventory-system.sqlite.db' AS sqlite_db (TYPE SQLITE);

-- 2. MASTER DATA TABLES (SQLite Persistence)
CREATE TABLE IF NOT EXISTS sqlite_db.".products" (
    "product_id"    INTEGER PRIMARY KEY,
    "sku"           TEXT UNIQUE,
    "category"      TEXT,
    "unit_price"    DECIMAL(10,2)
);

CREATE TABLE IF NOT EXISTS sqlite_db.".warehouses" (
    "warehouse_id"  INTEGER PRIMARY KEY,
    "code"          TEXT UNIQUE,
    "location"      TEXT
);

-- 3. RAW INGEST TABLE (Append-only / Immutable)
CREATE TABLE IF NOT EXISTS sqlite_db.".raw_inventory_ingest" (
    "ingest_id"     INTEGER PRIMARY KEY,
    "source_ref"    TEXT UNIQUE, 
    "payload"       TEXT,        
    "ingested_at"   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 4. IDEMPOTENT SEEDING: MASTER DATA
-- Using WHERE NOT EXISTS instead of ON CONFLICT for SQLite compatibility
INSERT INTO sqlite_db.".products" ("sku", "category", "unit_price")
SELECT 'SKU-' || i, CASE WHEN i % 2 = 0 THEN 'Electronics' ELSE 'Hardware' END, (random() * 500 + 10)::DECIMAL(10,2)
FROM range(1, 21) AS t(i)
WHERE NOT EXISTS (SELECT 1 FROM sqlite_db.".products" WHERE "sku" = 'SKU-' || i);

INSERT INTO sqlite_db.".warehouses" ("code", "location")
SELECT 'WH-001', 'North' WHERE NOT EXISTS (SELECT 1 FROM sqlite_db.".warehouses" WHERE "code" = 'WH-001');
INSERT INTO sqlite_db.".warehouses" ("code", "location")
SELECT 'WH-002', 'South' WHERE NOT EXISTS (SELECT 1 FROM sqlite_db.".warehouses" WHERE "code" = 'WH-002');
INSERT INTO sqlite_db.".warehouses" ("code", "location")
SELECT 'WH-003', 'East'  WHERE NOT EXISTS (SELECT 1 FROM sqlite_db.".warehouses" WHERE "code" = 'WH-003');

-- 5. IDEMPOTENT SEEDING: 200 JSON RECORDS
INSERT INTO sqlite_db.".raw_inventory_ingest" ("source_ref", "payload")
SELECT 
    'TXN-' || i AS "source_ref",
    json_object(
        'product_sku', 'SKU-' || (1 + (i % 20)),
        'warehouse_code', 'WH-00' || (1 + (i % 3)),
        'details', json_object(
            'quantity', CAST(random() * 100 AS INTEGER),
            'condition', CASE WHEN i % 5 = 0 THEN 'Refurbished' ELSE 'New' END
        ),
        'tags', json_array('batch-2026', 'audit-pending')
    ) AS "payload"
FROM range(1, 201) AS t(i)
WHERE NOT EXISTS (SELECT 1 FROM sqlite_db.".raw_inventory_ingest" WHERE "source_ref" = 'TXN-' || i);

-- 6. PERSISTENT VIEWS (Created in DuckDB, stored in SQLite)
-- We use ->> to ensure data is extracted as clean strings
DROP VIEW IF EXISTS main.structured_inventory_logic;

CREATE VIEW main.structured_inventory_logic AS
SELECT 
    r."ingest_id",
    r."source_ref",
    r."payload"->>'$.product_sku' AS "sku",
    r."payload"->>'$.warehouse_code' AS "wh_code",
    CAST(r."payload"->>'$.details.quantity' AS INTEGER) AS "qty",
    p."category",
    CAST(p."unit_price" AS DECIMAL(10,2)) AS "unit_price" -- Explicit Cast
FROM sqlite_db.".raw_inventory_ingest" r
JOIN sqlite_db.".products" p ON r."payload"->>'$.product_sku' = p."sku";


-- 3. EXPORT 1: Materialize structured data into SQLite
-- This physically moves the data into the .db file
DROP TABLE IF EXISTS sqlite_db.".structured_inventory";

CREATE TABLE sqlite_db.".structured_inventory" AS 
SELECT * FROM main.structured_inventory_logic;

-- 7. SUMMARY VIEW
DROP VIEW IF EXISTS main.warehouse_stock_levels;

CREATE VIEW main.warehouse_stock_levels AS
SELECT 
    "wh_code",
    "category",
    COUNT(*) AS "transaction_count",
    SUM("qty") AS "total_on_hand",
    -- Now performing math on (INTEGER * DECIMAL)
    ROUND(SUM(CAST("qty" AS DECIMAL(18,2)) * "unit_price"), 2) AS "inventory_value"
FROM main.structured_inventory_logic
GROUP BY 1, 2;


-- 3. EXPORT 1: Materialize structured data into SQLite
-- This physically moves the data into the .db file
DROP TABLE IF EXISTS sqlite_db.".warehouse_stock_levels";

CREATE TABLE sqlite_db.".warehouse_stock_levels" AS 
SELECT * FROM main.warehouse_stock_levels;

-- 8. VERIFICATION
SELECT * FROM main.warehouse_stock_levels ORDER BY "inventory_value" DESC;

Execution Results:

Successful Execution Results

PostgreSQL: Relational Integrity

PostgreSQL requires double quotes for the leading dot to prevent it from being seen as a schema separator.

Implementation:

/*
-- ===============================================================================
-- APPLICATION: Fully Idempotent & Immutable Inventory Management (PostgreSQL)
-- PATTERN: schema.".table_name"
-- FEATURES: Unique Source Refs, Append-only Journaling, Master Data Protection
-- ===============================================================================
*/

-- 1. SCHEMA SETUP (Idempotent)
CREATE SCHEMA IF NOT EXISTS inventory;

-- 2. CLEANUP (For a fresh start in a dev environment)
DROP VIEW IF EXISTS inventory.current_stock;
DROP TABLE IF EXISTS inventory.".stock_transactions";
DROP TABLE IF EXISTS inventory.".products";
DROP TABLE IF EXISTS inventory.".locations";

-- 3. MASTER DATA TABLES
-- Table: .locations
CREATE TABLE inventory.".locations" (
    "location_id"   SERIAL PRIMARY KEY,
    "name"          TEXT NOT NULL UNIQUE,
    "zone"          TEXT,
    "created_at"    TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Table: .products
CREATE TABLE inventory.".products" (
    "product_id"    SERIAL PRIMARY KEY,
    "sku"           TEXT UNIQUE NOT NULL,
    "description"   TEXT,
    "category"      TEXT,
    "created_at"    TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- 4. THE TRUTH TABLE (Appendable & Immutable)
CREATE TABLE inventory.".stock_transactions" (
    "transaction_id" SERIAL PRIMARY KEY,
    "source_ref"     TEXT UNIQUE NOT NULL, -- The Idempotency Key (e.g., 'INV-1001')
    "product_id"     INT REFERENCES inventory.".products"("product_id"),
    "location_id"    INT REFERENCES inventory.".locations"("location_id"),
    "quantity_delta" INT NOT NULL,
    "reason_code"    TEXT CHECK ("reason_code" IN ('PURCHASE', 'SALE', 'ADJUSTMENT', 'RETURN')),
    "recorded_by"    TEXT DEFAULT CURRENT_USER,
    "created_at"     TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- 5. PERFORMANCE INDEXES
CREATE INDEX idx_transactions_product ON inventory.".stock_transactions"("product_id");
CREATE INDEX idx_transactions_ref ON inventory.".stock_transactions"("source_ref");

-- 6. IDEMPOTENT BUSINESS LOGIC
-- This function skips processing if the source_ref already exists.
CREATE OR REPLACE FUNCTION inventory."usp_record_movement"(
    p_source_ref TEXT,
    p_sku TEXT,
    p_location_name TEXT,
    p_qty INT,
    p_reason TEXT
) RETURNS VOID AS $$
DECLARE
    v_prod_id INT;
    v_loc_id INT;
BEGIN
    -- Check if this transaction was already processed (Idempotency check)
    IF EXISTS (SELECT 1 FROM inventory.".stock_transactions" WHERE "source_ref" = p_source_ref) THEN
        RAISE NOTICE 'Transaction % already exists. Skipping.', p_source_ref;
        RETURN;
    END IF;

    -- Look up IDs
    SELECT "product_id" INTO v_prod_id FROM inventory.".products" WHERE "sku" = p_sku;
    SELECT "location_id" INTO v_loc_id FROM inventory.".locations" WHERE "name" = p_location_name;

    IF v_prod_id IS NULL OR v_loc_id IS NULL THEN
        RAISE EXCEPTION 'Product SKU (%) or Location Name (%) not found.', p_sku, p_location_name;
    END IF;

    -- Append to the log (Immutable entry)
    INSERT INTO inventory.".stock_transactions" ("source_ref", "product_id", "location_id", "quantity_delta", "reason_code")
    VALUES (p_source_ref, v_prod_id, v_loc_id, p_qty, p_reason);
END;
$$ LANGUAGE plpgsql;

-- 7. CALCULATION VIEW (The State of Truth)
CREATE OR REPLACE VIEW inventory.current_stock AS
SELECT 
    p."sku",
    l."name" AS "location_name",
    SUM(t."quantity_delta") AS "on_hand"
FROM inventory.".products" p
JOIN inventory.".stock_transactions" t ON p."product_id" = t."product_id"
JOIN inventory.".locations" l ON t."location_id" = l."location_id"
GROUP BY p."sku", l."name"
HAVING SUM(t."quantity_delta") <> 0;

-- 8. SEED DATA (20 Idempotent Records)
DO $$
BEGIN
    -- Seed Master Data
    INSERT INTO inventory.".locations" ("name", "zone") VALUES 
    ('Warehouse-A', 'Bulk'), ('Store-Front', 'Retail'), ('Return-Zone', 'Quarantine') ON CONFLICT DO NOTHING;

    INSERT INTO inventory.".products" ("sku", "description", "category") VALUES 
    ('TSHIRT-L', 'Large Cotton T-Shirt', 'Apparel'),
    ('JEANS-32', 'Denim Jeans Size 32', 'Apparel'),
    ('CAP-RED', 'Red Baseball Cap', 'Accessories') ON CONFLICT DO NOTHING;

    -- Seed Transactions (Append-only)
    -- Purchases
    PERFORM inventory."usp_record_movement"('REF-001', 'TSHIRT-L', 'Warehouse-A', 100, 'PURCHASE');
    PERFORM inventory."usp_record_movement"('REF-002', 'JEANS-32', 'Warehouse-A', 50, 'PURCHASE');
    PERFORM inventory."usp_record_movement"('REF-003', 'CAP-RED', 'Warehouse-A', 200, 'PURCHASE');

    -- Stock Movement (Transfer from Warehouse to Store)
    PERFORM inventory."usp_record_movement"('REF-004', 'TSHIRT-L', 'Warehouse-A', -20, 'ADJUSTMENT');
    PERFORM inventory."usp_record_movement"('REF-005', 'TSHIRT-L', 'Store-Front', 20, 'ADJUSTMENT');

    -- Sales
    PERFORM inventory."usp_record_movement"('REF-006', 'TSHIRT-L', 'Store-Front', -1, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-007', 'TSHIRT-L', 'Store-Front', -2, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-008', 'JEANS-32', 'Warehouse-A', -5, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-009', 'CAP-RED', 'Warehouse-A', -10, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-010', 'TSHIRT-L', 'Store-Front', -1, 'SALE');

    -- Returns
    PERFORM inventory."usp_record_movement"('REF-011', 'TSHIRT-L', 'Return-Zone', 1, 'RETURN');

    -- More Transactions to reach 20
    PERFORM inventory."usp_record_movement"('REF-012', 'JEANS-32', 'Warehouse-A', -2, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-013', 'CAP-RED', 'Warehouse-A', -50, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-014', 'CAP-RED', 'Warehouse-A', 10, 'PURCHASE');
    PERFORM inventory."usp_record_movement"('REF-015', 'JEANS-32', 'Warehouse-A', -1, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-016', 'TSHIRT-L', 'Store-Front', -3, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-017', 'CAP-RED', 'Warehouse-A', -2, 'SALE');
    PERFORM inventory."usp_record_movement"('REF-018', 'JEANS-32', 'Warehouse-A', 5, 'PURCHASE');
    PERFORM inventory."usp_record_movement"('REF-019', 'TSHIRT-L', 'Warehouse-A', -5, 'ADJUSTMENT');
    PERFORM inventory."usp_record_movement"('REF-020', 'TSHIRT-L', 'Store-Front', 5, 'ADJUSTMENT');

END $$;

-- 9. VERIFICATION
SELECT * FROM inventory.current_stock;

Execution Results:

Successful Execution Results

SQL Server: Enterprise Auditing

SQL Server supports the pattern through SET QUOTED_IDENTIFIER ON. The dot remains inside the brackets or double quotes.

Implementation:

-- Required for allowing dots/special characters in object names
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO

IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'sales')
BEGIN
    EXEC('CREATE SCHEMA sales');
END
GO

-- 1. Create Tables using the schema.".tablename" pattern
CREATE TABLE sales.".customers" (
    "customer_id"   INT IDENTITY(1,1) PRIMARY KEY,
    "full_name"     NVARCHAR(255) NOT NULL,
    "email"         NVARCHAR(255) NOT NULL,
    "created_at"    DATETIME2 DEFAULT GETDATE(),
    "created_by"    NVARCHAR(255) DEFAULT SUSER_SNAME()
);

CREATE TABLE sales.".orders" (
    "order_id"      INT IDENTITY(1,1) PRIMARY KEY,
    "customer_id"   INT NOT NULL,
    "order_date"    DATETIME2 DEFAULT GETDATE(),
    "total_amount"  DECIMAL(18, 2),
    "status"        NVARCHAR(50) DEFAULT 'pending',
    CONSTRAINT FK_orders_customer FOREIGN KEY ("customer_id") 
        REFERENCES sales.".customers"("customer_id")
);

-- 2. Create Indexes
CREATE INDEX IX_orders_customer ON sales.".orders"("customer_id");
GO

-- 3. Create Procedure using the pattern
CREATE PROCEDURE sales."usp_create_order"
    @customer_id INT,
    @amount DECIMAL(18,2)
AS
BEGIN
    INSERT INTO sales.".orders" ("customer_id", "total_amount")
    VALUES (@customer_id, @amount);
END;
GO

-- 4. Seed Data
INSERT INTO sales.".customers" ("full_name", "email") 
VALUES ('John Doe', 'john@example.com');

EXEC sales."usp_create_order" @customer_id = 1, @amount = 99.50;

-- 5. Final Query (Note the mandatory double quotes)
SELECT 
    c."full_name", 
    o."total_amount", 
    o."status"
FROM sales.".customers" AS c
JOIN sales.".orders" AS o ON c."customer_id" = o."customer_id";
GO

Execution Results:

Successful Execution Results

ClickHouse: Real-time Analytics

ClickHouse implementation uses backquotes to wrap the dotted table names, maintaining consistency even in OLAP environments.

Implementation:

WITH 
-- Mocking your ".products" table
`".products"` AS (
    SELECT 1 AS product_id, 'SKU-001' AS sku, 'Electronics' AS category, 299.99 AS unit_price
),
-- Mocking your ".raw_inventory_ingest" table
`".raw_inventory_ingest"` AS (
    SELECT 101 AS ingest_id, 'TXN-A1' AS source_ref, '{"product_sku":"SKU-001", "qty":5}' AS payload
)
-- Testing the extraction and join logic
SELECT 
    r.ingest_id,
    JSONExtractString(r.payload, 'product_sku') AS extracted_sku,
    JSONExtractInt(r.payload, 'qty') AS qty,
    p.category,
    p.unit_price * qty AS total_value
FROM `".raw_inventory_ingest"` AS r
JOIN `".products"` AS p ON JSONExtractString(r.payload, 'product_sku') = p.sku;

Execution Results:

execution log showing success

ORM Implementation

When using an ORM, you map the model name to the physical hidden table name using specific mapping attributes.

Example (SQLAlchemy):

import datetime
from sqlalchemy import create_engine, Column, Integer, String, DateTime, text
from sqlalchemy.orm import declarative_base, sessionmaker

# 1. Use modern SQLAlchemy 2.0 base
Base = declarative_base()

class Customer(Base):
    # This exactly matches your requirement for leading dots
    # SQLite will create a table named: [sales.".customers"]
    __tablename__ = 'sales.".customers"' 
    
    customer_id = Column(Integer, primary_key=True, autoincrement=True)
    full_name = Column(String(255), nullable=False)
    created_at = Column(DateTime, default=datetime.datetime.utcnow)

# 2. Use 'echo=True' to see the generated SQL in your terminal
engine = create_engine('sqlite:///orm-leadingdot-check.sqlite.db', echo=True)

# 3. Create the table
Base.metadata.create_all(engine)

# 4. Verify insertion
Session = sessionmaker(bind=engine)
with Session() as session:
    new_user = Customer(full_name="Anitha Varghese")
    session.add(new_user)
    session.commit()
    print("\nSuccess! Data inserted into the dotted table.")

Execution Results: Terminal execution log showing successful table creation


Results Summary

EnvironmentHidden Table SyntaxQuoting Strategy
SQLite".tablename"Double Quotes
DuckDB".tablename"Schema-prefixed Double Quotes
PostgreSQL".tablename"Schema-prefixed Double Quotes
SQL Server".tablename"Quoted Identifiers (" ")
ClickHouse".tablename"Backquotes
ORM@map(".tablename")Model Mapping

Pros and Cons of the Ideology

Pros (The "Internal Logic" Win)

  • Refactoring Freedom: You can replace .customer_v1 with .customer_v2 and simply point the customer view to the new one without breaking application code.
  • Safety Guardrail: Prevents SELECT * in applications because wrapper views explicitly list columns, acting as a fixed contract.
  • Visual Hierarchy: It is immediately obvious which objects are "Raw Data" (Hidden) and which are "Clean API" (Views).

Cons (The "Operational" Cost)

  • Escape Character Fatigue: Developers must remember to use ", [], or ` for raw tables. This is intentional friction to discourage direct table access, but it may impact emergency debugging speed.
  • DML Limitations: Inserting into complex views may require INSTEAD OF triggers or direct insertion into the hidden physical tables. For simple wrapper views, inserts may work directly depending on the RDBMS.
  • Portability: The . convention is not universal. Use dots for SQLite/DuckDB but underscores for Postgres/SQL Server/Oracle/MySQL.

Wrapper Views

For every hidden physical table (dotted or underscored) that needs to be accessed, we generate a wrapper view whose name is the table name without the leading dot or underscore.

Key characteristics:

  • View name: customer (no prefix)
  • Table name: .customer or _customer (with prefix)
  • Explicitly list columns rather than using SELECT *
  • Protects downstream queries from accidental schema drift
  • Makes the view a clear contract

Automated Wrapper View Generator (SQLite)

To avoid hand-maintaining these wrappers, views can be generated automatically using SQLite system metadata.It will help you enforce the "Physical-Hidden / Logical-Public" architecture without manual overhead.The script emits DROP VIEW IF EXISTS followed by CREATE VIEW statements, so rerunning it always recreates fresh, deterministic views. .It ensures that every table prefixed with a dot gets a corresponding clean view that explicitly lists columns to protect against schema drift.

/*
Enforces separation between physical storage and query access.
*/

WITH dotted_tables AS (
  SELECT
    s.name AS table_name,
    substr(s.name, 2) AS view_name
  FROM sqlite_schema AS s
  WHERE s.type = 'table'
    AND s.name LIKE '.%'
    AND s.name NOT LIKE 'sqlite_%'
),
column_lists AS (
  SELECT
    dt.table_name,
    dt.view_name,
    (
      SELECT group_concat(quote(p.name), ', ')
      FROM pragma_table_info(dt.table_name) AS p
      ORDER BY p.cid
    ) AS col_list
  FROM dotted_tables AS dt
),
ddl AS (
  -- 1. Drop existing wrapper views for idempotency 
  SELECT
    dt.view_name AS object_name,
    10 AS ord,
    'DROP VIEW IF EXISTS ' || quote(dt.view_name) || ';' AS sql
  FROM dotted_tables AS dt

  UNION ALL

  -- 2. Recreate wrapper views with explicit column lists 
  SELECT
    cl.view_name AS object_name,
    20 AS ord,
    'CREATE VIEW ' || quote(cl.view_name)
    || ' AS SELECT '
    || cl.col_list
    || ' FROM '
    || quote(cl.table_name)
    || ';' AS sql
  FROM column_lists AS cl
  WHERE cl.col_list IS NOT NULL
    AND cl.col_list <> ''
)
SELECT sql
FROM ddl
ORDER BY object_name, ord;

Usage Instructions

Below is a clear and correct way to express the cat-based commands for SQLite, aligned with your usage instructions.

Using cat to generate and execute wrapper views

Generate the wrapper SQL file:

cat views-for-dotted-tables.sql | sqlite3 your_db.db > wrapper_views.sql

Execute the generated wrapper views:

cat wrapper_views.sql | sqlite3 your_db.db

Notes

  • The first command feeds views-for-dotted-tables.sql into sqlite3 and captures the generated output in wrapper_views.sql.
  • The second command executes the generated SQL against the same database.
  • This is functionally equivalent to using input redirection (<) but may be preferable when composing pipelines or scripts.

Tables

Physical tables represent stored data and should be treated as internal implementation details.

Guidelines

  • Use concrete domain nouns: user, device, policy, audit_event
  • Relationship tables use meaningful compound nouns: user_role, account_membership, policy_control
  • Tables must be singular and use lowercase with underscores
  • Avoid vague nouns: data, info, record, item (unless your domain genuinely uses these terms)
  • Prefix physical tables with a leading dot (SQLite/DuckDB) or underscore (other RDBMS) to discourage direct querying

Examples

BadGoodWhy
users.userSingular with leading prefix
t_device.deviceNo prefix, use hidden convention instead
policy_data.policyNo vague terms
user_info.userNo generic suffixes
device_table.deviceNo type suffixes

Physical Table Storage

Physical tables are internal implementation details. This abstraction allows for Refactoring Safety: if we must replace .customer with .customer_v2, the public view customer remains stable, preventing any downtime for downstream consumers.

Table NamePurposePlatform Compatibility
.customerInternal storage for customersSQLite/DuckDB/PostgreSQL/SqlServer/ORM
.customer_orderInternal storage for ordersSame
.order_lineInternal storage for order line itemsSame
.productInternal storage for productsSame

Ergonomics Examples: Tables vs Views

Example 1: Simple Table vs Wrapper View

Physical table (discouraged to query directly):

-- Table name: .customer 
SELECT *
FROM ".customer"; 

Even a simple filter forces quoting (SQLite) or feels "off":

-- SQLite
SELECT name, email
FROM ".customer"
WHERE status = 'active';

-- PostgreSQL
SELECT name, email
FROM _customer
WHERE status = 'active';

Wrapper view (intended access surface):

-- View name: customer
SELECT name, email
FROM customer
WHERE status = 'active';

Path of Least Resistance

The path of least resistance is obvious. Clean and natural.

Example 2: Joins Amplify the Inconvenience

Physical tables: .customer_order, .order_line, .product (or underscored equivalents)

Direct join on tables (awkward and noisy):

-- SQLite
SELECT
  o.id,
  p.sku,
  ol.quantity
FROM ".customer_order" AS o
JOIN ".order_line" AS ol ON ol.order_id = o.id
JOIN ".product" AS p ON p.id = ol.product_id;

-- PostgreSQL
SELECT
  o.id,
  p.sku,
  ol.quantity
FROM _customer_order AS o
JOIN _order_line AS ol ON ol.order_id = o.id
JOIN _product AS p ON p.id = ol.product_id;

Every identifier must be quoted (SQLite) or prefixed with underscore (PostgreSQL), and the query is visually cluttered.

Wrapper views: customer_order, order_line, product

Join via views (clean and readable):

SELECT
  o.id,
  p.sku,
  ol.quantity
FROM customer_order AS o
JOIN order_line AS ol ON ol.order_id = o.id
JOIN product AS p ON p.id = ol.product_id;

Everything reads normally without quoting or cognitive friction.

Example 3: Layered Logic Belongs in Views

Physical table: .invoice

Base wrapper view: invoice

Derived view with logic: invoice_open

Table access (logic repeated everywhere):

SELECT *
FROM ".invoice"  
WHERE paid_at IS NULL
  AND canceled_at IS NULL;

Canonical wrapper view definition (written once):

CREATE VIEW invoice AS
SELECT
  invoice_id,
  customer_id,
  amount,
  issued_at,
  paid_at,
  canceled_at
FROM ".invoice"; 

Derived view with business logic:

CREATE VIEW invoice_open AS
SELECT
  invoice_id,
  customer_id,
  amount,
  issued_at
FROM invoice
WHERE paid_at IS NULL
  AND canceled_at IS NULL;

Developer usage:

SELECT *
FROM invoice_open;

The hidden physical table becomes effectively invisible in day-to-day work.

Example 4: Refactoring Safety

Scenario: Physical table renamed from .customer to .customer_v2

Wrapper view stays stable: customer

Old queries keep working:

SELECT id, name
FROM customer;  

Schema Evolution Safety

Only the wrapper view definition changes. No downstream SQL needs to be touched. This is schema evolution safety in action.


Views

Views are the public access surface that all consumers should use for querying and reporting.

Guidelines

  • Name the resulting concept, not the operation
  • If the view represents "latest", "current", or "active" data, include that in the noun phrase
  • Views should be singular nouns in lowercase with underscores
  • No prefixes like v_ or suffixes like _view
  • Avoid technical or implementation-specific names

Wrapper View Pattern

For every hidden physical table, create a wrapper view without the leading prefix:

CREATE VIEW customer AS
SELECT
  customer_id,
  name,
  email,
  status,
  created_at,
  updated_at
FROM ".customer";

Why Explicit Column Lists Matter

  • Protects against accidental schema drift
  • Changes to underlying tables don't silently propagate
  • Makes the view a clear, documented contract
  • Never use SELECT * in wrapper views - always list columns explicitly

Derived Views

Complex or recurrent logic should be encapsulated in derived views.

BadGoodWhy
device_filteredcompliant_deviceNames the concept, not the operation
user_latestactive_userDescribes the semantic meaning
evidence_rollup_viewlatest_policy_evidenceNo _view suffix
v_current_policycurrent_policyNo v_ prefix

Example derived view:

CREATE VIEW invoice_open AS
SELECT
  invoice_id,
  customer_id,
  amount,
  issued_at
FROM invoice
WHERE paid_at IS NULL
  AND canceled_at IS NULL;

This consolidates business logic in one place and improves maintainability.


Columns

Primary Keys

  • Pattern: <tablename>_id
  • Example: user_id, device_id, policy_id

Foreign Keys

  • Pattern: <referenced_table>_id
  • Example: user_id (references user), policy_id (references policy)

Timestamps

Be explicit about semantic meaning:

ContextColumn NameUse When
Record creationcreated_atTimestamp of row creation
Record modificationupdated_atTimestamp of last update
Observation timeobserved_atWhen data was observed in the real world
Ingestion timeingested_atWhen data entered the system
Effective dateeffective_atWhen something becomes active
Expiration dateexpires_atWhen something becomes inactive

Booleans

Use prefixes that read naturally in conditionals:

  • is_active, is_compliant, is_verified
  • has_permission, has_access, has_expired
  • can_read, can_write, can_delete

Abbreviations

Avoid abbreviations unless they're universal in your team's domain.

BadGood
usr_nmuser_name
dev_stsdevice_status
pol_eff_dtpolicy_effective_at

Indexes

Pattern

  • Default: <table>_<column>_idx
  • Composite: <table>_<col1>_<col2>_idx
  • Unique: <table>_<column>_uniq or <table>_<col1>_<col2>_uniq

Examples

BadGoodWhy
idx_1user_email_idxDescriptive, debuggable
user_idxuser_created_at_idxSpecifies the column
ix_devicedevice_serial_number_uniqClear uniqueness constraint

Why This Matters

During incidents, you need to know immediately what an index does. Generic names are useless.


Constraints

Guidelines

  • Name the rule, not the mechanism
  • Make the business logic clear from the name
  • Avoid auto-generated names in production schemas

Examples

BadGood
check_1user_email_must_be_unique
fk_user_devicedevice_user_id_references_user
constraint_abcpolicy_effective_at_required

Consistency Checks

Vocabulary Consistency

  • Pick one term and stick with it: device_status vs device_state — choose one
  • No mixed vocabulary: customer vs client vs tenant — choose one canonical noun
  • Document your choices: Maintain a glossary of domain terms

No Query Intent in Names

Avoid encoding usage patterns in object names:

  • reporting_user
  • tmp_device
  • test_policy
  • adhoc_audit

No Leaking Technical Details

Don't expose storage or implementation choices unless it represents a stable architectural layer:

  • json_data
  • raw_events
  • staging_user (unless "staging" is a genuine domain concept)

surveilr-Specific Conventions

Core Domain Nouns

Treat these as first-class nouns in the surveilr domain model:

  • Resource: resource, uniform_resource, file_resource, email_message
  • Evidence: policy_evidence, control_evidence, evidence_assertion
  • Provenance: provenance_event (distinct from audit_event)

Time Semantics

Use ingested_ or observed_ prefixes only when time semantics are critical:

  • ingested_at — when data entered surveilr
  • observed_at — when data was observed in the source system

Audit vs Provenance

Keep these concepts distinct:

  • audit_event — compliance and security audit trails
  • provenance_event — data lineage and transformation history

Quick Review Script

Say these sentences out loud. If they sound awkward, rename the object.

For tables and views:

Row Test

"One row in [object_name] is a ______."

If that sentence sounds weird, rename it.

For columns:

Value Test

"This column is the ______ of the ______."

If you need to hand-wave or explain, rename the column.


Summary of Best Practices

  1. Favor views over tables — make views the public and stable query surface.
  2. Use the "Hidden" convention — Use a leading dot (.) for SQLite/DuckDB or leading underscore (_) for PostgreSQL/SQL Server/Oracle/MySQL for all physical tables.
  3. Respect platform constraints — Follow the spirit of the "internal" signal while respecting the specific RDBMS quoting rules.
  4. Generate wrapper views — Use automated scripts to ensure views stay in sync with hidden physical tables.
  5. Contractual Wrapper Views — Never use SELECT * in wrapper views; explicitly list columns to protect against accidental schema drift.
  6. Existing Databases — If data is already present and migration to hidden names is "too hard," consult the architecture lead before proceeding.

Implementation Guide

Generating Wrapper Views

Use the automated view generator script(described above) to create and refresh wrapper views:

How it works:

  1. Queries SQLite system metadata to find all hidden physical tables (dotted or underscored)
  2. Emits DROP VIEW IF EXISTS statements for idempotency
  3. Generates CREATE VIEW statements with explicit column lists
  4. Designed to run as a generator with output executed in a second pass

Or in a single transaction from application code.

DML Operations on Views

Simple wrapper views: Most RDBMS allow direct INSERT, UPDATE, and DELETE operations on simple views that map 1:1 to a single table.

Complex views: Views with joins, aggregations, or complex logic may require:

  • INSTEAD OF triggers (SQL Server, PostgreSQL, Oracle)
  • Direct table access for write operations
  • Stored procedures that encapsulate the write logic

Consult your RDBMS documentation for specific view updatability rules.

Migration Strategy

When adopting this pattern in existing schemas:

  1. Rename existing tables to hidden versions (e.g., customer.customer )
  2. Generate wrapper views using the script
  3. Test thoroughly - all existing queries should work unchanged
  4. Update documentation to reference views, not tables
  5. Establish the pattern for all new tables going forward

Performance Considerations

View overhead: Modern query optimizers typically inline simple wrapper views with minimal overhead. For complex derived views, consider:

  • Indexed views / materialized views for frequently-accessed aggregations
  • Query plan analysis to verify optimization
  • Periodic performance testing as data volumes grow

The benefits of maintainability and schema stability typically outweigh minor performance costs.


Key Takeaways

The Core Insight

The hidden table strategy does not forbid table access—it just makes it unpleasant. Developers naturally gravitate toward the clean, readable option. Views become the default habit, not because of policy, but because they are simply easier to work with.

Over time, tables fade into the background as implementation details, which is exactly where we want them. This gives surveilr the flexibility to evolve schemas safely while maintaining a stable, well-documented public interface for all SQL consumers.


How is this guide?

Last updated on

On this page