Working with data
Querying with TaxiQL
Introduction
TaxiQL is Taxi’s query language for fetching and transforming data across your systems. Rather than writing integration code against specific APIs or databases, TaxiQL lets you declare what data you want using semantic types.
When you write a TaxiQL query, you’re describing the meaning of the data you need, not where to find it. Query engines like Orbital use these semantic types to automatically discover and orchestrate the necessary service calls - whether that’s a simple database query, or a complex flow across REST APIs, message queues, and serverless functions.
This semantic approach means your queries remain stable even as your architecture evolves. As services change their APIs or data moves between systems, TaxiQL adapts automatically, eliminating the traditional maintenance burden of integration code.
Basic syntax
The basic syntax of a TaxiQL query looks like this:
// Find all the people
find { Person[] }
// Find a person named Jim
find { Person( FirstName == 'Jim' ) }
// Find all the people named Jim
find { Person[]( FirstName == 'Jim' ) }
// Find a stream of person events from somewhere
stream { PersonEvents }Here’s some interactive examples:
Fetch a list of data from a service
Fetches a list of Person[] instances.
Server-side filtering
Adding contraints to the data being fetched controls the APIs that are called, only calling operations that satisfy the constraints
Learn more about server-side filtering and constraints
Client side filtering
Fetches data by calling an operation, then filters the result
Learn more about client-side filtering and expressions
Navigating data structures
You can access properties in models in two ways:
- Using semantic types (preferred) - `Customer::AddressLine“
- Using property names (classic, but not recommended) - `customer.addressLine“
You can also mix ‘n’ match these approaches.
Use types to stay decoupled and prevent breaking changes
Using types to access data keeps systems loosely coupled.
When consumers request data by its type, values are returned regardless of field names or nesting structure.
As schemas evolve, type-based access remains resilient to structural changes.
Using types to traverse data
Use the :: operator to perform deep traversal through data structures:
// Find AddressLine1 at any depth within Customer
Customer::AddressLine1
// Chain deep traversals
// Find Address anywhere beneath Customer, and then PostCode anywhere beneath Address
Customer::Address::PostCodeThe :: operator performs deep traversal, searching recursively through all nested levels of the data structure, not just immediate fields.
eg:
// Finds an instance of AddressLine1 anywhere in a Customer model
Customer::AddressLine1Ambiguous results return null
When writing an expression like Customer::AddressLine1, the result must select exactly one field, otherwise the statement is ambiguous, and the query engine returns null. See Uniqueness for more information.
Using types to traverse child properties
This example shows three different approaches to requesting a field by it's type, each with increasing specificity
Uniqueness and type traversal
When requesting data using a type, ambiguity arises if there’s more than one location of a type.
If the requested type is ambiguous within the data (ie., there are multiple instances found when a single instance was requested), then Taxi returns null.
To resolve this ambiguity, you have three options:
- Request all instances using array syntax
- Use structural navigation with property names
- Use structural navigation with type access syntax
For example:
Multiple values return null
In this query, PersonName is not unique, so returns null
Instead, you can either select all instances (eg: in the preceding example, by requesting PersonName[] instead of PersonName), or add selectors to make the
selected instance unambiguous:
Resolving type ambiguity
Either select the array, or use qualifiers to make the selected instance unambiguous
Using property names
You can reference data using property names, by using the dot-selector ..
eg:
model Customer {
profile: Profile
}
model Profile {
email: EmailAddress
}
// Property access - requires exact structure
customer.profile.emailAccessing properties by name
Access properties by name using standard 'dot' syntax
Mixing approaches
Combine traversal with property access:
// Type traversal then property
Customer::Address.streetName
// Property access then traversal
customer.addresses::PostCodeConstraints and filtering
Server-side constraints
Constraints limit which services are called based on their declared capabilities:
// Only calls services that can filter by FirstName
find { Customer(FirstName == 'Jimmy') }
// Multiple constraints
find { Customer[](
FirstName == 'Jimmy' &&
LastName == 'Smith'
) }Services declare their constraint support:
service CustomerService {
// Declares support for FirstName and LastName filtering
operation findCustomersWithName(
@PathVariable first : FirstName,
@PathVariable last : LastName
): Customer(FirstName == first && LastName == last)
}Client-side filtering
For additional filtering after data retrieval:
// Filter applied after data is fetched
find { Customer[].filter(FirstName -> FirstName == 'Jimmy') }
// Multiple conditions
find {
Customer[].filter(customer ->
customer::FirstName == 'Jimmy' &&
customer::Age > 21
)
}Performance consideration
Server-side constraints are more efficient as they reduce data transfer. Client-side filtering is more flexible but may result in more data being transferred.
Collection options
Any collection expression can be shaped with collection options — named arguments that limit, order, paginate and de-duplicate results. They’re written after the array marker, alongside an optional predicate:
find { Person[](CountryCode == 'GB', limit: 10, orderBy: DateOfBirth desc) }Collection options describe what the result should look like, not how to compute it.
An option-carrying collection reads as: an optional single predicate first (CountryCode == 'GB', which restricts which values match), followed by zero or more named options (limit: 10, orderBy: DateOfBirth desc, which shape the resulting collection).
Syntax
Type[]( predicate?, option: value, ... )- The predicate, if present, must come first.
- Options are named (
name: value) and unordered among themselves. - Each option may appear at most once.
To combine multiple conditions, write a single boolean expression rather than several predicates:
// Valid - one boolean predicate, then options
find { Person[](CountryCode == 'GB' && Status == 'Active', limit: 10) }Available options
| Option | Type | Meaning | Notes |
|---|---|---|---|
limit | Int | Return at most N items | “At most” — fewer may be returned if fewer are available. Must be non-negative. |
offset | Int | Skip the first N items | Must be non-negative. Warns if used without orderBy. |
after | String | Return the page after an opaque cursor | Mutually exclusive with before; can’t be combined with offset. |
before | String | Return the page before an opaque cursor | Mutually exclusive with after; can’t be combined with offset. |
orderBy | order term(s) | Order the collection by one or more expressions | Type asc\|desc; direction defaults to asc. Single term or list. |
uniqueBy | expression(s) | Keep at most one item per unique key | Single expression or list. |
Ordering
orderBy takes a single term, or a list of terms for multi-field ordering.
Each term is an expression (usually a type) with an optional direction — asc or desc, defaulting to asc:
// Single field, explicit direction
find { Person[](orderBy: DateOfBirth desc) }
// Direction defaults to ascending
find { Person[](orderBy: PersonName) }
// Multiple fields - use a list to avoid comma ambiguity
find { Person[](orderBy: [DateOfBirth desc, PersonName asc]) }Multiple order terms must be wrapped in a list. Writing them as bare comma-separated terms isn’t allowed, as the comma is ambiguous with the surrounding option list.
Deduplicating with uniqueBy
uniqueBy keeps at most one item for each distinct key.
Pass a single expression, or a list to key on a combination:
// One person per PersonId
find { Person[](uniqueBy: PersonId) }
// One person per (CountryCode, NationalInsuranceNumber) pair
find { Person[](uniqueBy: [CountryCode, NationalInsuranceNumber]) }Because it keys on semantic value, uniqueBy isn’t the same as a generic .distinct() — it de-duplicates by the meaning of the requested type(s).
Cursor pagination
after and before request a page of results relative to an opaque cursor string.
They’re mutually exclusive, and can’t be combined with offset:
query FindPeople(cursor: String, pageSize: Int) {
find { Person[](after: cursor, limit: pageSize, orderBy: PersonName asc) }
}Taxi treats the cursor value as opaque. Whether cursors are honoured at runtime depends on the engine and the underlying source — support is currently limited, so check your engine’s capabilities before relying on them.
Parameterised values
Option values can be literals or any expression that’s resolvable before the collection is fetched — most commonly a query parameter:
query FindPeople(maxRows: Int) {
find { Person[](limit: maxRows) }
}An option may reference query arguments and values from an enclosing scope, but not values produced by the collection it configures.
For example Person[](limit: PersonAge) is invalid, because PersonAge belongs to the very Person values being fetched.
Logical order
However you write them, options are applied in a fixed logical order:
predicate → uniqueBy → orderBy → offset → limit
This matters for correctness. For example:
find { Person[](uniqueBy: EmailAddress, limit: 10) }returns up to 10 unique people — deduplication happens first, then the limit — rather than 10 people that are then deduplicated down to fewer.
Likewise orderBy always precedes limit, so orderBy: DateOfBirth desc, limit: 10 means “order everything, then take 10”, not “take 10 arbitrary rows, then sort them”.
Where options apply — source vs projection
Options attach to the specific collection they follow, and their position determines which phase they shape.
On a find, they shape the source fetch. On a projection’s array marker, they shape the projected result:
// Fetch up to 100 people, project them, then return up to 10 projected results
find { Person[](limit: 100) } as {
name: PersonName
}[](limit: 10)Position determines the phase
The two are not interchangeable. An option on the source Person[] limits what's fetched; an option on the projection's [] limits what's returned after projecting. Limiting the source to 100 and the projection to 10 fetches up to 100 rows and returns 10 — limiting the source to 10 would only ever fetch 10.
Options also work on nested collection fields inside a projection:
find { Person[](CountryCode == 'GB', limit: 10, orderBy: PersonName asc) } as {
name: PersonName
transactions: Transaction[](limit: 20, orderBy: TransactionDate desc)
}[]Compile-time rules
Options are checked when your query compiles. The compiler rejects:
- Options on a non-collection type (
Person(limit: 10)—limitneedsPerson[]). - Unknown option names, and any option specified more than once.
- Values of the wrong type (
limit/offsetmust beInt;after/beforemust beString). - Statically-negative
limitoroffset(dynamic values are validated at runtime instead). - Sort directions other than
asc/desc. afterandbeforetogether, or either cursor combined withoffset.- A predicate written after named options.
offset without orderBy
Using offset without an orderBy compiles with a warning: without a stable order, the result window may shift between calls, so successive pages aren't guaranteed to line up.
Runtime behaviour
Collection options define the meaning of a result; the runtime semantics are engine-dependent.
A query engine such as Orbital decides whether to push each option down to the data source, apply it within the engine, or reject combinations it can’t satisfy safely.
For instance, limit on a stream simply takes the first N items and completes, whereas a global orderBy or uniqueBy over an unbounded stream is generally rejected.
Given statements
Given statements make data available to your query without constraining which operations are called:
// Basic given statement
given { EmailAddress = 'jimmy@demo.com' }
find { Customer }
// With variable name
given { email : EmailAddress = 'jimmy@demo.com' }
find { Customer }
// Multiple values
given {
status : OrderStatus = 'PENDING'
customerId : CustomerId = '123'
}
find { Order[] }Given vs constraints
Understanding the difference is crucial:
// Given: makes data available but doesn't restrict operations
given { status : OrderStatus = 'PENDING' }
find { Order[] } // May return orders of any status
// Constraint: restricts which operations can be called
given { status : OrderStatus = 'PENDING' }
find { Order[](OrderStatus == status) } // Only returns pending ordersGiven doesn't constrain data
Providing data in given makes data available that can be used with API calls -- but it doesn't limit which operations can be called. To filter results or specify exact data requirements, use constraints in your find statement.
Basic projections
Projections transform and enrich data:
// Project to a different structure
find { Movie[] } as {
title : MovieTitle
director : DirectorName
rating : RottenTomatoesScore
}[]
// Project to a named type
find { Book[] } as BookAndAuthor[]
// Select specific fields
find { Order[] } as {
id // Field shorthand
status
total
}[]Named queries
Save and reuse queries:
// Simple named query
query PendingOrders {
find { Order[](Status == 'PENDING') }
}
// Parameterized query
query FindOrdersByStatus(status: OrderStatus) {
find { Order[](Status == status) }
}Including and excluding services
Control which services are called:
// Only use specific services
find { Film[] }
using {
FilmService::getFilms, // Specific operation
ReviewService // Entire service
}
// Exclude specific services
find { Film[] }
excluding {
ImdbApi, // Exclude entire service
RottenTomatoes::getReviews // Exclude specific operation
}Supported operators
| Symbol | Meaning |
|---|---|
== | Equal to |
!= | Not equal to |
> | Greater than |
>= | Greater than or equal to |
< | Less than |
<= | Less than or equal to |
Next steps
- Learn about projections for transforming data
- Explore expressions and type traversal for navigating data
- Understand functions for data manipulation