Contents
- SQL Integration Guide
- Table of Contents
- Core API Functions
- EDN Helper Functions
- Entity Helper Functions
- Operational Functions
- EDN Functions (edn type)
- Temporal Queries
- Pagination
- Hybrid SQL/Datalog Patterns
- GUC Configuration Parameters
- Internal Schema
- Store Management
- Store-Aware Functions
- Virtual Table Views
- Materialized Views
- Time-Travel Queries
- Streaming Subscriptions
- Recursive Query Translation
- Security Considerations
SQL Integration Guide
pg_mentat exposes a Datomic-compatible Datalog engine as a set of PostgreSQL functions. Every operation – schema definition, transactions, queries, pulls, and introspection – is available through standard SQL function calls from any PostgreSQL client.
This guide covers the complete SQL function API, EDN helper functions, batch operations, hybrid SQL/Datalog patterns, and operational tools.
Table of Contents
- Core API Functions
- EDN Helper Functions
- Entity Helper Functions
- Operational Functions
- EDN Functions (edn type)
- Temporal Queries
- Pagination
- Hybrid SQL/Datalog Patterns
- GUC Configuration Parameters
Core API Functions
Since 1.9.0 the core functions are
edn_t,edn_q,edn_pull(andedn_evalinscriptbuilds). The pre-1.9.0 namesmentat_transact,mentat_query,mentat_pull,mentat_evalstill work as deprecated wrappers.
edn_t
Process EDN transactions: assert facts, retract facts, and retract entire entities.
edn_t(edn_tx TEXT) -> TEXT
Schema definition:
SELECT edn_t('[
{:db/ident :person/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :person/email
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity}
{:db/ident :person/age
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one}
{:db/ident :person/friends
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many}
]');
Assert facts (map form):
SELECT edn_t('[
{:db/id "alice"
:person/name "Alice"
:person/email "alice@example.com"
:person/age 30}
{:db/id "bob"
:person/name "Bob"
:person/email "bob@example.com"
:person/friends "alice"}
]');
Assert facts (list form):
SELECT edn_t('[
[:db/add "alice" :person/name "Alice"]
[:db/add "alice" :person/email "alice@example.com"]
]');
Retract individual facts:
SELECT edn_t('[
[:db/retract 10042 :person/name "Alice"]
]');
Retract entity (all facts):
SELECT edn_t('[
[:db/retractEntity 10042]
]');
Return value: JSON string containing the transaction report with tx-id, tempids map, and other metadata.
Schema attributes:
| Attribute | Type | Description |
|---|---|---|
:db/ident |
keyword | Attribute name (required) |
:db/valueType |
ref | One of :db.type/string, :db.type/long, :db.type/double, :db.type/boolean, :db.type/instant, :db.type/keyword, :db.type/ref, :db.type/uuid, :db.type/bytes |
:db/cardinality |
ref | :db.cardinality/one or :db.cardinality/many |
:db/unique |
ref | :db.unique/value or :db.unique/identity (optional) |
:db/index |
boolean | Enable AVET index for this attribute (optional) |
:db/fulltext |
boolean | Enable full-text search (optional) |
:db/isComponent |
boolean | Mark as component (cascade delete) (optional) |
:db/noHistory |
boolean | Disable history tracking (optional) |
:db/doc |
string | Documentation string (optional) |
edn_q
Execute a Datalog query with optional inputs and temporal modifiers.
edn_q(query TEXT, inputs JSONB) -> JSONB
Basic query:
SELECT edn_q('
[:find ?name ?email
:where
[?e :person/name ?name]
[?e :person/email ?email]]
', '{}');
With input parameters:
SELECT edn_q('
[:find ?name
:in $ ?min-age
:where
[?e :person/name ?name]
[?e :person/age ?age]
[(> ?age ?min-age)]]
', '{"min-age": 25}');
Find specifications:
| Find Spec | Example | Returns |
|---|---|---|
| Relation (default) | [:find ?name ?age ...] |
Array of tuples: [[\"Alice\", 30], [\"Bob\", 25]] |
| Tuple | [:find ?name ?age . ...] (dot) |
Single tuple: [\"Alice\", 30] |
| Collection | [:find [?name ...] ...] |
Flat array: [\"Alice\", \"Bob\"] |
| Scalar | [:find ?name . ...] |
Single value: \"Alice\" |
Aggregates:
SELECT edn_q('
[:find (count ?e) (avg ?age) (max ?age) (min ?age)
:where
[?e :person/age ?age]]
', '{}');
Supported aggregates: count, sum, avg, min, max.
Predicates:
SELECT edn_q('
[:find ?name
:where
[?e :person/name ?name]
[?e :person/age ?age]
[(>= ?age 30)]
[(!= ?name "Admin")]]
', '{}');
Supported predicates: >, <, >=, <=, =, !=.
OR and NOT clauses:
SELECT edn_q('
[:find ?name
:where
[?e :person/name ?name]
(or [?e :person/department ?eng]
[?e :person/department ?sales])
(not [?e :person/age 99])]
', '{}');
Rules (recursive):
SELECT edn_q('
[:find ?boss-name
:in $ ?emp-name
:where
[?e :person/name ?emp-name]
(reports-to ?e ?boss)
[?boss :person/name ?boss-name]]
:rules [
[(reports-to ?e ?boss) [?e :person/manager ?boss]]
[(reports-to ?e ?boss)
[?e :person/manager ?mid]
(reports-to ?mid ?boss)]]
', '{"emp-name": "Dave Kim"}');
Full-text search:
SELECT edn_q('
[:find ?name ?score
:where
[(fulltext $ :person/bio "database systems") [[?e _ ?score]]]
[?e :person/name ?name]]
', '{}');
Return value: JSONB with results array.
edn_pull
Pull attributes for a single entity using a pull pattern.
edn_pull(pattern TEXT, entity_id BIGINT) -> JSONB
Wildcard pull (all attributes):
SELECT edn_pull('[*]', 10042);
Specific attributes:
SELECT edn_pull('[:person/name :person/email]', 10042);
Nested ref traversal:
SELECT edn_pull('[
:person/name
{:person/department [:dept/name :dept/budget]}
{:person/friends [:person/name :person/email]}
]', 10042);
Reverse lookups (who references this entity?):
SELECT edn_pull('[:person/name :person/_friends]', 10042);
Limits and defaults:
SELECT edn_pull('[
(:person/friends :limit 5)
(:person/bio :default "N/A")
]', 10042);
Recursive pulls:
-- Pull manager chain up to 3 levels
SELECT edn_pull('[
:person/name
{:person/manager 3}
]', 10042);
mentat_pull_many
Pull attributes for multiple entities at once.
mentat_pull_many(pattern TEXT, entity_ids BIGINT[]) -> JSONB
SELECT mentat_pull_many(
'[:person/name :person/email :person/age]',
ARRAY[10042, 10043, 10044]
);
Returns a JSONB array with one entry per entity.
mentat_entity
Fetch all current facts about an entity as a JSON map.
mentat_entity(entity_id BIGINT) -> JSONB
SELECT mentat_entity(10042);
-- Returns:
-- {
-- ":db/id": 10042,
-- ":person/name": "Alice",
-- ":person/email": "alice@example.com",
-- ":person/age": 30
-- }
For cardinality-many attributes, values are returned as JSON arrays.
mentat_schema
Return the full schema as a JSON map keyed by attribute ident.
mentat_schema() -> JSONB
SELECT mentat_schema();
-- Returns:
-- {
-- ":person/name": {
-- "entid": 65,
-- "valueType": "string",
-- "cardinality": "one",
-- "unique": null,
-- "indexed": true,
-- "fulltext": false,
-- "component": false,
-- "noHistory": false
-- },
-- ...
-- }
mentat_explain
Show the execution plan for a Datalog query without executing it.
mentat_explain(query TEXT, inputs JSONB) -> JSONB
SELECT mentat_explain('
[:find ?name
:where
[?e :person/name ?name]
[?e :person/age ?age]
[(> ?age 25)]]
', '{}');
Returns the generated SQL, the PostgreSQL EXPLAIN output, and query complexity hints.
EDN Helper Functions
These functions live in the mentat schema and provide batch operations and import/export capabilities.
mentat.batch
Execute multiple operations in a single call.
mentat.batch(edn_batch TEXT) -> JSONB
SELECT mentat.batch('[
[:query [:find ?e :where [?e :person/name]]]
[:transact [{:db/id "new" :person/name "Charlie"}]]
[:pull [:person/name :person/email] 10042]
[:entity 10043]
[:schema]
]');
Supported operation types: :query, :transact, :pull, :entity, :schema.
Returns a JSONB array with one result object per operation:
[
{"type": "query", "results": [[10042], [10043]]},
{"type": "transact", "result": {"tx-id": 1001, "tempids": {"new": 10044}}},
{"type": "pull", "result": {":person/name": "Alice", ...}},
{"type": "entity", "result": {":db/id": 10043, ...}},
{"type": "schema", "result": {...}}
]
mentat.export_edn
Export specific entities to EDN transaction format.
mentat.export_edn(entity_ids BIGINT[]) -> TEXT
SELECT mentat.export_edn(ARRAY[10042, 10043]);
-- Returns:
-- [
-- {:db/id 10042
-- :person/name "Alice"
-- :person/email "alice@example.com"}
-- {:db/id 10043
-- :person/name "Bob"}
-- ]
The output can be fed directly into edn_t or mentat.import_edn on another database for data migration.
mentat.import_edn
Import entities from EDN transaction format.
mentat.import_edn(edn_data TEXT) -> JSONB
SELECT mentat.import_edn('[
{:db/id "alice"
:person/name "Alice"
:person/email "alice@example.com"}
{:db/id "bob"
:person/name "Bob"}
]');
This is equivalent to calling edn_t but returns a JSONB transaction report.
mentat.query_export_edn
Execute a query and export all matching entities to EDN.
mentat.query_export_edn(query TEXT, inputs JSONB) -> TEXT
SELECT mentat.query_export_edn(
'[:find ?e :where [?e :person/department ?d] [?d :dept/name "Engineering"]]',
'{}'
);
mentat.export_all_edn
Export the entire database as EDN transaction data. Use with caution on large databases.
mentat.export_all_edn() -> TEXT
SELECT mentat.export_all_edn();
edn_pretty
Pretty-print EDN text with smart indentation and configurable line width. This function lives in the public schema (a backwards-compatible alias exists as mentat.edn_pretty).
edn_pretty(edn_input TEXT, width INT DEFAULT NULL) -> TEXT
SELECT edn_pretty('{:person/name "Alice" :person/age 30}');
-- Returns:
-- {:person/age 30
-- :person/name "Alice"}
SELECT edn_pretty('[:find ?e :where [?e :person/name]]', 40);
-- Returns a multi-line formatted Datalog query
The width parameter controls the target line width (default 80 columns). Pass a narrower width to force multi-line output.
Entity Helper Functions
These convenience functions live in the mentat schema.
mentat.lookup_by_ident
Look up an entity ID by a string attribute value.
mentat.lookup_by_ident(attr_ident TEXT, value TEXT) -> BIGINT
SELECT mentat.lookup_by_ident('person/email', 'alice@example.com');
-- Returns: 10042
Returns NULL if no matching entity is found.
mentat.entity_attrs
Get the list of attribute idents currently asserted on an entity.
mentat.entity_attrs(entity_id BIGINT) -> JSONB
SELECT mentat.entity_attrs(10042);
-- Returns: [":person/name", ":person/email", ":person/age"]
mentat.attribute_values
Get all current values for a given attribute across all entities.
mentat.attribute_values(attr_ident TEXT) -> JSONB
SELECT mentat.attribute_values(':person/name');
-- Returns: ["Alice", "Bob", "Carol"]
mentat.retract_entity
Retract all facts about an entity (generating individual retraction datoms).
mentat.retract_entity(entity_id BIGINT) -> BIGINT
SELECT mentat.retract_entity(10042);
-- Returns: 5 (number of facts retracted)
This differs from [:db/retractEntity eid] in that it generates individual [:db/retract ...] operations for each fact, giving a more explicit retraction trail.
Operational Functions
mentat_query_stats
Return performance statistics about mentat function calls, database size, and cache status.
mentat_query_stats() -> JSONB
SELECT mentat_query_stats();
Returns:
{
"functions": [
{"function": "edn_q", "calls": 150, "avg_duration_ms": 12.5, ...}
],
"database_stats": {
"total_datoms": 5000,
"total_transactions": 42,
"schema_attributes": 15,
"partitions": {
"db.part/db": {"next_entid": 200, "used": 200},
"db.part/user": {"next_entid": 10500, "used": 500},
"db.part/tx": {"next_entid": 1000042, "used": 42}
}
},
"cache": {"schema_cache_warmed": true}
}
Requires track_functions = 'all' in postgresql.conf for function call statistics.
mentat_storage_stats
Return table and index sizes.
mentat_storage_stats() -> JSONB
SELECT mentat_storage_stats();
mentat_slow_queries
Identify slow mentat functions and heavy transactions.
mentat_slow_queries(threshold_ms DOUBLE PRECISION DEFAULT 100.0) -> JSONB
SELECT mentat_slow_queries(50.0);
mentat_stmt_cache_stats
Return prepared statement cache hit/miss statistics.
mentat_stmt_cache_stats() -> JSONB
mentat_stmt_cache_clear
Clear the prepared statement cache. Useful after schema changes.
mentat_stmt_cache_clear() -> TEXT
EDN Functions (edn type)
pg_mentat provides a native edn PostgreSQL type. See EDN_TYPE.md for the full type guide. The following functions operate on edn values.
Collection Access
-- Get a value from a map by key
SELECT edn_get('{:name "Alice" :age 30}'::edn, ':name'::edn);
-- Returns: "Alice"
-- Get a value from a vector by 0-based index
SELECT edn_nth('[10 20 30]'::edn, 1);
-- Returns: 20
-- Get collection size
SELECT edn_count('[1 2 3]'::edn);
-- Returns: 3
Type Predicates
SELECT edn_is_nil('nil'::edn); -- true
SELECT edn_is_boolean('true'::edn); -- true
SELECT edn_is_integer('42'::edn); -- true
SELECT edn_is_float('3.14'::edn); -- true
SELECT edn_is_text('"hello"'::edn); -- true
SELECT edn_is_keyword(':foo'::edn); -- true
SELECT edn_is_vector('[1 2]'::edn); -- true
SELECT edn_is_list('(1 2)'::edn); -- true
SELECT edn_is_set('#{1 2}'::edn); -- true
SELECT edn_is_map('{:a 1}'::edn); -- true
Collection Operations
-- Check membership
SELECT edn_contains('[1 2 3]'::edn, '2'::edn);
-- Returns: true
-- Extract map keys as a vector
SELECT edn_keys('{:a 1 :b 2}'::edn);
-- Returns: [:a :b]
-- Extract map values as a vector
SELECT edn_values('{:a 1 :b 2}'::edn);
-- Returns: [1 2]
Temporal Queries
pg_mentat supports three temporal modes via the inputs JSONB parameter.
As-Of (point-in-time)
See the database as it was at a specific transaction.
SELECT edn_q('
[:find ?name :where [?e :person/name ?name]]
', '{"asOf": 1000005}');
Since
See only facts asserted since a specific transaction.
SELECT edn_q('
[:find ?name :where [?e :person/name ?name]]
', '{"since": 1000005}');
History
See all datoms including retractions. The query must bind ?tx and ?added variables.
SELECT edn_q('
[:find ?e ?name ?tx ?added
:where [?e :person/name ?name ?tx ?added]]
', '{"history": true}');
Pagination
The inputs JSONB parameter supports limit and offset for pagination.
-- First page (10 results)
SELECT edn_q('
[:find ?name ?email
:where [?e :person/name ?name] [?e :person/email ?email]]
', '{"limit": 10}');
-- Second page
SELECT edn_q('
[:find ?name ?email
:where [?e :person/name ?name] [?e :person/email ?email]]
', '{"limit": 10, "offset": 10}');
When both a Datalog :limit clause and a JSON limit input are specified, the JSON input takes precedence.
Hybrid SQL/Datalog Patterns
pg_mentat functions return standard SQL types (JSONB, TEXT), so they compose naturally with PostgreSQL features.
CTEs combining Datalog results with SQL
WITH engineers AS (
SELECT (r->>'results') AS results
FROM edn_q('
[:find ?e ?name ?salary
:where
[?e :person/department ?d]
[?d :dept/name "Engineering"]
[?e :person/name ?name]
[?e :person/salary ?salary]]
', '{}') AS r
)
SELECT * FROM engineers;
Window functions over Datalog results
WITH salaries AS (
SELECT
elem->>0 AS entity_id,
elem->>1 AS name,
(elem->>2)::int AS salary
FROM edn_q('
[:find ?e ?name ?salary
:where
[?e :person/name ?name]
[?e :person/salary ?salary]]
', '{}') AS q,
jsonb_array_elements(q->'results') AS elem
)
SELECT
name,
salary,
RANK() OVER (ORDER BY salary DESC) AS salary_rank,
salary - AVG(salary) OVER () AS diff_from_avg
FROM salaries;
Joining Mentat data with relational tables
WITH mentat_people AS (
SELECT
elem->>0 AS name,
elem->>1 AS email
FROM edn_q('
[:find ?name ?email
:where
[?e :person/name ?name]
[?e :person/email ?email]]
', '{}') AS q,
jsonb_array_elements(q->'results') AS elem
)
SELECT
mp.name,
te.project_name,
SUM(te.hours) AS total_hours
FROM mentat_people mp
JOIN time_entries te ON te.person_email = mp.email
GROUP BY mp.name, te.project_name
ORDER BY total_hours DESC;
Pull API in SQL pipelines
-- Get detailed entity info for query results
WITH entity_ids AS (
SELECT elem->>0 AS eid
FROM edn_q('
[:find ?e
:where
[?e :person/salary ?s]
[(> ?s 150000)]]
', '{}') AS q,
jsonb_array_elements(q->'results') AS elem
)
SELECT edn_pull('[*]', eid::bigint)
FROM entity_ids;
GUC Configuration Parameters
pg_mentat exposes several GUC (Grand Unified Configuration) parameters for tuning.
| Parameter | Type | Default | Description |
|---|---|---|---|
mentat.enable_optimizer_hints |
boolean | true |
Enable automatic SET LOCAL optimizer hints during query execution |
mentat.default_work_mem |
string | 64MB |
The work_mem value applied during complex queries |
mentat.max_result_rows |
integer | 0 (unlimited) |
Maximum rows returned by edn_q before raising an error |
Set parameters per-session or in postgresql.conf:
-- Per-session
SET mentat.enable_optimizer_hints = off;
SET mentat.max_result_rows = 50000;
-- In postgresql.conf
mentat.enable_optimizer_hints = on
mentat.default_work_mem = '128MB'
mentat.max_result_rows = 100000
Internal Schema
pg_mentat stores all data in the mentat schema. The core tables are:
| Table | Purpose |
|---|---|
mentat.datoms |
Core fact table (partitioned by value type) |
mentat.schema |
Attribute definitions |
mentat.idents |
Keyword to entity ID mappings |
mentat.partitions |
Entity ID partition boundaries |
mentat.transactions |
Transaction metadata |
mentat.fulltext |
Full-text search index table |
The datoms table is partitioned by value_type_tag (LIST partitioning) for partition pruning and type-specific indexes. Each value type has a dedicated column (v_ref, v_long, v_text, etc.) ensuring native PostgreSQL type comparisons.
Indexes
| Index | Pattern | Purpose |
|---|---|---|
idx_datoms_eavt |
(e, a, tag, tx) WHERE added | Entity-centric lookups |
idx_datoms_aevt |
(a, e, tag, tx) WHERE added | Attribute-centric scans |
idx_datoms_vaet |
(v_ref, a, e, tx) WHERE added AND tag=0 | Reverse ref lookups |
idx_datoms_tx |
(tx DESC) | Transaction history |
idx_datoms_avet_ref |
(a, v_ref, e, tx) WHERE added AND tag=0 | Ref value lookups |
idx_datoms_avet_long |
(a, v_long, e, tx) WHERE added AND tag=2 | Numeric range queries |
idx_datoms_avet_text |
(a, v_text, e, tx) WHERE added AND tag=7 | String equality/prefix |
idx_datoms_avet_keyword |
(a, v_keyword, e, tx) WHERE added AND tag=8 | Keyword resolution |
Value Type Tags
| Tag | Type | Column |
|---|---|---|
| 0 | ref | v_ref |
| 1 | boolean | v_bool |
| 2 | long | v_long |
| 3 | double | v_double |
| 4 | instant | v_instant |
| 7 | string | v_text |
| 8 | keyword | v_keyword |
| 10 | uuid | v_uuid |
| 11 | bytes | v_bytes |
Store Management
pg_mentat supports multiple independent data stores (databases) within a single PostgreSQL instance. Each store has its own schema, datoms table, transaction log, and indexes, providing complete data isolation.
mentat_create_store
Create a new named store with its own isolated schema and tables.
mentat_create_store(store_name TEXT, description TEXT DEFAULT NULL) -> TEXT
SELECT mentat_create_store('analytics', 'Store for analytics data');
-- Creates schema mentat_analytics with all core tables and virtual views
Naming rules:
- Must start with a letter
- Only lowercase letters, digits, and underscores allowed
- Maximum 63 characters
- Cannot use reserved names: default, public, pg_*
mentat_drop_store
Drop a named store and all its data permanently.
mentat_drop_store(store_name TEXT) -> TEXT
SELECT mentat_drop_store('analytics');
-- WARNING: This permanently deletes all data in the store
The default store cannot be dropped.
mentat_list_stores
List all stores with metadata.
mentat_list_stores() -> JSONB
SELECT mentat_list_stores();
-- Returns:
-- [
-- {"name": "default", "schema": "mentat", "description": "Default store", "created_at": "..."},
-- {"name": "analytics", "schema": "mentat_analytics", "description": "Store for analytics data", "created_at": "..."}
-- ]
mentat_rename_store
Rename an existing store.
mentat_rename_store(old_name TEXT, new_name TEXT) -> TEXT
SELECT mentat_rename_store('analytics', 'metrics');
Store-Aware Functions
Every core function has a *_in_store variant that targets a specific named store. The default (non-suffixed) functions operate on the default store.
| Default Function | Store-Aware Variant |
|---|---|
edn_t(edn) |
mentat_transact_in_store(store, edn) |
edn_q(query, inputs) |
mentat_query_in_store(store, query, inputs) |
edn_pull(pattern, eid) |
mentat_pull_in_store(store, pattern, eid) |
mentat_pull_many(pattern, eids) |
mentat_pull_many_in_store(store, pattern, eids) |
mentat_entity(eid) |
mentat_entity_in_store(store, eid) |
mentat_schema() |
mentat_schema_in_store(store) |
Example: Multi-tenant Architecture
-- Create per-tenant stores
SELECT mentat_create_store('tenant_acme', 'ACME Corp data');
SELECT mentat_create_store('tenant_globex', 'Globex Corp data');
-- Transact into specific tenant
SELECT mentat_transact_in_store('tenant_acme', '[
{:db/ident :user/name :db/valueType :db.type/string :db/cardinality :db.cardinality/one}
]');
SELECT mentat_transact_in_store('tenant_acme', '[
{:db/id "u1" :user/name "Alice"}
]');
-- Query a specific tenant
SELECT mentat_query_in_store('tenant_acme',
'[:find ?name :where [?e :user/name ?name]]', '{}');
-- Tenant data is completely isolated
SELECT mentat_query_in_store('tenant_globex',
'[:find ?name :where [?e :user/name ?name]]', '{}');
-- Returns empty: Globex has no user data
Virtual Table Views
Each store automatically creates a set of virtual table views that expose Mentat data through a relational lens. These allow SQL-native querying without Datalog.
Available Views
| View | Purpose | Key Columns |
|---|---|---|
entities |
All entities with metadata | entity_id, first_tx, last_tx, attribute_count |
attributes |
Schema attributes | entid, ident, value_type, cardinality, indexed, … |
facts |
Human-readable fact triples | entity_id, attribute, value, value_type, tx, added |
text_values |
String-typed facts | entity_id, attribute, value, tx |
numeric_values |
Long/integer-typed facts | entity_id, attribute, value, tx |
double_values |
Double/float-typed facts | entity_id, attribute, value, tx |
boolean_values |
Boolean-typed facts | entity_id, attribute, value, tx |
keyword_values |
Keyword-typed facts | entity_id, attribute, value, tx |
ref_values |
Reference-typed facts | entity_id, attribute, target_entity, tx |
searchable_text |
Full-text search view | entity_id, attribute, value, search_vector |
Example: SQL-Native Queries
-- Find all entities with a :person/name attribute
SELECT entity_id, value
FROM mentat.text_values
WHERE attribute = ':person/name';
-- Get entity fact count
SELECT entity_id, attribute_count
FROM mentat.entities
ORDER BY attribute_count DESC
LIMIT 10;
-- Full-text search
SELECT entity_id, value
FROM mentat.searchable_text
WHERE search_vector @@ to_tsquery('english', 'database & systems');
-- Standard SQL JOINs between views
SELECT t.entity_id, t.value AS name, n.value AS age
FROM mentat.text_values t
JOIN mentat.numeric_values n ON t.entity_id = n.entity_id
WHERE t.attribute = ':person/name'
AND n.attribute = ':person/age'
AND n.value > 25;
Regenerating Virtual Tables
Virtual tables are created automatically when a store is created. To regenerate them (e.g., after schema changes):
SELECT mentat_create_virtual_tables('default');
SELECT mentat_create_virtual_tables('my_store');
Materialized Views
Materialized views cache the results of Datalog queries as PostgreSQL materialized views, providing fast read access at the cost of staleness until refreshed.
mentat_create_matview
mentat_create_matview(name TEXT, query TEXT, inputs JSONB) -> TEXT
SELECT mentat_create_matview('active_engineers',
'[:find ?name ?salary
:where
[?e :employee/name ?name]
[?e :employee/dept "Engineering"]
[?e :employee/salary ?salary]
[?e :employee/active true]]',
'{}');
-- Query it like a regular table
SELECT * FROM mentat.matview_active_engineers;
mentat_refresh_matview
Refresh a materialized view with current data.
mentat_refresh_matview(name TEXT, concurrently BOOLEAN DEFAULT FALSE) -> TEXT
-- Standard refresh (blocks reads)
SELECT mentat_refresh_matview('active_engineers');
-- Concurrent refresh (requires unique index, does not block reads)
SELECT mentat_refresh_matview('active_engineers', true);
mentat_drop_matview
mentat_drop_matview(name TEXT) -> TEXT
SELECT mentat_drop_matview('active_engineers');
mentat_list_matviews
mentat_list_matviews() -> JSONB
SELECT mentat_list_matviews();
-- Returns:
-- [
-- {"name": "active_engineers", "query": "...", "created_at": "...", "last_refresh": "..."}
-- ]
Store-Aware Variants
mentat_create_matview_in_store(store TEXT, name TEXT, query TEXT, inputs JSONB) -> TEXT
mentat_refresh_matview_in_store(store TEXT, name TEXT, concurrently BOOLEAN) -> TEXT
mentat_drop_matview_in_store(store TEXT, name TEXT) -> TEXT
mentat_list_matviews_in_store(store TEXT) -> JSONB
Use Cases
- Dashboards: Pre-compute aggregate statistics for fast display
- Reporting: Cache complex join results
- Search indexes: Materialize filtered entity sets for fast lookup
- API layers: Serve pre-computed results without real-time query overhead
Time-Travel Queries
pg_mentat preserves the full history of all facts, enabling point-in-time queries, audit trails, and change detection.
mentat_as_of
Query the database as it was at a specific transaction.
mentat_as_of(tx_id BIGINT, query TEXT, inputs JSONB) -> JSONB
-- What was the config at transaction 1000005?
SELECT mentat_as_of(1000005,
'[:find ?key ?val
:where
[?e :config/key ?key]
[?e :config/value ?val]]',
'{}');
mentat_since
Query only facts that were asserted since a specific transaction.
mentat_since(tx_id BIGINT, query TEXT, inputs JSONB) -> JSONB
-- What changed since the last deployment?
SELECT mentat_since(1000010,
'[:find ?attr ?val
:where
[?e ?attr ?val]]',
'{}');
mentat_history
Query the full assertion/retraction history including retractions.
mentat_history(query TEXT, inputs JSONB) -> JSONB
-- Complete audit trail for a field
SELECT mentat_history(
'[:find ?val ?tx ?added
:where
[?e :user/email "alice@example.com"]
[?e :user/role ?val ?tx ?added]]',
'{}');
-- Returns: [["admin", 1000005, true], ["admin", 1000008, false], ["superadmin", 1000008, true]]
Query Input Modifiers
Time-travel can also be specified via the inputs JSON parameter:
-- As-of via inputs
SELECT edn_q('[:find ?name :where [?e :person/name ?name]]',
'{"asOf": 1000005}');
-- Since via inputs
SELECT edn_q('[:find ?name :where [?e :person/name ?name]]',
'{"since": 1000005}');
-- History via inputs (query must bind ?tx and ?added)
SELECT edn_q(
'[:find ?e ?name ?tx ?added :where [?e :person/name ?name ?tx ?added]]',
'{"history": true}');
Store-Aware Variants
mentat_as_of_in_store(store TEXT, tx_id BIGINT, query TEXT, inputs JSONB) -> JSONB
mentat_since_in_store(store TEXT, tx_id BIGINT, query TEXT, inputs JSONB) -> JSONB
mentat_history_in_store(store TEXT, query TEXT, inputs JSONB) -> JSONB
Streaming Subscriptions
Subscriptions monitor Datalog query results for changes and emit PostgreSQL NOTIFY events when results change after transactions.
mentat_subscribe
Register a subscription that watches for query result changes.
mentat_subscribe(name TEXT, query TEXT, inputs JSONB, channel TEXT DEFAULT NULL) -> TEXT
-- Watch for high-temperature readings
SELECT mentat_subscribe('hot_sensors',
'[:find ?id ?temp
:where
[?e :sensor/id ?id]
[?e :sensor/temp ?temp]
[(> ?temp 30.0)]]',
'{}',
'sensor_alerts');
-- Returns subscription ID
mentat_unsubscribe
Remove a subscription.
mentat_unsubscribe(name TEXT) -> TEXT
SELECT mentat_unsubscribe('hot_sensors');
mentat_list_subscriptions
List all active subscriptions.
mentat_list_subscriptions() -> JSONB
SELECT mentat_list_subscriptions();
-- [{"name": "hot_sensors", "query": "...", "channel": "sensor_alerts", "created_at": "..."}]
mentat_notify_subscribers
Manually trigger subscription evaluation (also triggered automatically after transactions).
mentat_notify_subscribers() -> TEXT
SELECT mentat_notify_subscribers();
Listening for Notifications
-- In a PostgreSQL client:
LISTEN sensor_alerts;
-- After a transaction that matches the subscription query,
-- the client will receive a NOTIFY with the new results as payload.
Store-Aware Variants
mentat_subscribe_in_store(store TEXT, name TEXT, query TEXT, inputs JSONB, channel TEXT) -> TEXT
mentat_unsubscribe_in_store(store TEXT, name TEXT) -> TEXT
mentat_list_subscriptions_in_store(store TEXT) -> JSONB
Recursive Query Translation
pg_mentat translates recursive Datalog rules into PostgreSQL recursive CTEs for efficient graph traversal.
mentat_recursive_query
Execute a query with recursive rules.
mentat_recursive_query(query TEXT, inputs JSONB, rules TEXT) -> JSONB
SELECT mentat_recursive_query(
'[:find ?name
:in $ ?root
:where
(ancestor ?child ?root)
[?child :org/name ?name]]',
'{"root": ["lookup", ":org/name", "Corp"]}',
'[[(ancestor ?x ?y) [?x :org/parent ?y]]
[(ancestor ?x ?y) [?x :org/parent ?z] (ancestor ?z ?y)]]');
mentat_ancestors
Find all ancestors by following a ref attribute upward.
mentat_ancestors(query TEXT, inputs JSONB, ref_attr TEXT, max_depth INT) -> JSONB
-- Find all organizational parents up to the root
SELECT mentat_ancestors(
'[:find ?name
:in $ ?start
:where [?start :org/parent ?ancestor] [?ancestor :org/name ?name]]',
'{"start": ["lookup", ":org/name", "Backend"]}',
':org/parent',
10);
-- Returns: [["Engineering"], ["Corp"]]
mentat_descendants
Find all descendants by following a ref attribute downward.
mentat_descendants(query TEXT, inputs JSONB, ref_attr TEXT, max_depth INT) -> JSONB
-- Find all sub-organizations
SELECT mentat_descendants(
'[:find ?name
:in $ ?start
:where [?child :org/parent ?start] [?child :org/name ?name]]',
'{"start": ["lookup", ":org/name", "Corp"]}',
':org/parent',
10);
-- Returns: [["Engineering"], ["Sales"], ["Backend"], ["Frontend"], ["East"], ["West"]]
Store-Aware Variants
mentat_recursive_query_in_store(store TEXT, query TEXT, inputs JSONB, rules TEXT) -> JSONB
mentat_ancestors_in_store(store TEXT, query TEXT, inputs JSONB, ref_attr TEXT, max_depth INT) -> JSONB
mentat_descendants_in_store(store TEXT, query TEXT, inputs JSONB, ref_attr TEXT, max_depth INT) -> JSONB
Use Cases
- Organization charts: Traverse manager/report hierarchies
- Category trees: Navigate product/content taxonomies
- Dependency graphs: Find all transitive dependencies
- Access control: Compute inherited permissions through group hierarchies
Security Considerations
Input Validation
All pg_mentat functions validate inputs before execution:
- Store names: Restricted to
[a-z][a-z0-9_]*, max 63 chars, no reserved names - EDN transactions: Parsed by a strict EDN parser; malformed input is rejected
- Query text: Parsed by the Datalog parser; SQL cannot be injected through query strings
- Input parameters: Bound via parameterized queries (
$1,$2), never string-concatenated
SQL Injection Protection
pg_mentat uses several layers of protection against SQL injection:
- Schema-qualified identifiers: All dynamic SQL uses
format()with%I(identifier quoting) - Parameterized queries: SPI queries use
$Nparameter binding - Name validation: Store/matview/subscription names are validated against a strict regex before use in any SQL
- EDN parser boundary: The EDN parser converts text to a typed AST before any SQL generation
Privilege Model
- Functions run with the caller’s privileges (no
SECURITY DEFINER) - Store schemas are created with standard PostgreSQL ownership rules
- The
mentatschema requires appropriate USAGE/SELECT/INSERT grants - Use PostgreSQL roles and
GRANT/REVOKEto control access per store
Best Practices
- Use PostgreSQL roles for access control, not application-level checks
- Grant minimal privileges:
USAGEon schema +SELECTfor read-only users - Audit store creation/deletion by monitoring the
mentat.storestable - Use
mentat.max_result_rowsto prevent unbounded queries from consuming memory - Monitor subscription count to avoid excessive notification overhead