[try:/devdoc/test-query|Test Queries|play|right]
Overview
Our query language is GraphQL-inspired but designed for SQL-based backends and REST API adaptation. It uses a familiar, nested, and expressive syntax that eliminates the N+1 problem through intelligent batching and query optimization.
card: GraphQL-Inspired
Familiar nested syntax with entity selection, field specification, and parameter support
card: REST Compatible
Can translate queries to REST endpoints for compatibility with existing services
card: Performance Optimized
Eliminates N+1 SQL problems through automatic batching and query optimization
Query Examples
Basic Query
Fetch all fields from an entity [badge:Basic|info|right]
products {
id
name
price
description
}
Query with Parameters
Use parameters for filtering and pagination [badge:Parameters|info|right]
products(
where: "status = 'active'",
limit: 10,
orderBy: [name@asc]
) {
id
name
price
}
Nested Relations
Fetch related entities in a single query [badge:Relations|info|right]
products {
id
name
category {
id
name
}
variants(limit: 10) {
id
price
}
}
Query with Aggregations
Include aggregation functions in your query [badge:Aggregation|info|right]
products(
where: "price > 100"
) {
id
name
@count as total_products
@sum(price) as total_price
}
Field Functions
Apply functions to fields for transformations [badge:Functions|info|right]
categories() {
name,
length(name) as name_length,
isnull(description) as has_no_description,
upper(code) as code_upper
}
Date Functions
Extract date/time components from fields [badge:Functions|info|right]
orders() {
id,
order_date,
year(order_date) as order_year,
month(order_date) as order_month,
quarter(order_date) as order_quarter,
dayofweek(order_date) as weekday
}
Date Range Functions
Get precise time period boundaries for analytics [badge:Functions|info|right]
analytics() {
id,
event_date,
startOfYear(event_date) as year_start,
endOfYear(event_date) as year_end,
startOfMonth(event_date) as month_start,
endOfMonth(event_date) as month_end,
startOfHour(event_date) as hour_start,
endOfHour(event_date) as hour_end
}
GROUP BY with Functions
Use functions in aggregation grouping [badge:Aggregation|info|right]
sales(
@count as total_sales,
@sum(amount) as total_revenue,
@groupBy(year(sale_date), month(sale_date))
) { * }
Gap-Fill for Time Series Charts
Fill missing periods in grouped results for continuous charts [badge:Aggregation|info|right]
orders(
@count as order_count,
@sum(total) as monthly_revenue,
@groupBy(yearmonth(created_at)),
@fillGaps(yearmonth(created_at))
) { }
With an explicit date range (extend beyond data boundaries):
orders(
@count as order_count,
@sum(total) as monthly_revenue,
@groupBy(yearmonth(created_at)),
@fillGaps(yearmonth(created_at), from: "2024-01", to: "2024-12")
) { }
Complex Query
Combine multiple features in one query [badge:Advanced|info|right]
orders(limit: 10) {
id
total
user {
id
name
}
items {
id
quantity
product {
id
name
price
}
}
}
Translatable Fields
Query multilingual fields with language-specific selection [badge:Advanced|info|right]
products(limit: 10) {
id
code
name // Returns {"en": "English", "cs": "Czech"}
name:cs // Returns Czech translation only
name:en // Returns English translation only
description:cs as czech_desc
category {
name:cs
name:en
}
}
Historical Data Query
Query historical versions from archive tables combined with current row [badge:Advanced|info|right]
products(
@history
where: "id = 123"
) {
id
name
price
_deleted
_deletedAt
_archiveValidSince
_archiveValidTo
}
Point-in-Time History Query
Query data as it existed at a specific timestamp plus current row [badge:Advanced|info|right]
products(
@history(from: "2025-01-01T00:00:00")
where: "price > 100"
limit: 10
) {
id
name
price
_archiveValidSince
_archiveValidTo
}
Syntax Rules
| Syntax | Description | Example |
|---|---|---|
entity(params) { fields } | Entity Selection — specify entity name followed by optional parameters in parentheses | products(where: "active = true") |
{ id, name } or { * } | Field Selection — list fields in curly braces, use * for all fields | { id, name, price } or { * } |
lower(), upper(), year(), startOfYear(), isnull(), etc. | Field Functions — apply functions to fields: lower(), upper(), length(), year(), month(), startOfYear(), endOfMonth(), isnull(), etc. | lower(name) as name_lower, startOfYear(created_at) |
where, limit, offset, orderBy, id | Parameters — use where, limit, offset, orderBy, and id parameters. Default limit is 1000 rows. Use limit: 0 for unlimited results. | where: "status = 'active'", limit: 10 |
relation { fields } | Relations — nest related entities with their own field selection | category { id, name } |
@count, @sum(field), @avg(field) | Aggregations — use @ prefix for aggregation functions | @count, @sum(price), @avg(rating) |
@fillGaps(field) or @fillGaps(field, from: x, to: y) | Gap Fill — fill missing periods in grouped time-series results (use with @groupBy). Auto-detects range or accepts explicit from/to bounds | @fillGaps(yearmonth(createdAt)) or @fillGaps(month, from: 1, to: 12) |
[field@asc], [field@desc] | Order By — array of field@direction pairs for sorting | [created_at@desc, name@asc] |
field as alias | Aliases — use “as” keyword to rename fields or relations | category as product_category |
name:cs, description:en | Translatable Fields — select specific language variants using fieldName:languageCode syntax | name:cs, description:en (supported: cs, en, de, sk, pl, hu, fr, es) |
@history or @history(from: "date") | History Queries — use @history directive to query historical data from archive tables combined with current row. Optionally specify a point-in-time with from parameter | @history or @history(from: "2025-01-01T00:00:00") |
Field Functions
Transform and analyze field values directly in your queries
Field functions allow you to apply transformations and checks to fields in the select clause. Functions can be used standalone or with aliases using the as keyword.
String Functions
| Function | Description | Example |
|---|---|---|
lower(field) | Convert string to lowercase | lower(name) |
upper(field) | Convert string to uppercase | upper(code) |
length(field) | Get the length of a string field | length(description) |
isnull(field) | Check if a field is NULL | isnull(email) |
Date / Time Functions
| Function | Description | Example |
|---|---|---|
year(field) | Extract year from date/datetime | year(created_at) |
month(field) | Extract month from date/datetime | month(birth_date) |
day(field) | Extract day from date/datetime | day(due_date) |
week(field) | Extract week number from date | week(created_at) |
quarter(field) | Extract quarter from date (1-4) | quarter(order_date) |
dayofweek(field) | Extract day of week (0-6, Sunday=0) | dayofweek(event_date) |
yearmonth(field) | Get year-month string (YYYY-MM) | yearmonth(created_at) |
yearmonthday(field) | Get year-month-day string (YYYY-MM-DD) | yearmonthday(created_at) |
yearweek(field) | Get year-week string (YYYY-WW) | yearweek(start_date) |
yearquarter(field) | Get year-quarter string (YYYY-QN) | yearquarter(fiscal_date) |
Date Range Functions
| Function | Description | Example |
|---|---|---|
startOfYear(field) | Get beginning of year (YYYY-01-01 00:00:00) | startOfYear(created_at) |
endOfYear(field) | Get end of year (YYYY-12-31 23:59:59.999) | endOfYear(created_at) |
startOfMonth(field) | Get beginning of month (YYYY-MM-01 00:00:00) | startOfMonth(created_at) |
endOfMonth(field) | Get end of month (YYYY-MM-31 23:59:59.999) | endOfMonth(created_at) |
startOfDay(field) | Get beginning of day (YYYY-MM-DD 00:00:00) | startOfDay(created_at) |
endOfDay(field) | Get end of day (YYYY-MM-DD 23:59:59.999) | endOfDay(created_at) |
startOfHour(field) | Get beginning of hour (YYYY-MM-DD HH:00:00) | startOfHour(created_at) |
endOfHour(field) | Get end of hour (YYYY-MM-DD HH:59:59.999) | endOfHour(created_at) |
Usage Examples
String transformation functions:
users() {
name,
lower(name) as name_lower,
upper(code) as code_upper,
length(description) as desc_length
}
Date/time extraction functions:
orders() {
id,
order_date,
year(order_date) as order_year,
month(order_date) as order_month,
dayofweek(order_date) as weekday
}
Mixed function usage:
products() {
name,
isnull(description) as has_no_description,
length(name) as name_length,
yearmonth(created_at) as creation_month
}
Date range functions for time period analysis:
analytics() {
id,
event_date,
startOfYear(event_date) as year_start,
endOfYear(event_date) as year_end,
startOfMonth(event_date) as month_start,
endOfMonth(event_date) as month_end,
startOfDay(event_date) as day_start,
endOfDay(event_date) as day_end,
startOfHour(event_date) as hour_start,
endOfHour(event_date) as hour_end
}
Functions in GROUP BY aggregations:
orders(
@count as order_count,
@sum(total) as total_revenue,
@groupBy(year(created_at), month(created_at))
) { * }
// Date range functions in GROUP BY
events(
@count as event_count,
@sum(value) as total_value,
@groupBy(startOfYear(event_date), startOfMonth(event_date))
) { * }
**Notes**
- Functions can be aliased using the `as` keyword
- All function names are case-insensitive
- String functions work with any field that can be converted to string
- Date/time functions work with DATE and DATETIME field types
- Date range functions return precise timestamps using PostgreSQL's DATE_TRUNC and INTERVAL operations
- Functions can be used in both field selection and @groupBy aggregations
- `dayofweek()` returns 0-6 where 0=Sunday, 6=Saturday
- `quarter()` returns 1-4 for Q1-Q4
- `yearmonth()` returns format 'YYYY-MM' (e.g., '2023-12')
- `yearweek()` returns format 'YYYY-WW' (e.g., '2023-52')
- `yearquarter()` returns format 'YYYY-QN' (e.g., '2023-Q4')
- Date range functions provide microsecond precision for accurate time period boundaries
Advanced Features
Field Functions
Apply transformations and checks directly in queries
lower(name), year(created_at), length(description)
Multiple ID Queries
Fetch by one or more IDs using arrays and mixed types
products(id: ['id1', "uuid", 123]) { * }
Deep Nesting
Access deeply nested relations without performance penalties
orders { user { profile { preferences } } }
Comments Support
Add comments to your queries for documentation
// Single line comment
/* Multi-line comment */
products(limit: 10) {
id
name // inline comment
}
Translatable Fields
Translatable (multilingual) fields can be queried in multiple ways:
Without language suffix:
products { name, description }
Returns all filled languages as JSON object: {"en": "English", "cs": "Czech"}
With language suffix:
products {
name:cs, // Czech only
name:en, // English only
name:de // German only
}
Returns only the specific language as string. Supports: cs, en, de, sk, pl, hu, fr, es
With aliases:
products {
name:cs as czech_name,
name:en as english_name
}
Use aliases to rename fields with language suffixes
**Language Selection:**
- Language is selected using the Accept-Language HTTP header.
- All translatable fields in query results return all the filled languages by default (without language suffix).
- Use fieldName:languageCode syntax to get only specific language.
- Language suffix works both in WHERE filters and SELECT fields.
- If the requested language is not available, the field may be empty or null.
- Example: `Accept-Language: en-US, cs-CZ;q=0.9`
Lookups and External IDs
You can reference entities using lookup identifiers instead of numeric/UUID IDs. This is especially useful when working with data from external systems like ABRA Flexi/FlexiBee.
Standard Lookups
Use field-based identifiers like code:, ean:, or guid:
Using code lookup:
products(id: "code:ABC123") { id, name, code }
Using lookup in WHERE clause:
products(where: "category = 'code:ELEC'") { id, name }
Multiple IDs with lookups:
products(id: ["code:ABC", "ean:123456", "uuid-value"]) { * }
External ID Lookups
External IDs (ext:) allow you to reference entities using identifiers from external systems. These IDs are stored in a dedicated extIds table and support multi-part keys with colons.
Using external ID in query:
products(id: "ext:SYSTEM:PRODUCT:12345") { id, name, code }
Using external ID in WHERE clause:
products(where: "category = 'ext:SYSTEM:CATEGORY:ELEC'") { id, name }
Mixed identifiers (standard + external):
products(id: ["code:ABC", "ext:SYSTEM:PRODUCT:789"]) { * }
External ID in relation fields:
orders(where: "customer = 'ext:CRM:CUSTOMER:C001'") {
id
customer {
id
name
}
}
Multi-part external IDs (with colons):
products(id: "ext:SYSTEM:NAMESPACE:TYPE:ID:123") { id, name }
- External IDs are automatically created when importing data with `ext:` identifiers
- The same entity can have multiple external IDs from different systems
- External IDs work everywhere regular IDs work (queries, WHERE clauses, relation fields)
- Everything after `ext:` is treated as the external identifier
- External IDs are case-insensitive when matching
- Stored in dedicated `extIds` table with fields: code, related_id, related_entity
API Endpoint: /api/query
The /api/query endpoint accepts POST requests with query arrays and returns streaming JSON responses for optimal performance. Multiple queries are processed in parallel and results are pushed as soon as available.
Required Headers
| Header | Description |
|---|---|
Authorization | Bearer token for authentication (e.g., Bearer your-jwt-token) |
x-organisation-id | Organization identifier to scope the query context |
x-archive-at | Optional timestamp for point-in-time queries. See [navigate:/devdoc/changeset |
x-changeset-id | Optional changeset ID for querying specific data versions. See [navigate:/devdoc/changeset |
Accept-Language | Language preference for translatable fields (e.g., en-US, cs-CZ) |
[navigate:/apiKeys|Manage API Keys|key]
Request Format
POST /api/query
Content-Type: application/json
[
{
"requestId": "products",
"query": "products(where: \"category.name = ${categoryName}\", limit: 5) { id, name, price }",
"params": {"categoryName": "Electronics"}
},
{
"requestId": "categories",
"query": "categories { id, name }",
"params": {}
}
]
Success Response
[
{
"requestId": "products",
"success": true,
"data": [
{"id": 1, "name": "Product 1", "price": 100},
{"id": 2, "name": "Product 2", "price": 150}
]
}
]
Error Response
[
{
"requestId": "products",
"success": false,
"error": "Invalid where clause syntax",
"errorCode": "QUERY_ERROR"
}
]
Responses are streamed part by part for optimal performance. Multiple queries are processed in parallel, and results may arrive in different order than requested. Use a partial JSON parser that can handle incomplete JSON when processing the response stream.
Gap-Filling for Time Series (@fillGaps)
Fill missing periods in grouped results for smooth, continuous charts
When building charts or reports over time, grouped results often have gaps — months, weeks, or years where no data exists. @fillGaps automatically inserts rows for those missing values so the result set is continuous.
Use @fillGaps together with @groupBy on the same field. It runs as a post-processing step and does not modify your data.
How It Works
- Missing intervals are detected by comparing the grouped field values against the expected range.
- Range auto-detection: by default, the range spans from the smallest to the largest value in the actual data.
- Explicit range: use
fromand/ortoto extend the range beyond what the data contains. @countand@sumaggregations are filled with0for missing rows.@avg,@min,@maxaggregations are filled withnullfor missing rows.
Supported Field Types
| Field | Format | Example |
|---|---|---|
yearmonth(field) | YYYY-MM strings | "2024-01" … "2024-12" |
yearmonthday(field) | YYYY-MM-DD strings | "2024-01-01" … "2024-01-31" |
yearweek(field) | YYYY-WW strings | "2024-01" … "2024-52" |
year(field) | integer year | 2022 … 2024 |
month(field) | integer 1–12 | 1 … 12 |
day(field), week(field), quarter(field), dayofweek(field) | integers | |
| plain integer field | any integer | @groupBy(month), @fillGaps(month) |
Monthly trend with auto-detected range
orders(
@count as order_count,
@sum(total) as monthly_revenue,
@groupBy(yearmonth(created_at)),
@fillGaps(yearmonth(created_at))
) { }
Months with no orders get order_count=0 and monthly_revenue=0
Full-year dashboard (explicit range)
orders(
@count as order_count,
@avg(total) as avg_order_value,
@groupBy(yearmonth(created_at)),
@fillGaps(yearmonth(created_at), from: "2024-01", to: "2024-12")
) { }
Always returns exactly 12 rows, Jan–Dec 2024
Weekly activity with null metrics for empty weeks
events(
@count as event_count,
@avg(duration) as avg_duration,
@groupBy(yearweek(event_date)),
@fillGaps(yearweek(event_date))
) { }
Empty weeks get event_count=0 and avg_duration=null
Integer field grouping
products(
@count as product_count,
@groupBy(month),
@fillGaps(month, from: 1, to: 12)
) { }
- `@fillGaps` must be used together with `@groupBy` on the same field
- Auto-detection fills from the minimum to the maximum value present in the data
- You can specify `from` only, `to` only, or both for fine-grained control
- Numeric `from`/`to` values are written without quotes; string values (yearmonth, yearweek) use double quotes
- The directive only affects the result set; it does not modify data or run extra SQL queries
History Queries (@history)
Query historical data from archive tables combined with current row
The @history directive allows you to query historical data from archive tables. When entities support archiving, all changes to records are preserved in special archive tables that maintain a complete history of modifications and deletions. The query returns both historical versions and the current state of records.
How It Works
The @history directive uses SQL UNION ALL to combine:
- Archive table rows — historical versions from
{entity}_archive - Current table row — the current state from
{entity}
Results are ordered by _archiveValidTo DESC NULLS LAST, meaning historical versions appear first (most recent to oldest), and the current row appears last.
Archive-Specific Fields
| Field | Type | Description |
|---|---|---|
_deleted | Boolean | Whether the record was deleted (true) or modified (false). Always false for current row |
_deletedAt | DateTime | Timestamp when the record was deleted (null if not deleted or current row) |
_archiveValidSince | DateTime | Start of the period when this version was valid. NOW() for current row |
_archiveValidTo | DateTime | End of the period when this version was valid. NULL for current row |
Query all historical versions plus current row
products(@history) {
id
name
price
_deleted
_deletedAt
_archiveValidSince
_archiveValidTo
}
Point-in-time query (data as of a specific date)
products(
@history(from: "2025-01-01T00:00:00")
where: "price > 100"
) {
id
name
price
_archiveValidSince
_archiveValidTo
}
Find all changes to a specific product
products(
@history
where: "id = 123"
) {
id
name
price
_archiveValidSince
_archiveValidTo
}
Find deleted records
products(
@history
where: "_deleted = true"
) {
id
name
_deletedAt
_archiveValidSince
_archiveValidTo
}
Common Use Cases
card: Audit Trail
Track all changes to sensitive data for compliance
card: Time Travel
View data as it existed at any point in the past
card: Change Analysis
Analyze patterns in data modifications over time
card: Deleted Records Recovery
Find and review deleted records alongside current records
- Not all entities support archiving. If you use @history on an unsupported entity, you'll receive an error
- The query always includes the current state of records as the last row(s) in the result set
- Archive tables can contain many versions, so use appropriate where clauses and limit parameters
- Timestamps should be provided in ISO 8601 format (e.g., "2025-01-01T12:00:00")
- For the current row, _archiveValidTo is NULL and _deleted is always false
Filtering with WHERE Clauses
The where parameter uses our powerful Filter System that supports various operators, logical conditions, and relationship filtering. Learn about all available operators and see practical examples.
products(where: "price > 100 and category.name = 'Electronics'") { id, name, price }
[navigate:/devdoc/where|View WHERE Clause Documentation|filter] [navigate:/devdoc/test-query|Test Queries|play]
Comprehensive guide to filtering and search capabilities
Performance & Best Practices
card: N+1 Problem Elimination
The system automatically batches and optimizes nested queries to prevent N+1 SQL problems
card: Selective Field Fetching
Only request the fields you need to minimize data transfer and processing
card: Use Pagination
Always use limit and offset for large datasets to improve response times. Default limit is 1000 rows — use limit: 0 for unlimited results (use with caution).
card: Streaming Results
Multiple queries may return results in optimized order for better throughput