API Reference¶
Excalibase GraphQL automatically generates a complete GraphQL schema from your database tables, views, and tracked functions. This page documents the shape of that schema.
Default endpoints:
- PostgreSQL: http://localhost:10000/graphql
- MySQL: http://localhost:10001/graphql
Schema Introspection¶
Explore the full auto-generated schema:
Or check what mutations are available:
Queries¶
For every table and view, three query fields are generated:
| Field | Description |
|---|---|
{schema}{Table} |
List with filtering, sorting, pagination |
{schema}{Table}Connection |
Cursor-based (Relay) pagination |
{schema}{Table}Aggregate |
Aggregate functions (count, sum, avg, min, max) |
List Query¶
{
hanaCustomer(
where: { active: { eq: true } }
orderBy: { last_name: ASC }
limit: 20
offset: 0
) {
customer_id
first_name
last_name
email
}
}
Arguments:
| Argument | Type | Description |
|---|---|---|
where |
{Table}WhereInput |
Filter conditions |
orderBy |
{Table}OrderByInput |
Sort direction per column |
limit |
Int |
Max rows to return |
offset |
Int |
Rows to skip |
Connection Query (Cursor Pagination)¶
{
hanaCustomerConnection(first: 10, after: "cursor_value") {
edges {
node {
customer_id
first_name
last_name
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
Aggregate Query¶
PostgreSQL aggregate fields return nested per-column results over the numeric columns,
narrowed by the same where as the list field (introspection shows both):
{
hanaOrdersAggregate(where: { status: { eq: "paid" } }) {
count
sum { total_amount }
avg { total_amount }
min { total_amount }
max { total_amount }
}
}
An array relationship has the same aggregate under <relationship>Aggregate, over the related
rows, with its own where (Hasura's <relationship>_aggregate). It exists where the role is allowed
aggregations on the related table:
{
hanaCustomers {
name
hanaOrdersAggregate(where: { status: { eq: "paid" } }) { count sum { total_amount } }
}
}
MySQL aggregate fields return flat scalars:
Mutations¶
For every table, the following mutations are generated:
| Mutation | Description |
|---|---|
create{Schema}{Table} |
Insert one row |
createMany{Schema}{Table} |
Bulk insert |
update{Schema}{Table} |
Update one row |
delete{Schema}{Table} |
Delete one row, returns the deleted row |
{schema}{FunctionName} |
Call a tracked VOLATILE function (see Functions) |
Create¶
mutation {
createHanaCustomer(input: {
first_name: "Alice"
last_name: "Smith"
email: "alice@example.com"
}) {
customer_id
first_name
}
}
Bulk Create¶
mutation {
createManyHanaCustomer(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
}
}
Update¶
PostgreSQL — PK is part of the input object:
mutation {
updateHanaCustomer(input: { customer_id: 1, email: "new@example.com" }) {
customer_id
email
}
}
MySQL — PK is a separate id argument:
mutation {
updateExcalibaseCustomer(id: 1, input: { email: "new@example.com" }) {
customer_id
email
}
}
Delete¶
Delete returns the deleted row so you can update your UI without a separate fetch:
Tracked function call¶
A function the project tracks, and the role may call, is a root field: STABLE/IMMUTABLE
functions on Query, VOLATILE ones on Mutation. It returns rows of its return table, filtered
and projected by the role's select permission on that table. Procedures are never exposed.
See Functions → for full documentation.
Filtering¶
Every query accepts a where argument with per-column filter inputs.
Available Operators¶
| Operator | Types | Example |
|---|---|---|
eq |
All | { status: { eq: "active" } } |
neq |
All | { status: { neq: "cancelled" } } |
gt / gte |
Numeric, Date | { total_amount: { gte: 100 } } |
lt / lte |
Numeric, Date | { price: { lte: 50 } } |
in |
All | { status: { in: ["pending", "shipped"] } } |
notIn |
All | { status: { notIn: ["cancelled"] } } |
isNull |
All | { email: { isNull: true } } |
isNotNull |
All | { email: { isNotNull: true } } |
contains |
String | { last_name: { contains: "smith" } } |
startsWith |
String | { email: { startsWith: "alice" } } |
endsWith |
String | { email: { endsWith: "@example.com" } } |
like |
String | { email: { like: "%@gmail%" } } |
ilike |
String (PG) | { name: { ilike: "%alice%" } } |
JSON Operators (PostgreSQL)¶
| Operator | Description |
|---|---|
hasKey |
JSON object contains this key |
hasKeys |
JSON object contains all these keys |
contains |
JSON is a superset of this value |
containedBy |
JSON is a subset of this value |
path |
JSON path exists |
pathText |
JSON path value equals text |
{
hanaEnhancedTypes(where: {
jsonb_col: {
hasKey: "score"
contains: "{\"active\": true}"
}
}) {
id
name
jsonb_col
}
}
Array Operators (PostgreSQL)¶
| Operator | Description |
|---|---|
contains |
Array contains this element |
hasAny |
Array overlaps with these values |
hasAll |
Array contains all these values |
{
hanaEnhancedTypes(where: {
text_array: { hasAny: ["postgresql", "graphql"] }
}) {
id
name
text_array
}
}
OR Conditions¶
{
hanaCustomer(or: [
{ first_name: { eq: "MARY" } }
{ first_name: { eq: "JOHN" } }
]) {
customer_id
first_name
last_name
}
}
Relationships¶
Forward (Many-to-One)¶
A FK column adds an object relationship named after the column without its _id
(customer_id → hanaCustomer, home_team_id → publicHomeTeam):
The older name built from the whole column (hanaCustomerId) still answers and is marked deprecated in
introspection. A name that would clash with a column or another relationship is not added.
Reverse (One-to-Many)¶
The referenced table gets a list field for the rows that point to it. It takes the same where,
orderBy, limit and offset as a table's list (the role's own row limit still applies):
{
hanaCustomer {
customer_id
first_name
hanaOrders(where: { status: { eq: "paid" } }, orderBy: { order_date: DESC }, limit: 5) {
order_id
total_amount
}
}
}
When a table points at another through several FKs (matches.home_team_id, matches.away_team_id),
each list is named by its key: publicMatchesByHomeTeam, publicMatchesByAwayTeam. The older names
(publicMatchesHomeTeamId, ...) still answer and are deprecated.
Aliases¶
Aliases work at every depth, inside relationships, connections, aggregates and mutation results:
{ publicMatches { matchId: id home: publicHomeTeam { teamName: name } } }.
bigint and decimal¶
bigint and numeric/decimal columns are read as strings holding every digit ("9223372036854775807",
"19.90"), in GraphQL and REST, on Postgres and MySQL. A JSON number past 2^53, or with more than
about 15 significant digits, is rounded by JavaScript's parser. Filters and writes take either a
string or a number. Sums and averages over these columns are strings too.
Composite Foreign Keys (PostgreSQL)¶
Multi-column FKs work the same way — include all FK columns:
Computed Fields (PostgreSQL)¶
Functions of one table row (customer_full_name(c customer)) are reflected but not exposed
for GA, to any role, service included: they are functions the project does not track, and an
untracked function is unreachable. Selecting one answers Unknown field(s): <name>. Use a view,
or a tracked function that returns rows of a table. A later step may
let a function be tracked as a computed field.
Views (Read-Only)¶
Views and materialized views appear as read-only query fields (no mutations generated):
# PostgreSQL views from initdb.sql
{ hanaActiveCustomers { customer_id first_name last_name email } }
{ hanaPostsWithAuthors { id title author_username published } }
{ hanaEnhancedTypesSummary { id name json_name array_size } }
# MySQL views
{ excalibaseActiveCustomers { customer_id first_name last_name email } }
{ excalibaseOrdersSummary { customer_id first_name order_count total_spent } }
{ excalibaseHighValueOrders { order_id total status first_name last_name } }
Composite Primary Keys (PostgreSQL)¶
Tables with composite PKs work with all CRUD operations. All PK columns are required for update/delete:
# Create
mutation {
createHanaOrderItems(input: { order_id: 1, product_id: 2, quantity: 3, price: 59.99 }) {
order_id product_id quantity price
}
}
# Update — all PK columns required
mutation {
updateHanaOrderItems(input: { order_id: 1, product_id: 2, quantity: 5 }) {
order_id product_id quantity
}
}
# Delete — all PK columns required
mutation {
deleteHanaOrderItems(input: { order_id: 1, product_id: 2 }) {
order_id product_id
}
}
Row-Level Security (PostgreSQL)¶
Send a JWT in the Authorization header. Excalibase verifies the token and sets the userId claim as the request.user_id PostgreSQL session variable before executing the query:
With this RLS policy in place:
CREATE POLICY user_isolation ON rls_orders
FOR ALL USING (user_id = current_setting('request.user_id', true));
Each user sees only their own rows — no application-level filtering needed. Requires jwt-enabled: true and a configured auth.jwks-url or public key.
A mutation whose written rows fail the role's permission check (inserting a row the caller could not own, or reassigning a row out of their permission) rolls back whole and fails with a typed error instead of database text:
{
"errors": [
{
"message": "A written row does not pass the role's permission check",
"extensions": { "code": "permission_check_failed" }
}
]
}
Match on extensions.code: permission_check_failed for a failed check, missing_session_variable / invalid_session_variable when a permission needs a session variable the request lacks or cannot convert. Reads never raise — filtered rows are simply absent.
See API permissions → for the model and the error contract.
Real-Time Subscriptions (PostgreSQL)¶
Excalibase uses PostgreSQL logical replication (CDC) to push live changes over WebSocket:
See Subscriptions → for setup and configuration.
Type Mappings¶
BIGINT and NUMERIC/DECIMAL are strings in GraphQL and REST alike: a 64-bit integer or
an exact decimal does not fit a JavaScript number. Inputs take a string or a number.
PostgreSQL¶
| DB Type | GraphQL Type |
|---|---|
INTEGER, SMALLINT |
Int |
BIGINT |
BigInteger, sent as a string ("9007199254740993") |
NUMERIC, DECIMAL |
BigDecimal, sent as a string ("19.90") |
REAL, DOUBLE PRECISION |
Float |
TEXT, VARCHAR, CHAR |
String |
BOOLEAN |
Boolean |
DATE, TIMESTAMP, TIMESTAMPTZ |
String |
TIMETZ, INTERVAL |
String |
JSON, JSONB |
JSON (custom scalar) |
INTEGER[], TEXT[], etc. |
[Int], [String], etc. |
INET, CIDR, MACADDR |
String |
BYTEA |
String (hex-encoded) |
XML |
String |
BIT(n), VARBIT(n) |
String |
UUID |
ID |
Custom ENUM |
GraphQL enum type |
| Custom composite type | GraphQL object type |
| Domain type | Mapped to base type |
MySQL¶
| DB Type | GraphQL Type |
|---|---|
INT, BIGINT, SMALLINT, TINYINT |
Int |
FLOAT, DOUBLE, DECIMAL |
Float |
VARCHAR, TEXT, CHAR |
String |
TINYINT(1) |
Boolean |
DATE, DATETIME, TIMESTAMP |
String |
JSON |
String (JSON-serialized) |
ENUM(...) |
GraphQL enum type |
Security¶
All queries use parameterized statements — SQL injection is not possible through the GraphQL interface. Input is validated against the generated type schema before execution.
Query depth is limited to prevent overly complex nested queries from reaching the database.