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.

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Server-side filtering

Adding contraints to the data being fetched controls the APIs that are called, only calling operations that satisfy the constraints

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Learn more about server-side filtering and constraints


Client side filtering

Fetches data by calling an operation, then filters the result

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Learn more about client-side filtering and expressions

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::PostCode

The :: 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::AddressLine1

Ambiguous 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

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

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:

  1. Request all instances using array syntax
  2. Use structural navigation with property names
  3. Use structural navigation with type access syntax

For example:

Multiple values return null

In this query, PersonName is not unique, so returns null

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

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

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

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

Accessing properties by name

Access properties by name using standard 'dot' syntax

Schema
Query Plan
Play with this snippet by editing it here, or edit it on Taxi Playground
Result
Query failed

Mixing approaches

Combine traversal with property access:

// Type traversal then property
Customer::Address.streetName

// Property access then traversal
customer.addresses::PostCode

Constraints 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

OptionTypeMeaningNotes
limitIntReturn at most N items“At most” — fewer may be returned if fewer are available. Must be non-negative.
offsetIntSkip the first N itemsMust be non-negative. Warns if used without orderBy.
afterStringReturn the page after an opaque cursorMutually exclusive with before; can’t be combined with offset.
beforeStringReturn the page before an opaque cursorMutually exclusive with after; can’t be combined with offset.
orderByorder term(s)Order the collection by one or more expressionsType asc\|desc; direction defaults to asc. Single term or list.
uniqueByexpression(s)Keep at most one item per unique keySingle 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)limit needs Person[]).
  • Unknown option names, and any option specified more than once.
  • Values of the wrong type (limit/offset must be Int; after/before must be String).
  • Statically-negative limit or offset (dynamic values are validated at runtime instead).
  • Sort directions other than asc / desc.
  • after and before together, or either cursor combined with offset.
  • 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 orders

Given 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

SymbolMeaning
==Equal to
!=Not equal to
>Greater than
>=Greater than or equal to
<Less than
<=Less than or equal to

Next steps

Previous
Annotations
Next
Transforming data (projections)