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

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:

OperatorDescriptionExample
eqEquals{ id: { eq: 1 } }
neqNot equals{ id: { neq: 1 } }
gtGreater than{ price: { gt: 10 } }
gte, greater_or_equalsGreater or equal{ price: { gte: 10 } }
ltLess than{ price: { lt: 100 } }
lte, lesser_or_equalsLess or equal{ price: { lte: 100 } }
inIn list{ id: { in: [1,2,3] } }
ninNot in list{ id: { nin: [1,2] } }
is_nullIs null{ id: { is_null: true } }
iregexCase-insensitive regex{ name: { iregex: "product" } }
has_keyJSON has key{ metadata: { has_key: "foo" } }
has_key_anyJSON 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:

FunctionExample
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.

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 fieldsGET /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 tablesGET /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:

ModePath shapeBehaviour
Row-joinGET /resource/{id} with a matching joins: entryChild field on the parent DB table; parent column populates the path param
Top-level (single)GET /resource/{id} without a joins: entryRoot field; path param becomes a required field argument
Top-level (list)GET /resources with optional query filtersRoot field; each query param becomes an optional field argument
SkippedNon-JSON response, mutating verb (POST/PUT/DELETE), or unsupported authLogged at boot; not exposed

Authentication — declared in YAML, applied automatically to every request to the upstream:

SchemeUse for
bearerstatic or pass-through tokens
basicusername/password
api_keyheader or query param
oauth2_client_credentialsmachine-to-machine OAuth
token_exchangevendor-specific POST → JSON token flows

Per-spec concurrency, response shaping, and overridesresult_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:

ConstraintDescription
format"email", custom regex
minMinimum length
maxMaximum length
requiredField is required
requiredIfRequired if condition matches
greaterThanNumeric comparison
lessThanNumeric comparison
equalsExact match
lessThanOrEqualsFieldCompare 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:

ColumnTypeDescription
keytext (PK)Object's path within the table's effective root
sizebigintBytes
content_typetextMIME type, best-effort
etagtextBackend-defined identifier
modified_attimestampLast-modified timestamp (RFC3339)
urltextPresigned GET URL by default
datatextbase64 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:

FieldPurpose
presign_ttlURL validity (default 15 min)
public_base_urlWhen set, replaces presigned URLs with <base>/<key> — useful for CDN fronting
endpointS3 endpoint override (MinIO, localstack, R2, etc.)
max_list_page_sizeCaps 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:

LayerTablesPurpose
Source/index statecode_languages, code_grammars, code_query_packs, code_files, code_file_versions, code_index_status, code_parse_errorsWhat was indexed, when, with which grammar/query-pack versions
Raw syntaxcode_nodes, code_capturesTree-sitter nodes, byte/range positions, named/error/missing flags, query captures
Code intelligencecode_symbols, code_scopes, code_locals, code_refs, code_imports, code_edges, code_injections, code_db_refs, docs/text FTSSearchable 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:

KindWhat it exposes
databaseConfigured application database names and types
tableDatabase/schema/table names, table type, primary key, column counts
columnColumn type, nullability, primary/unique/index flags, comments, stable keys
relationshipSource and target columns, relationship type, cross-database flag
workflowWorkflow discovery rows and workflow-catalog linkage
directive, operator_set, query_pattern, mutation_pattern, entrypoint, capability, system_capabilityGraphJin 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:

  1. The stale response is returned to the caller immediately (single-digit ms)
  2. A background worker re-runs the query under the same role and overwrites the cache entry
  3. Concurrent refreshes for the same key are deduplicated via singleflight
  4. 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:

DatabaseQueriesMutationsSubscriptionsArrays / collectionsFull-TextGISSchema DDL
PostgreSQLYesYesYesYesYesPostGISYes
MySQLYesYesYesNoYes8.0+Yes
MariaDBYesYesYesNoYesYesYes
MSSQLYesYesYesNoNoYesYes
OracleYesYesYesNoNoYesYes
SQLiteYesYesYesNoFTS5SpatiaLiteYes
MongoDBYesYesYesYesYesYesNo
Cassandra / KeyspacesCQL-nativeSingle-table PKPartition-bound pollingLimited CQLNoNoNo
SnowflakeYesYesNoNoNoNoYes
RedshiftExperimental queriesExperimental PK writesExperimental batched pollingNoLimited searchLimitedExperimental basic DDL
BigQueryExperimental queriesNoNoLimitedNoNoNo
CockroachDBYesYesYesYesYesNoNo

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 ApproachWith GraphJin
Write REST endpoints for each use caseWrite one GraphQL query
Manual SQL query optimizationAutomatic LATERAL JOIN optimization
N+1 query problemsSingle optimized query
Weeks of API developmentMinutes
Maintain resolver codeZero backend code
Manual security checksDeclarative role-based security
Database-specific codeSame code works on 8 databases

GraphJin is production-ready, high-performance, and saves development teams thousands of hours.