GraphJin Features - Complete Reference
July 20, 2026 · View on GitHub
GraphJin is a high-performance GraphQL to SQL compiler that automatically generates optimized database queries from GraphQL. This document covers all 50+ features with real examples.
Table of Contents
- The Magic of GraphJin
- Query Capabilities
- Mutation Capabilities
- Real-time Subscriptions
- File Uploads
- Filesystem Tables
- CodeSQL Source Indexes
- Metadata Graph
- Apollo Federation v2
- MCP Connections for AI Clients
- Security Features
- Advanced Features
- Multi-Database Support
- Configuration Reference
The Magic of GraphJin
GraphJin eliminates weeks of backend API development by automatically converting GraphQL queries into highly optimized SQL. Here's what makes it magical:
Zero-Code API Generation
Write a GraphQL query, and GraphJin automatically:
- Discovers your database schema and relationships
- Generates optimized SQL with proper JOINs
- Returns nested JSON exactly as requested
- Handles pagination, filtering, and ordering
query {
products(limit: 3, order_by: { id: asc }) {
id
name
owner {
id
fullName: full_name
}
}
}
This single query automatically generates optimized SQL that fetches products with their owners in one database query - no N+1 problem.
Single Optimized SQL Query
Complex nested queries compile to a single SQL statement using LATERAL JOINs:
query getProducts {
products(limit: 20, order_by: { price: desc }) {
id
name
price
owner {
full_name
email
category_counts(limit: 3) {
count
category { name }
}
}
category(limit: 3) { id, name }
}
products_cursor
}
Production Security
In production mode, queries are read from locally saved copies - clients cannot modify queries at runtime. This provides security equivalent to hand-written APIs.
Query Capabilities
Basic Queries
Simple field selection with aliases:
query {
products(limit: 3, order_by: { id: asc }) {
id
count_likes
owner {
id
fullName: full_name # Field alias
}
}
}
Query by ID returns a single object:
query {
products(id: $id) {
id
name
}
}
# Variables: { "id": 2 }
# Returns: {"products":{"id":2,"name":"Product 2"}}
Filtering & WHERE Clauses
GraphJin supports 15+ filter operators:
| Operator | Description | Example |
|---|---|---|
eq | Equals | { id: { eq: 1 } } |
neq | Not equals | { id: { neq: 1 } } |
gt | Greater than | { price: { gt: 10 } } |
gte, greater_or_equals | Greater or equal | { price: { gte: 10 } } |
lt | Less than | { price: { lt: 100 } } |
lte, lesser_or_equals | Less or equal | { price: { lte: 100 } } |
in | In list | { id: { in: [1,2,3] } } |
nin | Not in list | { id: { nin: [1,2] } } |
is_null | Is null | { id: { is_null: true } } |
iregex | Case-insensitive regex | { name: { iregex: "product" } } |
has_key | JSON has key | { metadata: { has_key: "foo" } } |
has_key_any | JSON has any key | { metadata: { has_key_any: ["foo","bar"] } } |
Logical operators - and, or, not:
query {
products(where: {
and: [
{ not: { id: { is_null: true } } },
{ price: { gt: 10 } }
]
}, limit: 3) {
id
name
price
}
}
Filter on related tables:
query {
products(where: { owner: { id: { eq: $user_id } } }) {
id
owner { id, email }
}
}
Regex matching:
query {
products(where: {
or: {
name: { iregex: $name },
description: { iregex: $name }
}
}) {
id
}
}
Ordering & Pagination
Basic ordering:
query {
products(order_by: { price: desc }, limit: 5) {
id
name
price
}
}
Distinct values:
query {
products(
limit: 5,
order_by: { price: desc },
distinct: [price],
where: { id: { gte: 50, lt: 100 } }
) {
id
name
price
}
}
Nested ordering (order by related table):
query {
products(order_by: { users: { email: desc }, id: desc }, limit: 5) {
id
price
}
}
Order by custom list:
query {
products(
order_by: { id: [$list, "asc"] },
where: { id: { in: $list } }
) {
id
price
}
}
# Variables: { "list": [3, 2, 1, 5] }
# Returns products in order: 3, 2, 1, 5
Cursor-based pagination (efficient infinite scroll):
query {
products(
first: 3,
after: $cursor,
order_by: { price: desc }
) {
name
}
products_cursor # Encrypted cursor for next page
}
Dynamic order_by (configurable ordering):
conf.Tables = []core.Table{{
Name: "products",
OrderBy: map[string][]string{
"price_and_id": {"price desc", "id asc"},
"just_id": {"id asc"},
},
}}
query {
products(order_by: $order, limit: 5) {
id
price
}
}
# Variables: { "order": "price_and_id" }
Relationship Queries
Parent to children (one-to-many):
query {
users(limit: 2) {
email
products { # User's products
name
price
}
}
}
Children to parent (many-to-one):
query {
products(limit: 2) {
name
owner { # Product's owner
email
}
}
}
Many-to-many via join table:
query {
products(limit: 2) {
name
customer { # Customers who purchased (via purchases table)
email
}
owner {
email
}
}
}
Multiple top-level tables:
query {
products(id: $id) {
id
name
}
users(id: $id) {
id
email
}
purchases(id: $id) {
id
}
}
Recursive Queries
Query self-referential data structures like comment trees:
Find all parents (ancestors):
query {
comments(id: 50) {
id
comments(find: "parents", limit: 5) {
id
}
}
}
# Returns: comment 50 with its parent chain
Find all children (descendants):
query {
comments(id: 95) {
id
replies: comments(find: "children") {
id
}
}
}
# Returns: {"comments":{"id":95,"replies":[{"id":96},{"id":97},{"id":98},{"id":99},{"id":100}]}}
Aggregations on recursive results:
query {
comments(id: 95) {
id
replies: comments(find: "children") {
count_id # Count all children
}
}
}
Aggregations
Built-in aggregate functions:
| Function | Example |
|---|---|
count_<column> | count_id |
sum_<column> | sum_price |
max_<column> | max_price |
min_<column> | min_price |
avg_<column> | avg_price |
query {
products(where: { id: { lteq: 100 } }) {
count_id
max_price
}
}
# Returns: {"products":[{"count_id":100,"max_price":110.5}]}
Analytics Directives
Attach analytics directives to real columns when you want reporting metrics
without collapsing the original rows. Use grouped aggregates with distinct for
one-row-per-group summaries; use analytics directives for running totals, moving
averages, previous/next values, first/last values, and ranking inside a group.
query {
orders {
account_id
month
total
running_total: total @running(
aggregate: sum,
by: "account_id",
orderBy: { month: asc }
)
moving_avg_total: total @moving(
aggregate: avg,
rows: 6,
by: "account_id",
orderBy: { month: asc }
)
previous_total: total @previous(
by: "account_id",
orderBy: { month: asc }
)
rank_by_total: total @rank(by: "account_id", order: desc)
}
}
Supported directives: @running, @moving, @previous, @next, @first,
@last, @rank, @denseRank, and @rowNumber.
@running and @moving require aggregate: sum|avg|count|min|max.
@moving also requires rows: N, a positive trailing row count including the
current row. All analytics directives require orderBy: { col: asc|desc } or
the shorthand order: asc|desc to order by the annotated column. Optional by
accepts a single column or a list of columns when the metric should be scoped
within groups.
Dialect support — Postgres, MySQL 8.0+, MariaDB 10.2+, MSSQL 2012+, Oracle, SQLite 3.25+, Snowflake, CockroachDB. MongoDB does not support analytics directives. When GraphJin knows a database version is too old, compilation fails with a dialect-specific error.
The MCP get_query_syntax resource and write_query prompt also expose these
analytics patterns so AI agents can generate reporting queries directly from
schema context.
Full-Text Search
query {
products(search: "Product 3", limit: 5) {
id
name
}
}
Supports PostgreSQL tsvector, MySQL FULLTEXT, and SQLite FTS5.
JSON Operations
Filter on JSON fields:
query {
quotations(where: {
validity_period: {
issue_date: { lte: "2024-09-18T03:03:16+0000" }
}
}) {
id
validity_period
}
}
Underscore syntax for JSON paths:
query {
products(where: { metadata_foo: { eq: true } }) {
id
metadata
}
}
# Filters where metadata->foo = true
Check for JSON keys:
query {
products(where: { metadata: { has_key_any: ["foo", "bar"] } }) {
id
}
}
JSON column as virtual table:
conf.Tables = []core.Table{{
Name: "category_counts",
Table: "users",
Type: "json",
Columns: []core.Column{
{Name: "category_id", Type: "int", ForeignKey: "categories.id"},
{Name: "count", Type: "int"},
},
}}
query {
users(id: 1) {
id
category_counts {
count
category { name }
}
}
}
GraphQL Fragments
Reuse field selections across queries:
fragment productFields on product {
id
name
price
}
fragment ownerFields on user {
id
email
}
query {
products(limit: 2) {
...productFields
owner {
...ownerFields
}
}
}
Polymorphic Relationships
Query union types for polymorphic associations:
conf.Tables = []core.Table{{
Name: "subject",
Type: "polymorphic",
Columns: []core.Column{{Name: "subject_id", ForeignKey: "subject_type.id"}},
}}
query {
notifications {
id
verb
subject {
...on users { email }
...on products { name }
}
}
}
# Returns: {"notifications":[
# {"id":1,"subject":{"email":"user1@test.com"},"verb":"Joined"},
# {"id":2,"subject":{"name":"Product 2"},"verb":"Bought"}
# ]}
Directives
Role-based inclusion/exclusion:
query {
products @include(ifRole: "user") {
id
name
}
users @skip(ifRole: "user") {
id
}
}
Variable-based inclusion/exclusion:
query {
products @include(ifVar: $showProducts) {
id
}
}
# Variables: { "showProducts": true }
Field-level directives:
query {
products {
id @skip(ifRole: "user")
name @include(ifRole: "user")
}
}
Add/Remove directives (exclude from response entirely):
query {
products @add(ifRole: "user") { # Only added if user role
id
}
users @remove(ifRole: "user") { # Removed if user role
id
}
}
Conditional field values:
query {
products {
id(includeIf: { id: { eq: 1 } }) # null if id != 1
name
}
}
@object directive (force single object response):
query {
me @object {
email
}
}
# Returns: {"me":{"email":"..."}} instead of {"me":[{...}]}
Remote API Joins
Combine database data with external REST APIs as child fields on a parent table:
conf.Resolvers = []core.ResolverConfig{{
Name: "payments",
Type: "remote_api",
Table: "users",
Column: "stripe_id",
StripPath: "data",
Props: core.ResolverProps{"url": "http://api.stripe.com/payments/$id"},
}}
query {
users {
email
payments { # Fetched from Stripe API
desc
amount
}
}
}
remote_api is the right tool for ad-hoc URL joins. For APIs that publish an OpenAPI spec, the OpenAPI integration (below) is usually a better fit — it derives auth, parameter wiring, and the response shape from the spec instead of asking you to wire each endpoint by hand.
OpenAPI Integration
Drop an OpenAPI 3 spec into config/specs/, declare credentials and join wiring in your environment config, and every classifiable operation in the spec becomes a GraphQL field. Two shapes are emitted automatically:
Row-join fields — GET /resource/{id} declared in joins: shows up as a child field on a parent DB table:
# config/dev.yml
sources:
- name: app
kind: sql
type: postgres
connection_string: ${APP_DATABASE_URL}
default: true
- name: upstream
kind: openapi
specs_dir: ./config/specs
specs:
interaction_studio:
base_url: https://${IS_ACCOUNT}.personalization.salesforce.com/api
auth:
scheme: token_exchange
token_url: https://${IS_ACCOUNT}.personalization.salesforce.com/api/token
request:
body:
apiKeyId: ${IS_API_KEY}
apiKeySecret: ${IS_API_SECRET}
response:
token_field: access_token
expires_field: expires_in
joins:
getUserById:
parent_table: users
parent_column: email
param: userId
expose_as: is_profile
tables:
- name: users
source: app
query {
users(where: { id: { eq: 42 } }) {
id
email
is_profile { # → GET /users/{userId} with userId = users.email
lastSeenAt
segments { id name }
}
}
}
Top-level virtual tables — GET /resource/{id} (single) or GET /resources (list) without a joins: entry surface as their own root fields, with path/query parameters mapped to GraphQL field arguments:
query {
is_get_user_by_id(userId: "u_123") { # path param
lastSeenAt
}
is_list_audit_logs(actorId: "u_123") { # query param
items { ts action }
}
}
What gets classified — every GET operation in the spec is auto-categorised:
| Mode | Path shape | Behaviour |
|---|---|---|
| Row-join | GET /resource/{id} with a matching joins: entry | Child field on the parent DB table; parent column populates the path param |
| Top-level (single) | GET /resource/{id} without a joins: entry | Root field; path param becomes a required field argument |
| Top-level (list) | GET /resources with optional query filters | Root field; each query param becomes an optional field argument |
| Skipped | Non-JSON response, mutating verb (POST/PUT/DELETE), or unsupported auth | Logged at boot; not exposed |
Authentication — declared in YAML, applied automatically to every request to the upstream:
| Scheme | Use for |
|---|---|
bearer | static or pass-through tokens |
basic | username/password |
api_key | header or query param |
oauth2_client_credentials | machine-to-machine OAuth |
token_exchange | vendor-specific POST → JSON token flows |
Per-spec concurrency, response shaping, and overrides — result_path strips JSON wrappers, expose_as renames a field on collision, concurrency_limit caps parallel calls per spec. See the OpenAPI Integration config reference for the full surface.
Boot log reports what loaded and what was skipped:
openapi: loaded interaction_studio.yaml (5 active, 12 skipped)
openapi: GET /exports/{jobId} — skipped: non-JSON response (application/octet-stream)
openapi: GET /users/{userId} — exposed as is_profile (row-join on users.email)
The integration is read-only today (GET only). Mutating verbs are a deferred feature — the Filesystem Tables abstraction is the recommended path for write-side object-store operations.
Database Functions
Scalar functions as fields:
query {
products(id: 51) {
id
name
is_hot_product(args: { id: id }) # Calls database function
}
}
Table-returning functions:
query {
get_oldest5_products(limit: 3) {
id
name
}
}
Functions with named arguments:
query {
get_oldest_users(limit: 2, args: { user_count: 4, tag: $tag }) {
id
full_name
}
}
Functions with positional arguments:
query {
get_oldest_users(args: { a0: 4, a1: "tag_value" }) {
id
}
}
Mutation Capabilities
Simple Inserts
mutation {
users(insert: {
id: $id,
email: $email,
full_name: $fullName
}) {
id
email
}
}
Bulk Inserts
Array variable:
mutation {
users(insert: $data) {
id
email
}
}
# Variables: { "data": [
# { "id": 1002, "email": "user1@test.com" },
# { "id": 1003, "email": "user2@test.com" }
# ]}
Inline array:
mutation {
users(insert: [
{id: $id1, email: $email1},
{id: $id2, email: $email2}
]) {
id
email
}
}
Nested Inserts
Insert across multiple related tables atomically:
mutation {
purchases(insert: $data) {
quantity
customer {
id
full_name
}
product {
id
name
price
}
}
}
{
"data": {
"id": 3001,
"quantity": 5,
"customer": {
"id": 1004,
"email": "new@customer.com",
"full_name": "New Customer"
},
"product": {
"id": 2002,
"name": "New Product",
"price": 99.99,
"owner_id": 3
}
}
}
All inserts happen in a single transaction - if any fails, all roll back.
Presets (auto-fill fields):
conf.AddRoleTable("user", "products", core.Insert{
Presets: map[string]string{"owner_id": "$user_id"},
})
mutation {
products(insert: { name: "Product", price: 10 }) {
id
owner { id } # Automatically set to current user
}
}
Connect & Disconnect
Link to existing records instead of creating new ones:
Connect on insert:
mutation {
products(insert: {
name: "New Product",
owner: { connect: { id: 6 } } # Link to existing user
}) {
id
owner { email }
}
}
Recursive connect:
mutation {
comments(insert: {
body: "Parent comment",
comments: {
find: "children",
connect: { id: 5 } # Make comment 5 a child
}
}) {
id
}
}
Validation
Use @constraint directive for input validation:
mutation
@constraint(variable: "email", format: "email", min: 1, max: 100)
@constraint(variable: "full_name", requiredIf: { id: 1007 })
@constraint(variable: "id", greaterThan: 1006) {
users(insert: { id: $id, email: $email }) {
id
}
}
Available constraints:
| Constraint | Description |
|---|---|
format | "email", custom regex |
min | Minimum length |
max | Maximum length |
required | Field is required |
requiredIf | Required if condition matches |
greaterThan | Numeric comparison |
lessThan | Numeric comparison |
equals | Exact match |
lessThanOrEqualsField | Compare to another field |
Updates
Simple update:
mutation {
products(id: $id, update: { name: "Updated Name" }) {
id
name
}
}
Update with WHERE:
mutation {
products(where: { id: 100 }, update: { tags: ["new", "tags"] }) {
id
tags
}
}
Update multiple related tables:
mutation {
purchases(id: $id, update: {
quantity: 6,
customer: { full_name: "Updated Customer" },
product: { description: "Updated Description" }
}) {
quantity
customer { full_name }
product { description }
}
}
Connect and disconnect on update:
mutation {
users(id: $id, update: {
products: {
connect: { id: 99 },
disconnect: { id: 100 }
}
}) {
products { id }
}
}
Real-time Subscriptions
Subscribe to data changes with automatic polling:
subscription {
users(id: $id) {
id
email
phone
}
}
conf := &core.Config{SubsPollDuration: 1} // Poll every second
gj, _ := core.NewGraphJin(conf, db)
m, _ := gj.Subscribe(ctx, gql, vars, nil)
for msg := range m.Result {
fmt.Println(msg.Data) // Triggered on every change
}
Most SQL dialects use GraphJin's batched polling path. Dialects without the batched path are listed as unsupported for subscriptions until that wrapper is verified.
Cursor-based subscriptions (for feeds/chat):
subscription {
chats(first: 1, after: $cursor) {
id
body
}
chats_cursor
}
File Uploads
The GraphQL endpoint accepts multipart/form-data POSTs alongside JSON, following the graphql-multipart-request-spec. Files are placed at variable paths declared in the request's map field.
uploads:
enabled: true
max_size: 25_000_000 # bytes; defaults to 25 MB
allowed_mime: ["image/*", "application/pdf"]
storage: avatars # optional: filesystem table to stream into
storage_key_prefix: "{date}/" # optional: {date} → YYYY/MM/DD
Two modes, controlled by whether storage is set:
Inline base64 (no storage) — the file becomes a JSON object inside the variable:
{ "filename": "logo.png", "content_type": "image/png",
"size": 12345, "data": "<base64 bytes>" }
Mutations bind this object to a JSONB column or to a PL/pgSQL function that decodes data into bytea.
Streamed to a filesystem table (storage: avatars) — the body is written to the named filesystem table and the variable becomes a stable reference:
{ "key": "2026/05/08/abc123.png",
"url": "https://s3.../...?presigned",
"size": 12345,
"content_type": "image/png" }
This keeps large bodies out of the GraphQL request/response and gives mutations a queryable handle to the stored object.
Generated keys are <prefix>/<8-byte-hex><ext> — collisions are near-impossible and the upstream filename never reaches storage (useful when filenames are user-supplied).
Validation — total request bounded by max_size, MIME type checked against allowed_mime (glob patterns supported: image/*, application/pdf).
Filesystem Tables
Object stores show up as ordinary tables in the GraphQL schema. Declare a filesystem source and attach a table to it; it gets the same query surface as a database table — no per-storage GraphQL plumbing needed.
sources:
- name: avatars
kind: filesystem
backend: s3
bucket: my-bucket
prefix: avatars/
region: us-east-1
presign_ttl: 15m
- name: invoices
kind: filesystem
backend: gcs
bucket: invoices
prefix: 2026/
- name: uploads_local
kind: filesystem
backend: local
root: /var/lib/graphjin/uploads
tables:
- name: avatars
source: avatars
read_only: true
- name: invoices
source: invoices
read_only: true
- name: uploads_local
source: uploads_local
Every filesystem table exposes the same columns regardless of backend:
| Column | Type | Description |
|---|---|---|
key | text (PK) | Object's path within the table's effective root |
size | bigint | Bytes |
content_type | text | MIME type, best-effort |
etag | text | Backend-defined identifier |
modified_at | timestamp | Last-modified timestamp (RFC3339) |
url | text | Presigned GET URL by default |
data | text | base64 body — populated when selected |
List queries use the same GraphJin query surface as database tables where it maps cleanly:
{ avatars(
where: { key: { like: "users/%" } }
order_by: { key: asc }
limit: 50
) {
key size content_type modified_at url
}
}
The older prefix: "users/", key: "users/42.png", and inline_data: true shortcuts are still accepted for compatibility, but the normal GraphJin read surface is preferred for new callers. Filesystem tables accept id, where, order_by, limit, offset, first, last, after, and before; cursor inputs use the same variable-based shape as database tables.
Cursor pagination returns the usual root cursor field:
query Files($cursor: Cursor) {
avatars(first: 50, after: $cursor, order_by: { key: asc }) {
key size url
}
avatars_cursor
}
Single-key fetch (HEAD-equivalent + presigned URL):
{ avatars(id: "users/42.png") { key size url } }
Inline body (small files only — heavyweight path):
{ avatars(id: "users/42.png") {
key size url data # data is base64
}
}
where and order_by are supported on portable metadata columns: key, size, content_type, etag, and modified_at. url and data are generated response fields, so they are selected but not filtered or sorted.
Per-table options:
| Field | Purpose |
|---|---|
presign_ttl | URL validity (default 15 min) |
public_base_url | When set, replaces presigned URLs with <base>/<key> — useful for CDN fronting |
endpoint | S3 endpoint override (MinIO, localstack, R2, etc.) |
max_list_page_size | Caps limit: for list queries (default 1000) |
Authentication — uses each platform's standard credential chain, never embedded in GraphJin config:
- S3: env vars /
~/.aws/credentials/ IRSA / EC2 IMDS - GCS: Application Default Credentials (env, GCE/GKE metadata server,
gcloud auth) - Local: filesystem permissions
Build-tag gating — slim builds drop SDK weight. -tags no_s3 excludes the S3 backend, -tags no_gcs excludes GCS. Local is always built in.
Custom backends — register through core.OptionSetFilesystemBackend(name, factory) to plug in Azure Blob, R2, or anything implementing the fstable.Backend interface.
CodeSQL Source Indexes
CodeSQL makes a source tree behave like another database in GraphJin. Configure a folder, and GraphJin creates a managed SQLite cache under config/codesql/, indexes source files with tree-sitter, and reconciles new/changed/deleted files on startup. Development services also watch the tree while running; production services do not live-watch source changes and refresh the cache on restart.
sources:
- name: warehouse
kind: sql
type: snowflake
connection_string: user@account/warehouse/public?warehouse=compute_wh
default: true
- name: app_code
kind: codesql
path: /srv/app
tables:
- name: gj_code
source: app_code
read_only: true
The cache filename uses the source name as a prefix, for example config/codesql/app_code-<source-root-hash>.sqlite.
CodeSQL stores three useful internal layers and projects them into one public GraphQL root, gj_code:
| Layer | Tables | Purpose |
|---|---|---|
| Source/index state | code_languages, code_grammars, code_query_packs, code_files, code_file_versions, code_index_status, code_parse_errors | What was indexed, when, with which grammar/query-pack versions |
| Raw syntax | code_nodes, code_captures | Tree-sitter nodes, byte/range positions, named/error/missing flags, query captures |
| Code intelligence | code_symbols, code_scopes, code_locals, code_refs, code_imports, code_edges, code_injections, code_db_refs, docs/text FTS | Searchable symbols, imports, refs, locals, injections, docs, file text, and best-effort database references |
query {
gj_code(
where: { kind: { eq: "symbol" }, name: { iregex: "handler|resolver|workflow" } }
order_by: { name: asc }
limit: 20
) {
name
symbol_kind
language
start_row
path
hash
}
}
CodeSQL also projects database references as gj_code(where: { kind: { eq: "db_reference" } }), a best-effort syntax index of where database tables and columns appear in SQL files, SQL strings, GraphQL operations, GraphJin config/allowlist files, migrations, and common struct/model tags.
Because CodeSQL is projected through gj_code, it composes with the rest of GraphJin: an agent can query operational systems and the code that operates them through normal GraphJin GraphQL. That is the practical unlock for organizations: the LLM can inspect catalog items, workflows, imports, call sites, and docs together without raw shell access or a separate source-code service. It can answer questions like "which endpoints write orders?", "which code paths mention this column?", and "what changed around this data flow?" using the same audited GraphJin interface.
Boundaries are intentionally clear in v1: CodeSQL stores syntax structure, captures, scopes, local bindings, imports, references, calls, comments/docs, parse errors, and injected-language regions. It does not claim semantic type resolution, full cross-file symbol binding, inheritance correctness, or LSP-grade go-to-definition.
CodeSQL source edits go through gj_code mutations with kind: "change_set". Agents first query gj_code source fields plus path and hash, preview a guarded change set, inspect the diff, then apply it. A change set can replace byte ranges, create files, delete files, or rename files:
mutation {
gj_code(insert: {
kind: "change_set"
action: "preview"
title: "move source files"
edits: [
{ op: "create", path: "pkg/new_file.go", content: "package pkg\n", mkdirs: true }
{ op: "delete", path: "old_file.go", expected_hash: "current-file-hash" }
{ op: "rename", path: "old_name.go", new_path: "pkg/new_name.go", expected_hash: "current-file-hash", mkdirs: true }
]
}) {
id
kind
status
diff
errors_json
}
}
replace, delete, and rename require the current file hash. create does not use expected_hash and fails if the target path already exists. Long-running edit sessions can reserve source or target paths with gj_code mutations using kind: "lock" and whole_file: true for create and rename target reservations.
Catalog Graph
The catalog graph exposes GraphJin-owned catalog and workflow read data as queryable read-only nanoDB tables. It is a built-in feature governed by mode defaults and system.capabilities.catalog.read; it is regenerated on startup and has no public source or database name.
The graph only describes application databases. Runtime-only system storage and CodeSQL SQLite caches are omitted from catalog rows so the catalog graph never loops back into itself. CodeSQL remains reachable through explicit gj_catalog to gj_code relationships.
sources:
- name: app
kind: database
type: postgres
connection_string: postgres://app:secret@db/app
default: true
- name: code
kind: code
path: /srv/app
tables:
- name: users
source: app
- name: gj_code
source: code
read_only: true
Catalog item kinds:
| Kind | What it exposes |
|---|---|
database | Configured application database names and types |
table | Database/schema/table names, table type, primary key, column counts |
column | Column type, nullability, primary/unique/index flags, comments, stable keys |
relationship | Source and target columns, relationship type, cross-database flag |
workflow | Workflow discovery rows and workflow-catalog linkage |
directive, operator_set, query_pattern, mutation_pattern, entrypoint, capability, system_capability | GraphJin language and system guidance |
When exactly one CodeSQL source is selected, GraphJin automatically links gj_catalog.code_refs_id to code:gj_code.db_object_id:
query {
gj_catalog(where: { kind: { eq: "column" }, table_name: { eq: "users" }, column_name: { eq: "email" } }) {
type
gj_code {
kind
ref_kind
path
symbol_id
}
}
}
This makes an organization's data systems and codebase accessible through one governed graph. An LLM can move from "what is this column?" to "where is it read or written?" without shell access, repository-specific glue, or a separate code-search API.
Apollo Federation v2
GraphJin can register as a federation subgraph so it composes alongside other services behind Apollo Router / Cosmo / Hive Gateway:
federation:
enabled: true
version: "v2.5"
keys: # auto-derived from PKs by default
users: ["id"]
orders: ["id", "tenant_id"] # composite keys via override
shareable: ["Tag.name"]
inaccessible: ["Users.encrypted_password"]
tags:
Users.full_name: ["pii"]
_service { sdl } — returns a federation-flavoured SDL describing every non-blocked, primary-keyed table:
extend schema @link(url: "https://specs.apollo.dev/federation/v2.5",
import: ["@key","@shareable","@external","@requires","@provides","@inaccessible","@tag"])
type Users @key(fields: "id") {
id: ID!
full_name: String! @tag(name: "pii")
email: String! @shareable
encrypted_password: String! @inaccessible
}
scalar _Any
type _Service { sdl: String! }
union _Entity = Users | Products | ...
extend type Query {
_service: _Service!
_entities(representations: [_Any!]!): [_Entity]!
}
Composition succeeds out-of-the-box — Apollo Router can register the subgraph and route non-entity queries straight to GraphJin.
_entities resolution is on the roadmap — the engine returns a clear "not yet implemented" error today rather than silent failures, so cross-subgraph entity references will surface the gap to gateway operators instead of producing confusing partial responses.
Detection is fast — token-bounded substring scan over the raw query, so JSON traffic costs one extra MIME parse only when federation is enabled.
MCP Connections for AI Clients
GraphJin exposes a governed Model Context Protocol surface for AI clients. The MCP tools use the same compiler, roles, row filters, source access, allow-lists, workflow policy, and audit/runtime context as GraphQL and REST.
For local development, use GraphJin's helper:
graphjin mcp add codex
graphjin mcp add claude
The helper normalizes the server to http://localhost:8080/api/v1/mcp, probes
authentication, and installs either a native URL connection or the local proxy
needed for older auth_login deployments.
Native MCP clients can also connect directly:
codex mcp add graphjin --url http://localhost:8080/api/v1/mcp
claude mcp add --transport http graphjin http://localhost:8080/api/v1/mcp
GraphJin's /api/v1/mcp endpoint is Streamable HTTP. Use Claude's
--transport http for GraphJin; reserve SSE for legacy/custom servers:
claude mcp add --transport sse <name> <url>
claude mcp add --transport sse private-api https://api.company.com/sse \
--header "X-API-Key: your-key-here"
Hosted GraphJin can advertise standards-compatible MCP OAuth so clients handle login through protected-resource metadata, authorization-server metadata, DCR/CIMD discovery, and MCP 401 challenges:
codex mcp add graphjin --url https://graphjin.example.com/api/v1/mcp
claude mcp add --transport http graphjin https://graphjin.example.com/api/v1/mcp
auth:
type: jwt
mcp:
oauth:
enabled: true
mode: builtin # or external
scopes: ["mcp"]
Codex can also add non-URL stdio MCP servers with:
codex mcp add <server-name> -- <command> [args...]
Security Features
Role-Based Access Control
Define roles and their permissions:
// Define role detection query
conf.RolesQuery = `SELECT * FROM users WHERE id = $user_id`
conf.Roles = []core.Role{
{Name: "admin", Match: "role = 'admin'"},
{Name: "user", Match: "id IS NOT NULL"},
}
Row-Level Security
Filter rows based on user context:
conf.AddRoleTable("user", "products", core.Query{
Filters: []string{`{ owner_id: { eq: $user_id } }`},
})
Now users only see their own products.
Column Blocking
Restrict which columns a role can access:
conf.AddRoleTable("anon", "users", core.Query{
Columns: []string{"id", "name"}, // Only these columns allowed
})
Block entire tables:
conf.AddRoleTable("disabled_user", "users", core.Query{Block: true})
Disable functions:
conf.AddRoleTable("anon", "products", core.Query{
DisableFunctions: true,
})
Read-Only Databases
Mark a database as read-only to block all mutations (insert, update, delete) and DDL operations while still allowing queries:
databases:
analytics:
type: postgres
host: analytics-db.example.com
dbname: analytics
read_only: true # All mutations blocked, queries allowed
Once set in config, read_only cannot be disabled at runtime — even by MCP tools or LLM-driven config updates. This tamper protection ensures reporting and replica databases are never accidentally modified.
Query Allow Lists
In production mode, only pre-approved queries can run:
conf := &core.Config{
Production: true, // Enables allow list enforcement
}
Queries are saved locally during development and locked in production.
Response Caching (SWR)
Cache GraphQL responses with automatic invalidation on mutations. Pluggable backend — Redis for shared cache across instances, in-memory LRU as the no-config fallback.
caching:
ttl: 3600 # hard expiry (seconds); entry is dropped after this
fresh_ttl: 300 # soft expiry; entries past this trigger SWR refresh
exclude_tables: ["sessions"]
Stale-while-revalidate — when an entry is past fresh_ttl but inside ttl:
- The stale response is returned to the caller immediately (single-digit ms)
- A background worker re-runs the query under the same role and overwrites the cache entry
- Concurrent refreshes for the same key are deduplicated via singleflight
- The worker pool is bounded so a thundering herd of stale hits can't spawn unbounded goroutines
Automatic invalidation — mutations that touch a row drop every cached query that depended on that row. Indexing is row-level for small results (≤500 rows) and falls back to table-level for large results — so analytic queries don't pay row-tracking overhead.
Cache key — includes operation name, query text, variables, role, and (for ABAC) user ID. Anonymous queries (no operation name, no APQ key) bypass the cache entirely.
Advanced Features
Synthetic Tables
Create virtual tables that map to real tables:
conf.Tables = []core.Table{{Name: "me", Table: "users"}}
conf.AddRoleTable("user", "me", core.Query{
Filters: []string{`{ id: $user_id }`},
Limit: 1,
})
query {
me @object {
email
}
}
# Returns current user's data
Views Support
Query database views with relationship configuration:
conf.Tables = []core.Table{{
Name: "hot_products",
Columns: []core.Column{
{Name: "product_id", Type: "int", ForeignKey: "products.id"},
},
}}
query {
hot_products(limit: 3) {
product {
id
name
}
}
}
Multi-Schema Support
Query tables from different database schemas:
query {
test_table @schema(name: "custom_schema") {
column1
column2
}
}
Transaction Support
Execute queries within a transaction:
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
res, _ := gj.GraphQLTx(ctx, tx, query, vars, nil)
tx.Commit()
CamelCase Conversion
Automatically convert between camelCase (GraphQL) and snake_case (SQL):
conf := &core.Config{EnableCamelcase: true}
query {
hotProducts { # Queries hot_products table
countProductID # Maps to count_product_id
products { id }
}
}
Multi-Database Support
GraphJin supports operational databases, warehouses, and CQL/NoSQL systems through the same GraphQL compiler surface. Dialect-specific limits remain explicit:
| Database | Queries | Mutations | Subscriptions | Arrays / collections | Full-Text | GIS | Schema DDL |
|---|---|---|---|---|---|---|---|
| PostgreSQL | Yes | Yes | Yes | Yes | Yes | PostGIS | Yes |
| MySQL | Yes | Yes | Yes | No | Yes | 8.0+ | Yes |
| MariaDB | Yes | Yes | Yes | No | Yes | Yes | Yes |
| MSSQL | Yes | Yes | Yes | No | No | Yes | Yes |
| Oracle | Yes | Yes | Yes | No | No | Yes | Yes |
| SQLite | Yes | Yes | Yes | No | FTS5 | SpatiaLite | Yes |
| MongoDB | Yes | Yes | Yes | Yes | Yes | Yes | No |
| Cassandra / Keyspaces | CQL-native | Single-table PK | Partition-bound polling | Limited CQL | No | No | No |
| Snowflake | Yes | Yes | No | No | No | No | Yes |
| Redshift | Experimental queries | Experimental PK writes | Experimental batched polling | No | Limited search | Limited | Experimental basic DDL |
| BigQuery | Experimental queries | No | No | Limited | No | No | No |
| CockroachDB | Yes | Yes | Yes | Yes | Yes | No | No |
Also works with: AWS Aurora/RDS, Google Cloud SQL, YugabyteDB. Snowflake supports key pair (JWT) authentication and SHOW-based catalog discovery/paging. Redshift support is experimental: queries/discovery, single-table primary-key writes, batched polling subscriptions, limited ILIKE search over configured full_text columns, limited spatial filters, and basic generated DDL. Redshift subscriptions are polling-based warehouse queries, not native change streams. BigQuery support is experimental and query-focused. Cassandra / Keyspaces support CQL-native queries, partition-bound polling subscriptions, and single-table primary-key writes; aggregates, full scans, OR-style cross-partition filters, full-text, and GIS remain outside the generic surface.
Cross-Database Joins
When tables live in different databases, GraphJin automatically handles cross-database joins. Write a normal nested query — GraphJin fetches the parent from one database, extracts the foreign key, queries the child table in the target database, and stitches the results together:
query {
orders {
id
total
customer { # 'customer' lives in a different database
name
email
}
}
}
Configure the relationship in your config:
databases:
main:
type: postgres
default: true
crm:
type: postgres
tables: [customers]
tables:
- name: orders
columns:
- name: customer_id
related_to: customers.id
The join is transparent — no special query syntax needed. GraphJin handles ID extraction, cross-database querying, and result stitching automatically.
Configuration Reference
Key configuration options:
conf := &core.Config{
// Database
DBType: "postgres", // postgres, mysql, mongodb, etc.
// Security
SecretKey: "encryption-key", // For cursor encryption
DisableAllowList: false, // Enforce allow list in production
Production: true, // Production mode
// Features
EnableCamelcase: true, // camelCase to snake_case
DefaultLimit: 20, // Default query limit
// Subscriptions
SubsPollDuration: 2, // Seconds between polls
// Variables
Vars: map[string]string{
"product_price": "50",
},
}
Table configuration:
conf.Tables = []core.Table{
{
Name: "products",
OrderBy: map[string][]string{
"by_price": {"price desc", "id asc"},
},
Columns: []core.Column{
{Name: "category_id", ForeignKey: "categories.id"},
},
},
}
Role configuration:
conf.AddRoleTable("user", "products", core.Query{
Filters: []string{`{ owner_id: { eq: $user_id } }`},
Columns: []string{"id", "name", "price"},
DisableFunctions: false,
Limit: 100,
})
conf.AddRoleTable("user", "products", core.Insert{
Presets: map[string]string{"owner_id": "$user_id"},
})
Why GraphJin?
| Traditional Approach | With GraphJin |
|---|---|
| Write REST endpoints for each use case | Write one GraphQL query |
| Manual SQL query optimization | Automatic LATERAL JOIN optimization |
| N+1 query problems | Single optimized query |
| Weeks of API development | Minutes |
| Maintain resolver code | Zero backend code |
| Manual security checks | Declarative role-based security |
| Database-specific code | Same code works on 8 databases |
GraphJin is production-ready, high-performance, and saves development teams thousands of hours.