Skip to content
qibdo qibdo
Theme
Book a demo

Filtering

Most List endpoints accept a filter parameter: a single expression that narrows the results server-side. qibdo Cloud implements the Google AIP-160 filter language consistently across all services, so the same syntax works everywhere it is offered. A few listings narrow differently because the data behind them is not tabular, and each says so on its own service page. See Where filtering does not apply.

Pass the expression as the filter query parameter (URL-encoded over REST), alongside page_size, page_token, and order_by:

Terminal window
curl --get https://api.qibdo.example.com/taxonomy/v1/organisations/$ORG/groups \
--header "Authorization: Bearer $QIBDO_API_TOKEN" \
--data-urlencode "filter=name : 'eng' AND create_time >= '2026-01-01T00:00:00Z'"

An empty filter matches everything.

Each resource considers an enumerated set of fields, not everything in the response. AIP-160 allows a service to consider a subset, and requires it to document which. A field in the set is always accepted; a field outside it is always rejected. There are no hidden fields to discover by trial.

Two places list the set for a resource:

Field names are the resource’s wire names in snake_case, the same names you see in responses. Where a value is stored internally is an implementation detail and is not part of the contract.

A listing narrows differently when the data behind it is not a set of records with indexed attributes. A field predicate would be misleading there, or ruinously expensive, so it is not offered. Two shapes exist today, and each is documented on the service page that owns it:

  • Ordered by key. Object storage listings take prefix and delimiter, because a bucket is a flat keyspace rather than a table. See buckets and objects.
  • Owned by another query language. Some telemetry listings are enumerated by the store that holds them, and are narrowed with LogQL, PromQL or TraceQL. See querying logs, metrics and traces.

A listing may also accept a restricted dialect rather than the full language. Registry artifacts are filtered through the registry engine, which accepts three fields with = and AND only. The resource’s reference page and the discovery endpoint are always authoritative for what it accepts.

The filter string is a concrete grammar layered on top of AIP-160: AIP-160 defines the filter field as the API surface, and qibdo fixes the exact syntax that goes inside it. That syntax is a strict subset of the official AIP-160 EBNF. The grammar qibdo’s parser accepts is:

filter = expression | empty ;
expression = and_expression ;
and_expression = or_expression { AND or_expression } ; (* OR binds tighter than AND *)
or_expression = unary_expression { OR unary_expression } ;
unary_expression = ( NOT | "-" ) unary_expression
| primary ;
primary = "(" expression ")"
| correlated_call
| function_call
| presence
| comparison ;
presence = field ":" "*" ; (* unquoted star, only here *)
comparison = field comparator value
| field ; (* a boolean field alone means = true *)
function_call = field "." IDENTIFIER "(" value ")" ; (* exactly one argument *)
correlated_call = field "." "any" "(" "(" expression ")" ")" ;
field = IDENTIFIER { "." IDENTIFIER } ; (* max 3 segments *)
comparator = "=" | "!=" | ">" | ">=" | "<" | "<=" | ":" ;
value = STRING_LITERAL | NUMBER_LITERAL | BOOLEAN_LITERAL | IDENTIFIER ;
STRING_LITERAL = "'" { char | "''" } "'" (* single-quoted, '' escapes ' *)
| '"' { char } '"' ; (* double-quoted *)
NUMBER_LITERAL = [ "-" ] DIGIT { DIGIT } [ "." DIGIT { DIGIT } ] ;
BOOLEAN_LITERAL = "true" | "false" ;
IDENTIFIER = LETTER { LETTER | DIGIT | "_" } ;
AND = case-insensitive "AND" ;
OR = case-insensitive "OR" ;
NOT = case-insensitive "NOT" ;

Whitespace around operators is flexible. The sections below walk through each production.

OperatorMeaningExample
=equalsstatus = "QIBDO_VM_STATUS_RUNNING"
!=not equalshypervisor != "KVM"
> >=greater than (or equal)cpu.v_cpus_boot >= 8
< <=less than (or equal)cpu.v_cpus_boot < 32
:has (see below)cpu.features.name : "avx2"

Whether capitalisation matters depends on the operator, not on the field. Searching inside a value ignores it; selecting a value does not.

You writeCapitalisation
= != > >= < <=matters
: on a string, and quoted * patternsignored
contains, startsWith, endsWithignored

So name : "storage" and name : "Storage" return the same rows, while name = "Storage-Viewer" does not find a resource stored as storage-viewer.

Equality stays exact deliberately. Names are unique within their parent, and that uniqueness is case-sensitive — so a case-insensitive = could match two different resources at once, and a lookup by name would stop being a lookup.

Fields that hold one of a fixed set of values, such as a status or an engine, behave differently again: see Fixed sets of values below.

: is the has operator. It has three meanings, chosen by the shape of the field it is applied to. This is one operator, not three: the field decides.

Field shape: meansCapitalisationExample
Scalar stringsubstring matchignoredos.cmdline : "console"
Repeated (a list)element containmentmatterscpu.features.name : "avx2"
Nested structurecontainmentmattersos.boot_devices : "NETWORK"

Containment is an exact match against each element, not a search inside one, which is why it keeps capitalisation: the elements are values, not text to scan.

The rule that follows from this, and the one worth remembering:

A repeated field is matched with :. A scalar field is matched with =.

Using = on a repeated field does not match “the list equals this value”, and using : on a number or boolean is an error. When a field is repeated, the discovery endpoint says so explicitly through repeated_field.

Containment preserves the value’s type, so a number matches a stored number rather than its text form. Negation works as it does everywhere else:

cpu.features.name : "avx2"
-cpu.features.name : "avx512f"
cpu.features.name : "avx2" AND cpu.v_cpus_boot >= 8

Some fields hold one of a fixed, published set of values — a status, an engine, a resource kind. You select one of those values; you do not search inside them. Two things follow.

The value resolves whatever case you write it in. status = "qibdo_vm_status_running" and status = "QIBDO_VM_STATUS_RUNNING" are the same request. A value that is not in the set is rejected outright, listing the values that are — it does not quietly return an empty page:

INVALID_ARGUMENT
The filter value 'RUNNIG' could not be parsed for property 'status'

A substring operator is refused, not attempted. status : "RUNNING" is an error naming the operator to use instead and listing the legal values, because a partial match against a closed set has no meaning the API can honour:

INVALID_ARGUMENT
The field 'status' holds one of a fixed set of values;
select one with '=' rather than searching it with ':'

The discovery endpoint tells you which fields these are before you write a filter: such a field lists its valid_values, and its operators do not include :. See Discovering the surface.

A condition on a repeated field asks whether some element matches. Two such conditions therefore ask two independent questions, and the elements that satisfy them need not be the same one.

That distinction decides real answers. A storage class carries a list of per-region availability entries, so:

# "some entry is in this region" AND, separately, "some entry is available"
availability.region_id = "us-east-1" AND availability.status = "…AVAILABLE"

matches a storage class whose entry for us-east-1 is unsupported while a different entry, in another region, is available. Offered as a choice, it would fail on use.

To require that a single element satisfies several conditions at once, name the field and call any:

# one entry that is both in this region and available
availability.any((region_id = "us-east-1" AND status = "…AVAILABLE"))

The inner parentheses are part of the grammar: the argument is a parenthesised expression, and any(x = 1) is not accepted. Inside the call, field names are written without the leading path — region_id, not availability.region_id — because the call has already named it; the fully qualified form is accepted too. Naming a field the list does not carry is refused, and the error lists the ones it does.

What comes back:

  • The whole resource, unpruned. The filter chooses which resources match; it never edits them. A returned storage class carries its complete availability list, exactly as an unfiltered request would return it.
  • Each resource at most once, and the reported total counts resources, not elements.
  • Negation applies to the whole question. -availability.any((…)) means no element satisfies it, never “some element fails it”. A resource whose list is empty therefore matches the negated form and no positive one.

Not every repeated field accepts any. The ones that do are listed by the discovery endpoint as correlatable fields.

To ask whether an optional field is set at all, use the unquoted star. It is valid only on the right of :, and nowhere else in the grammar:

numa.mode:*
-numa.mode:*
FormMeaning
numa.mode:*the field is set
-numa.mode:*the field is not set

A quoted "*" is something different: it is a substring wildcard, and it never means presence. The two look similar and do not overlap, so it is worth being deliberate about which you want:

numa.mode:*
os.cmdline : "*console*"

The first asks whether numa.mode is set. The second matches rows whose os.cmdline contains console, and does not match rows where the field is unset.

Combine terms with AND, OR, and NOT (all case-insensitive, so AND, and, and aNd are equivalent). A leading - is an alternative to NOT. Group with parentheses.

OperatorMeaningExample
ANDboth conditions must matcha = "1" AND b = "2"
OReither condition must matcha = "1" OR b = "2"
NOTnegates the following conditionNOT status = "QIBDO_VM_STATUS_DELETED"
-negation prefix (alternative to NOT)-status = "QIBDO_VM_STATUS_DELETED"
(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND hypervisor = "KVM"
NOT name.contains("test")
-status = "QIBDO_VM_STATUS_DELETED"

Precedence runs NOT/- (highest), then OR, then AND (lowest), and binary operators are left-associative. Without parentheses, a = "1" AND b = "2" OR c = "3" is read as a AND (b OR c).

This is the order AIP-160’s grammar defines, and it is the reverse of most programming languages, where AND binds tighter. The practical consequence is that an unparenthesised mix of the two almost never means what it looks like:

# Running or starting machines, on KVM — reads naturally, and is wrong
status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING" AND hypervisor = "KVM"
# What it actually means
status = "QIBDO_VM_STATUS_RUNNING" OR (status = "QIBDO_VM_STATUS_STARTING" AND hypervisor = "KVM")
# What was meant — say it with parentheses
(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND hypervisor = "KVM"

Both readings are valid syntax, so nothing can warn you. Parenthesise every expression that mixes AND and OR, and the question of which binds tighter stops mattering.

  • Strings are single- or double-quoted; escape a single quote by doubling it: name = 'O''Brien'.
  • Numbers are unquoted, and may be negative or decimal: cpu.v_cpus_boot > 8, ratio = 3.14.
  • Booleans are true / false.
  • Enums are written as the token the API presents, for example status = "QIBDO_VM_STATUS_RUNNING". Matching is exact and case-sensitive. The accepted vocabulary for a field is available from the discovery endpoint, and a rejected value returns an error listing what was valid.
  • Bare identifiers may be written unquoted: status = QIBDO_VM_STATUS_RUNNING. A bare identifier is resolved against the field’s type: boolean fields read true/false as booleans, numeric fields read digits as numbers, and string fields treat the identifier as text.
  • Timestamps are quoted RFC-3339 strings: create_time >= "2026-01-01T00:00:00Z".

A * inside a quoted string value is a wildcard (it matches any sequence of characters), usable with = and != on string fields. For the unquoted star, see Presence and absence.

PatternMatches
"alpha*"values starting with alpha
"*beta"values ending with beta
"*mid*"values containing mid
"exact"exact match (no wildcard)
name = "prod-*"
name = "*-v2"

Wildcards are valid on string fields only; using one on a numeric or boolean field returns an error. A wildcard pattern is a search, so it ignores capitalisation — including when it rides on =, which is otherwise exact. On a field holding a fixed set of values a wildcard is refused, for the same reason : is.

Three string functions are available (string fields only, exactly one argument, camelCase names):

FunctionMatchesExample
contains()values containing the argumentname.contains("prod")
startsWith()values beginning with itname.startsWith("api-")
endsWith()values ending with itname.endsWith("-v2")
name.contains("prod")
name.startsWith("api-") AND status = "QIBDO_VM_STATUS_RUNNING"
NOT name.contains("test")

All three are searches, so all three ignore capitalisation, and all three are refused on a field holding a fixed set of values.

A physical measure is a single number named for its base unit, so you filter one value and never a magnitude plus a separate unit:

FieldBase unit
memory.size_bytesbytes
clock_speed_hzhertz
bandwidth_bpsbits/sec
memory.size_bytes >= 8589934592

That expression means “at least 8 GiB”. Because the stored value is always in the base unit, the result is the same regardless of the unit a caller originally used, and there is no unit predicate to pair with the comparison. A 64-bit measure is carried as a JSON string in responses, which is the standard encoding for a 64-bit integer; it is still a number and still compares numerically.

Money is the exception. An amount keeps its currency, because a currency is a dimension and not a scale factor, and amounts in different currencies cannot be meaningfully ordered against each other. So ordering by a money field is rejected unless the filter constrains the results to a single currency. Pair the two, as a storage bucket’s month-to-date cost does:

cost.month_to_date.currency_code = "EUR" AND cost.month_to_date >= 100

With that filter in place, order_by=cost.month_to_date is accepted. Without it, the request is rejected. A field that behaves this way is flagged by sort_depends_on_filter in the discovery response, and Sorting a field that depends on a filter covers which expressions actually constrain it — several plausible ones do not.

Filter on nested fields with dot notation, up to three segments deep:

cpu.v_cpus_boot >= 8
cpu.topology.sockets = 2
memory.size_bytes >= 8589934592 AND numa.mode = "STRICT"

Which nested fields a resource considers is part of its curated set, exactly like its top-level fields. Filtering on a field outside the set returns an INVALID_ARGUMENT error that lists the fields you can use.

Nested values compare by their real type, not as text. A numeric nested field compares numerically, so cpu.v_cpus_boot >= 8 matches a machine with 16 vCPUs. Under text ordering it would not, because "16" sorts before "8".

JSON literal types are preserved end to end, so an integer-valued key matches an integer and a string-valued key matches a string. A nested field whose type cannot be compared is rejected with INVALID_ARGUMENT rather than failing at runtime.

Most nested fields can be filtered but not sorted. A path that resolves into a stored JSON structure is filter-only by construction: the engine can test containment cheaply, but it has no ordered index to sort by, and asking it to returns INVALID_ARGUMENT. A virtual machine shows the usual shape, advertising far more filterable fields than sortable ones, with every sortable one top-level.

It is not a universal rule. A dotted path that resolves to a real stored column can be sorted, as cost.month_to_date on a storage bucket is. So do not infer sortability from the shape of the name: each resource publishes its sortable set beside its filterable set in the reference, and sortable in the discovery response gives the same answer at runtime. See Sorting.

Every service exposes its query surface at runtime, so a client can build a query interface without hardcoding field names. The endpoint needs no authentication: it returns resource shape metadata, never data.

Terminal window
# Every resource in a service
curl --get https://api.qibdo.example.com/compute/v1/filterSchemas
# One resource
curl --get https://api.qibdo.example.com/compute/v1/filterSchemas/virtualMachine

Each field in the response carries:

PropertyWhat it tells you
namethe field path to use in a filter
value_typehow values are compared
repeated_fieldwhether it is a list, so whether it takes : rather than =
sortablewhether it may appear in order_by
operatorsthe operators this field accepts
valid_valuesthe accepted vocabulary for an enum-backed field, empty when any value of the type is allowed
sort_depends_on_filterthat ordering requires a filter first, as money does

The response also carries correlatable_fields, one entry per repeated field that accepts any:

PropertyWhat it tells you
namethe field to call any on
member_namesthe fields you may write inside the call, without the leading path

So a storage class advertising availability with members region_id and status tells you that availability.any((region_id = … AND status = …)) is accepted. Those members also appear in fields under their full names, because they remain individually filterable — a correlatable field adds the correlated question, it does not remove the ordinary ones.

The discovery response and the enforcement engine are derived from the same source, so a field advertised here is always accepted and a field absent here is always rejected.

# Exact match on an enum
status = "QIBDO_VM_STATUS_RUNNING"
# Comparison with a timestamp
create_time >= "2026-06-01T00:00:00Z"
# Substring on a scalar string
name : "web"
# Containment on a repeated field
cpu.features.name : "avx2"
# Presence, then absence
numa.mode:*
-numa.mode:*
# Numeric comparison on a nested field
cpu.v_cpus_boot >= 8
# A measure, in its base unit
memory.size_bytes >= 8589934592
# Combine conditions
status = "QIBDO_VM_STATUS_RUNNING" AND hypervisor = "KVM"
# Group with OR, then AND
(status = "QIBDO_VM_STATUS_RUNNING" OR status = "QIBDO_VM_STATUS_STARTING") AND -name.contains("temp")
# Prefix wildcard
name = "api-*"

To keep queries cheap and safe, filters are bounded:

LimitValue
Filter string length2048 characters
Field traversal depth3 segments
Expression nesting (parentheses and negations)4 levels
Expression count (comparisons and functions)100

Two notes on the depths:

  • Field traversal depth counts dot-separated segments in a single field path: a.b.c is the deepest accepted, a.b.c.d is rejected.
  • Expression nesting counts parentheses and negations together. Each ( and each NOT or - adds one level, so both ((((( ... ))))) and a chain of five NOTs exceed the limit.

Exceeding a limit, using an unsupported operator or function, or supplying a malformed value returns an INVALID_ARGUMENT error describing the problem (see Standard responses).

Every filter error maps to INVALID_ARGUMENT, and the message names what was wrong. A rejected field lists the fields you could have used, and distinguishes a field that does not exist from one that exists but is not filterable. A rejected enum value lists the accepted vocabulary.

You wroteWhy it fails
name = "unterminatedinvalid syntax (unbalanced quote, (), etc.)
nonexistent_field = "x"unknown field for this resource
create_time = "not-a-timestamp"value does not match the field’s type
status = "RUNNING"value outside the field’s vocabulary
name.fooBar("x")unsupported function
name.contains("a", "b")a function takes exactly one argument
cpu.v_cpus_boot : "8": on a non-string, non-repeated field
cpu.v_cpus_boot = "*"wildcard on a non-string field
name = *unquoted star anywhere except after :
a.b.c.d = "x"traversal depth exceeded (max 3)
(((((a = "1")))))nesting depth exceeded (max 4)

A few AIP-160 features are intentionally left out. Use the listed alternative:

FeatureUse instead
IN operator (field IN ("a", "b"))field = "a" OR field = "b"
Struct / composite literals ({...})dot-notation field traversal
Cross-resource traversal (author.email)filter the field on its own resource
Duration / timestamp literal typesquote them as strings ("2026-01-01T00:00:00Z")
Regex or glob operatorsthe * wildcard or contains/startsWith/endsWith
any inside anyone level only; no list today carries a nested list

Filtering selects which rows return; order_by controls their order. Pass a comma-separated list of fields, each optionally suffixed with a direction ( asc or desc, case-insensitive). The direction defaults to asc, and results fall back to a stable order when you omit order_by:

order_by=create_time desc, name

The filterable and sortable sets are published separately per resource, because they are genuinely different sets. A field may be one, the other, or both:

  • Most nested fields are filterable but not sortable (see Sorting a nested field).
  • Some fields are sortable but not filterable. A registry artifact can be ordered by push_time, pull_time and size_bytes, none of which it accepts in a filter.
  • A few require a filter before they can be ordered, flagged by sort_depends_on_filter. Money is the standard case.

Read the resource’s own two lists rather than generalising from a field’s name. They are in the reference beside each List operation, and in sortable on the discovery response.

A field flagged sort_depends_on_filter is conditionally sortable: it carries a unit that varies from row to row, so sorting it depends on the filter first restricting that unit to one value. Money is the standard case — a currency is a dimension rather than a scale factor, so 100 JPY and 90 USD have no order between them, and nothing is converted on your behalf.

To sort such a field, satisfy the dependency by fixing its unit:

cost.month_to_date.currency_code = "EUR" AND cost.month_to_date >= 100
order_by=cost.month_to_date desc

Without that filter the request is rejected, and the error names the field it depends on:

Cannot sort by 'cost.month_to_date': it is conditionally sortable and depends on
'cost.month_to_date.currency_code'. Its values carry a unit that varies between rows, so amounts
in different units cannot be ordered against each other, and are never converted. Add
cost.month_to_date.currency_code = "<value>" to the filter, joined with AND, so the request covers
one unit. Only a plain equality satisfies this: a wildcard, '!=', or an equality inside an OR
still allows several.

Only a plain equality reached through AND narrows the request to one unit. This is the part that most often surprises people, because several expressions look like they should satisfy the dependency and do not:

ExpressionSatisfies it?Why
currency_code = "EUR"yesexactly one value
currency_code = "EUR" AND size_bytes > 0yesthe equality is on the AND spine
currency_code != "EUR"norules out one value, leaves the rest
currency_code = "E*"noa wildcard; E* spans EUR and EGP
currency_code:*noasks whether a currency is present, not which
currency_code = "EUR" OR currency_code = "USD"noeither remains possible

The dependency is satisfied by the whole expression, not by a clause in isolation: an equality sitting inside an OR branch does not satisfy it, because the other branch is still reachable.

The dependency is discoverable rather than something to memorise: sort_depends_on_filter on the discovery response names the exact field whose value must be fixed.