On this page:
4.1 The query builder
4.2 Filtering records
4.2.1 Comparison operators
4.2.2 String functions
4.2.3 Logical operators
4.2.4 Building complex filters
4.3 Selecting fields
4.4 Expanding related records
4.5 Sorting results
4.6 Limiting and paginating results
4.7 Composing queries
4.8 Seeing the generated query string
4.9 Common patterns
4.9.1 Find records by partial name
4.9.2 Find records modified recently
4.9.3 Filter by date range
4.9.4 Find records with specific status and amount range
4.9.5 Get lightweight list for a dropdown
4.10 Next steps
9.3

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—all in a single request.

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—use only what you need:

(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

eq

equals

ne

ne

not equals

lt

lt

less than

gt

gt

greater than

le

le

less than or equal

ge

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

contains

field contains substring

startswith

field starts with prefix

endswith

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.