Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Metadata Introspection

SCHEMABOUND’s metadata introspection surface — exposed through the schemabound::mirror module — gives agents a deep, structured view of the underlying database schema. Beyond basic table-and-column enumeration, it surfaces triggers, user-defined types, and ORM field-mapping heuristics so that agents can reason about data contracts and change semantics without manual annotation.

What Is Introspected

PrimitiveStructKey Fields
Tablemirror::Tablename, columns, indexes, unique_indexes, foreign_keys, triggers, field_mappings
Columnmirror::Columnname, sql_type, nullable, primary_key, default_value, enum_values
Non-unique indexmirror::Indexname, columns
Unique indexmirror::UniqueIndexname, columns
Foreign keymirror::ForeignKeyfrom_column, to_table, to_column, on_delete, on_update
Composite FKmirror::CompositeForeignKeyfrom_columns, to_table, to_columns, …
Triggermirror::Triggername, event, timing, table_name, body
User-defined typemirror::UserDefinedTypename, base_type, check_constraint, nullable, default_value
Field mappingmirror::FieldMappinglogical_name, physical_name, orm_convention, notes

The full model is returned as a SchemaModel:

#![allow(unused)]
fn main() {
pub struct SchemaModel {
    pub tables: Vec<Table>,
    pub user_defined_types: Vec<UserDefinedType>,
}
}

Trigger Discovery

Triggers represent procedural data-change logic embedded in the database. Surfacing them allows agents to warn about side-effects, model cascading writes, and fire TriggerFired events.

#![allow(unused)]
fn main() {
use schemabound::mirror::introspect_sqlite_path;

let schema = introspect_sqlite_path("path/to/database.db")?;
for table in &schema.tables {
    for trigger in &table.triggers {
        println!(
            "[{}] TRIGGER {} {} {} ON {}",
            table.name, trigger.name, trigger.timing, trigger.event, trigger.table_name
        );
        // timing: BEFORE | AFTER | INSTEAD OF
        // event:  INSERT | UPDATE | DELETE | UPDATE OF <col>
    }
}
}

Trigger Struct

#![allow(unused)]
fn main() {
pub struct Trigger {
    pub name: String,
    /// INSERT | UPDATE | DELETE | UPDATE OF <col>
    pub event: String,
    /// BEFORE | AFTER | INSTEAD OF
    pub timing: String,
    pub table_name: String,
    pub body: String,  // the raw CREATE TRIGGER body
}
}

TriggerFired Event (EventBus)

When the SCHEMABOUND runtime detects that a mutation query will fire a trigger, it emits a TriggerFired variant on the EventBus:

#![allow(unused)]
fn main() {
use schemabound::interceptor::{get_event_bus, Event};

get_event_bus().register_subscriber(Box::new(|event: &Event| {
    if let Event::TriggerFired { trigger_name, table_name, .. } = event {
        log::warn!("Trigger {} fired on {}", trigger_name, table_name);
    }
}))?;
}

User-Defined Types

SQLite encodes custom types as columns with CHECK constraints. The introspector discovers these and promotes them to first-class UserDefinedType entries.

#![allow(unused)]
fn main() {
pub struct UserDefinedType {
    pub name: String,
    pub base_type: String,             // TEXT, INTEGER, REAL, …
    pub check_constraint: Option<String>,
    pub nullable: bool,
    pub default_value: Option<String>,
}
}

Example — a table with an enum-like column:

CREATE TABLE orders (
    status TEXT NOT NULL CHECK(status IN ('pending','shipped','cancelled'))
);

The introspector produces:

{
  "name": "status",
  "base_type": "TEXT",
  "check_constraint": "status IN ('pending','shipped','cancelled')",
  "nullable": false,
  "default_value": null
}

This is also surfaced per-column via Column.enum_values.

ORM Field-Mapping Heuristics

Physical column names often differ from the logical names that application layers — Hibernate, Entity Framework, ActiveRecord — use. SCHEMABOUND detects the convention from naming patterns and generates a FieldMapping per column that appears to be ORM-managed.

#![allow(unused)]
fn main() {
pub struct FieldMapping {
    pub logical_name: String,
    pub physical_name: String,
    /// "hibernate" | "ef" | "ef_shadow"
    pub orm_convention: String,
    pub notes: Option<String>,
}
}

Convention Detection Rules

PatternConventionExample
camelCasehibernateorderIdorder_id
PascalCaseefOrderIdorder_id
_prefixef_shadow_tenantIdTenantId

These heuristics enable agents to translate between LLM-generated column names and physical database column names without user annotations.

The MirrorProvider Trait

The MirrorProvider async trait lets you plug in any database backend:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait MirrorProvider: Send + Sync {
    async fn introspect_schema(&self) -> Result<SchemaModel, String>;
}
}

The built-in implementation is SqliteMirrorProvider:

#![allow(unused)]
fn main() {
use schemabound::{MirrorProvider, SqliteMirrorProvider};

let provider = SqliteMirrorProvider::new("path/to/database.db");
let schema = provider.introspect_schema().await?;
}

To extend SCHEMABOUND to a new database engine, implement MirrorProvider and register the provider with the GrpcExecutor builder:

#![allow(unused)]
fn main() {
let executor = GrpcExecutor::builder()
    .mirror(MyPostgresMirrorProvider::new(&connection_string))
    .build()?;
}

Proto Surface

The introspected metadata is exposed over gRPC via GetSchemaResponse and GetTableResponse (see SchemaService proto):

message GetSchemaResponse {
  string schema_id                            = 1;
  string database_type                        = 2;
  string generated_at                         = 3;
  repeated TableDef tables                    = 4;
  repeated UserDefinedTypeDef user_defined_types = 5;
}

message GetTableResponse {
  string generated_at = 1;
  TableDef table      = 2;
}

message TableDef {
  string name                              = 1;
  repeated ColumnDef columns               = 2;
  repeated IndexDef indexes                = 3;   // is_unique distinguishes unique from regular
  repeated TriggerDef triggers             = 4;
  repeated FieldMappingDef field_mappings  = 5;
}

message TriggerDef {
  string name   = 1;
  string timing = 2;
  string event  = 3;
  string body   = 4;
}

message UserDefinedTypeDef {
  string name      = 1;
  string base_type = 2;
  repeated string variants = 3;
}

message FieldMappingDef {
  string column_name   = 1;
  string logical_name  = 2;
  string convention    = 3;
}

The Rust conversion function is table_to_table_def:

#![allow(unused)]
fn main() {
use schemabound::grpc_executor::table_to_table_def;

let proto_def = table_to_table_def(&schema.tables[0]);
}

Future Work

  • MSSQL introspectorMssqlMirrorProvider for SQL Server schemas, complex UDTs, CLR triggers
  • PostgreSQL introspector — domain types, row-level security policies, event triggers
  • Computed column detection — flag virtual/generated columns explicitly
  • Partition metadata — surface range/list/hash partitioning from supported engines