Skip to content

MySQL Support

Excalibase GraphQL supports MySQL 8.4+ as a first-class database backend. All core features work out of the box — simply point the application at your MySQL database and get a full GraphQL API automatically.

Quick Start

# Start the MySQL stack
docker-compose -f docker-compose.mysql.yml up -d

# GraphQL endpoint (project-scoped)
http://localhost:10001/{projectId}/graphql

Configuration

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/excalibase
    username: excalibase
    password: password123
    driver-class-name: com.mysql.cj.jdbc.Driver

app:
  schemas: excalibase
  database-type: mysql

server:
  port: 10001

Supported Features

CRUD Operations

Full create, read, update, and delete on every table:

# Query with filtering and pagination
{
  excalibaseCustomer(
    where: { active: { eq: 1 } }
    orderBy: { last_name: "ASC" }
    limit: 10
    offset: 0
  ) {
    customer_id
    first_name
    last_name
    email
  }
}

# Create
mutation {
  createExcalibaseCustomer(input: {
    first_name: "Alice"
    last_name: "Smith"
    email: "alice@example.com"
  }) {
    customer_id
    first_name
    last_name
  }
}

# Update
mutation {
  updateExcalibaseCustomer(id: 1, input: { email: "newemail@example.com" }) {
    customer_id
    email
  }
}

# Delete
mutation {
  deleteExcalibaseCustomer(id: 1) {
    customer_id
  }
}

# Bulk create
mutation {
  createManyExcalibaseCustomer(inputs: [
    { first_name: "Bob", last_name: "Jones", email: "bob@example.com" }
    { first_name: "Carol", last_name: "White", email: "carol@example.com" }
  ]) {
    customer_id
    first_name
  }
}

ENUM Types

MySQL ENUM columns are reflected as GraphQL enum types:

CREATE TABLE task (
    task_id  BIGINT AUTO_INCREMENT PRIMARY KEY,
    title    VARCHAR(200) NOT NULL,
    status   ENUM('todo', 'in_progress', 'done', 'cancelled') DEFAULT 'todo',
    priority ENUM('low', 'medium', 'high', 'critical')        DEFAULT 'medium'
);
{
  excalibaseTask(where: { status: { eq: "in_progress" }, priority: { eq: "high" } }) {
    task_id
    title
    status
    priority
  }
}

JSON Types

MySQL JSON columns (and Postgres json/jsonb) are the GraphQL JSON scalar: they answer JSON values and take them as input, inline or through a JSON variable:

CREATE TABLE product_detail (
    detail_id  BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id BIGINT NOT NULL,
    attributes JSON,
    metadata   JSON,
    tags       JSON
);
{
  excalibaseProductDetail {
    detail_id
    attributes
    metadata
    tags
  }
}

Other column types

MySQL type Answered as Written from
DECIMAL a string with every digit, e.g. "19.90" a number or numeric string, kept exact
BIGINT, BIGINT UNSIGNED a string, up to "18446744073709551615" a number or numeric string
TIME "838:59:59", as stored (past 24 hours, negative) the same form
BINARY, VARBINARY, BLOB, BIT \x00ff10 (\x and lowercase hex), as Postgres bytea the same form

Views

Read-only GraphQL types are generated for MySQL views:

{
  excalibaseActiveCustomers {
    customer_id
    first_name
    last_name
    email
  }
}

{
  excalibaseOrdersSummary {
    customer_id
    first_name
    last_name
    order_count
    total_spent
  }
}

Foreign Key Relationships

Forward and reverse FK fields are automatically added to the schema:

{
  excalibaseOrders {
    order_id
    total
    status
    excalibaseCustomer {          # forward FK: orders.customer_id -> customer
      first_name
      last_name
    }
  }
}

{
  excalibaseCustomer {
    customer_id
    first_name
    excalibaseOrders {            # reverse FK: all orders for this customer
      order_id
      total
      status
    }
  }
}

Aggregate Queries

{
  excalibaseOrdersAggregate {
    count
    sum
    avg
    min
    max
  }
}

Cursor-Based Pagination

{
  excalibaseCustomerConnection(first: 10, after: "cursor123") {
    edges {
      node {
        customer_id
        first_name
        last_name
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

Stored procedures

Not exposed. Only tracked functions are reachable, and tracking is Postgres-only: a MySQL function returns a scalar, not rows of a table, so no table permission could apply to its result (see Functions).

Permissions

Permissions apply to MySQL projects as they do to Postgres ones: the same document compiles to MySQL SQL, and a differential suite checks every case against native Postgres RLS on identical data.

  • TIMESTAMP compares in UTC whatever the session time zone; ENUM compares by declaration order; FLOAT compares at single precision; NaN/Infinity (which MySQL cannot store) match nothing.
  • Text compares under the column's own collation, so on utf8mb4_0900_ai_ci _eq: "alpha" keeps "Alpha". Realtime asks the database for such rows instead of deciding in memory, so it delivers exactly the rows a query returns.
  • MySQL cannot return the rows a write touches, so writes lock and key them first, then the permission's check runs on exactly the rows found again by those keys; a written row that cannot be found again (an AUTO_INCREMENT 0, a rounded key) rolls the write back. A write needs a primary key; an insert must supply it or let AUTO_INCREMENT generate it; an update that changes it is followed to the new key; onConflict is refused.
  • That check runs after the write, so an insert or update check that reads, through a relationship or _exists, a table the same mutation writes (this field's table or an earlier field's) is refused with 400: Postgres judges it against the database as it was before the write, which MySQL cannot.
  • The engine cannot see writes made by the project's triggers or foreign-key cascades, but a check does: it can be satisfied by rows its own write produced that way, which Postgres would not see. Keep checks off tables that triggers or cascades write.
  • Permission keys name a table <database>.<table>, the database being its schema.

Filtering Operators

Operator Description Example
eq Equal { status: { eq: "active" } }
neq Not equal { status: { neq: "cancelled" } }
gt / gte Greater than / or equal { total: { gte: 50 } }
lt / lte Less than / or equal { price: { lte: 100 } }
in In list { status: { in: ["pending", "processing"] } }
notIn Not in list { status: { notIn: ["cancelled"] } }
isNull Is null { email: { isNull: true } }
isNotNull Is not null { email: { isNotNull: true } }
contains Substring match { first_name: { contains: "ali" } }
startsWith Prefix match { last_name: { startsWith: "Sm" } }
endsWith Suffix match { email: { endsWith: "@example.com" } }
like SQL LIKE pattern { email: { like: "%@gmail%" } }

Differences from PostgreSQL

Feature PostgreSQL MySQL
orderBy value Enum (ASC / DESC) String ("ASC" / "DESC")
Update mutation update{Table}(input: { pk, fields... }) update{Table}(id: X, input: { fields... })
Aggregate result Nested per-column (sum { total }) Flat scalars (sum, avg)
Subscriptions / CDC Supported Not yet supported
Custom composite types Supported N/A
Array types Supported N/A

REST API

The PostgREST-compatible REST API (/{projectId}/api/v1/{table}) serves MySQL with the same surface as GraphQL: select with to-one and to-many embeds, filters, order (including nullsfirst/nullslast), limit/offset, Prefer: count=exact, inserts, bulk inserts, updates, deletes and Prefer: resolution=merge-duplicates upserts.

MySQL has no RETURNING, so Prefer: return=representation reads the written rows back by primary key:

Write How the rows come back
Insert One statement per row; read back by the key sent, or the AUTO_INCREMENT key generated (only when the key is that column)
Update Keys of the matched rows locked (FOR UPDATE) before the update, then read back (a changed key is followed)
Delete Rows read, locked, before they are deleted
Upsert The sent key is locked (FOR UPDATE): an INSERT when no row holds it, else an UPDATE of that row alone, read back by the key sent; without every key column it is a plain insert. An existing row the update permission's filter hides is left as it is

Filters run natively where MySQL has the operator: match/imatch (REGEXP_LIKE), cs/cd/haskey (JSON_CONTAINS, JSON_CONTAINS_PATH) and fts/plfts/phfts (MATCH … AGAINST, the column needs its own FULLTEXT index).

An upsert that collides on a unique key other than the primary key fails with 409 unique_violation, as on Postgres, however that key's columns were filled (sent, DEFAULT, generated or by a trigger): the upsert never uses ON DUPLICATE KEY UPDATE, which would update whichever row collides. Under READ COMMITTED, which takes no gap lock, a concurrent insert of the same key makes the upsert fail as a duplicate rather than write twice.

Refused with 400 and a message naming MySQL, never ignored:

  • array, range, JSON path, web-search and vector operators (ov, sl, arraylength, jsonpath, wfts, vector, …)
  • return=representation (or max-affected), or a permission check, on a table without a primary key, or an insert whose key is neither sent nor generated
  • RPC (/rpc/{function}): functions are not tracked on MySQL

A filter on a column the table does not have is a 400 naming the column on every database, also inside or=(…): a typo never returns every row.

Limitations

  • CDC / Subscriptions — real-time subscriptions via Change Data Capture are PostgreSQL-only for now
  • Spatial types — PostGIS equivalents not yet supported