4 Querying with OData
Business Central’s REST API uses OData, a standard protocol
for building and consuming RESTful APIs. OData provides a query language that lets you filter
records, select specific fields, and include related data—
OData handles all of this through URL query parameters. A raw OData query might look like:
/customers?$filter=state eq 'CA' and blocked eq false&$select=id,displayName,email&$top=10 |
While functional, this is error-prone to write by hand. bcnav provides a Racket-native way to build these queries that’s more readable than raw query strings and catches errors before you send the request.
(make-query #:filter (and: (eq 'state "CA") (eq 'blocked #f)) #:select '(id displayName email) #:top 10)
4.1 The query builder
Use make-query to construct a query. All arguments are optional—
(make-query #:filter filter-expr ; which records to return #:select field-list ; which fields to include #:expand field-list ; which related records to include #:orderby field-or-pair ; how to sort results #:top n ; maximum records to return #:skip n) ; records to skip (for pagination)
Pass the query to an entity function using the #:query keyword:
(customers #:query (make-query #:top 5))
4.2 Filtering records
The #:filter argument takes a filter expression built from bcnav’s filter functions.
4.2.1 Comparison operators
The basic comparison operators test a field against a value. They use OData’s operator names:
Function | OData operator | Meaning |
eq | equals | |
ne | not equals | |
lt | less than | |
gt | greater than | |
le | less than or equal | |
ge | greater than or equal |
Each takes a field name (as a symbol) and a value:
> (filter->string (eq 'status "Open")) "status eq 'Open'"
> (filter->string (gt 'amount 1000)) "amount gt 1000"
> (filter->string (ne 'blocked #t)) "blocked ne true"
Field names are symbols (like 'displayName) that correspond to the entity’s properties. You can discover available fields using metadata discovery (covered later).
4.2.2 String functions
For text fields, you can search within values:
Function | Meaning |
field contains substring | |
field starts with prefix | |
field ends with suffix |
> (filter->string (contains 'displayName "Contoso")) "contains(displayName,'Contoso')"
> (filter->string (startswith 'number "C00")) "startswith(number,'C00')"
> (filter->string (endswith 'email ".com")) "endswith(email,'.com')"
4.2.3 Logical operators
Combine multiple conditions with and:, or:, and not:. These use a : suffix to avoid shadowing Racket’s built-in forms:
> (filter->string (and: (eq 'status "Open") (gt 'amount 500))) "(status eq 'Open' and amount gt 500)"
> (filter->string (or: (eq 'type "Company") (eq 'type "Person"))) "(type eq 'Company' or type eq 'Person')"
> (filter->string (not: (eq 'blocked #t))) "not (blocked eq true)"
and: and or: accept multiple arguments:
> (filter->string (and: (eq 'status "Open") (gt 'amount 100) (lt 'amount 10000))) "((status eq 'Open' and amount gt 100) and amount lt 10000)"
4.2.4 Building complex filters
Since filter expressions are just Racket values, you can build them up programmatically:
;; Build a filter dynamically based on user input (define (make-customer-filter #:status [status #f] #:min-amount [min-amt #f] #:name-contains [name #f]) (define conditions (filter values (list (and status (eq 'status status)) (and min-amt (gt 'amount min-amt)) (and name (contains 'displayName name))))) (if (null? conditions) #f (apply and: conditions))) ;; Use it: (make-customer-filter #:status "Open" #:min-amount 1000)
4.3 Selecting fields
By default, BC returns all fields for each record. Use #:select to request only the fields you need:
(customers #:query (make-query #:select '(id displayName email phoneNumber)))
This reduces response size and can improve performance, especially when records have many fields.
Some fields are always included regardless of #:select, particularly the record’s ID and ETag.
4.4 Expanding related records
Many BC entities have relationships to other entities. For example, a sales order has lines, a customer has a default currency, etc. Normally, you’d need separate API calls to fetch related data. With #:expand, you get everything in one request:
;; Get sales orders with their line items (sales-orders #:query (make-query #:expand '(salesOrderLines))) ;; Get customers with their currency and payment terms (customers #:query (make-query #:expand '(currency paymentTerm)))
The expanded records appear as nested data in the response. For example, with #:expand '(salesOrderLines), each order hash will have a 'salesOrderLines key containing a list of line item hashes.
To discover what can be expanded, use inspect (covered in Metadata Discovery).
4.5 Sorting results
Use #:orderby to control the sort order. You can specify just a field name (ascending) or a pair of field and direction:
;; Sort by displayName ascending (default) (customers #:query (make-query #:orderby 'displayName)) ;; Sort by amount descending (sales-orders #:query (make-query #:orderby '(amount . desc))) ;; Sort ascending explicitly (items #:query (make-query #:orderby '(number . asc)))
BC’s OData implementation doesn’t support sorting by multiple fields in a single request.
4.6 Limiting and paginating results
Use #:top to limit how many records are returned:
;; Get just the first 10 customers (customers #:query (make-query #:top 10))
Use #:skip with #:top for manual pagination:
;; Page 1: records 1-10 (customers #:query (make-query #:top 10 #:skip 0)) ;; Page 2: records 11-20 (customers #:query (make-query #:top 10 #:skip 10))
However, bcnav handles pagination automatically by default. When you call (customers), bcnav follows BC’s @odata.nextLink to fetch all pages. You can disable this if needed:
;; Disable auto-pagination globally (current-bc-auto-paginate #f) ;; Or for just one call using parameterize (parameterize ([current-bc-auto-paginate #f]) (customers)) ;; Returns only the first page
4.7 Composing queries
The query-with-* functions let you modify an existing query, creating a new one without changing the original:
> (define base-query (make-query #:select '(id displayName))) > base-query (query #f '(id displayName) #f #f #f #f)
> (query-with-filter base-query (eq 'status "Open")) (query status eq 'Open' '(id displayName) #f #f #f #f)
; Original is unchanged: > base-query (query #f '(id displayName) #f #f #f #f)
This is useful for building up queries incrementally or creating variations:
(define customer-base (make-query #:select '(id displayName email))) (define active-customers (query-with-filter customer-base (eq 'blocked #f))) (define ca-customers (query-with-filter active-customers (eq 'state "CA")))
4.8 Seeing the generated query string
For debugging, you can see what OData parameters bcnav will send:
> (query->params (make-query #:filter (and: (eq 'status "Open") (gt 'amount 1000)) #:select '(id displayName amount) #:top 50))
'(($filter . "(status eq 'Open' and amount gt 1000)")
($select . "id,displayName,amount")
($top . "50"))
This returns an association list that bcnav converts to URL query parameters.
4.9 Common patterns
Here are some queries you might find useful:
4.9.1 Find records by partial name
(customers #:query (make-query #:filter (contains 'displayName "contoso")))
4.9.2 Find records modified recently
(customers #:query (make-query #:filter (ge 'lastModifiedDateTime (odata-datetime "2024-01-01T00:00:00Z")) #:orderby '(lastModifiedDateTime . desc)))
4.9.3 Filter by date range
For date fields like postingDate or orderDate, use date-between for concise range queries:
;; All invoices from July 2025 (sales-invoices #:query (make-query #:filter (date-between 'postingDate "2025-07-01" "2025-07-31")))
The odata-date and odata-datetime wrappers prevent a common error where dates are sent as quoted strings, causing BC to reject the query with a type mismatch.
You can also use odata-date directly with comparison operators:
;; Orders from 2025 onwards (sales-orders #:query (make-query #:filter (ge 'orderDate (odata-date "2025-01-01"))))
4.9.4 Find records with specific status and amount range
(sales-orders #:query (make-query #:filter (and: (eq 'status "Open") (ge 'totalAmountIncludingTax 1000) (le 'totalAmountIncludingTax 50000)) #:orderby '(orderDate . desc)))
4.9.5 Get lightweight list for a dropdown
(customers #:query (make-query #:select '(id displayName) #:filter (eq 'blocked #f) #:orderby 'displayName))
4.10 Next steps
Now that you understand querying, continue to Working with Entities to learn about creating, updating, and deleting records.