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 = or_expression ;
or_expression = and_expression { OR and_expression } ;
and_expression = unary_expression { AND unary_expression } ;
unary_expression = ( NOT | "-" ) unary_expression
| primary ;
primary = "(" expression ")"
| function_call
| presence
| comparison ;
presence = field ":" "*" ; (* unquoted star, only here *)
comparison = field comparator value ;
function_call = field "." IDENTIFIER "(" value ")" ; (* exactly one argument *)
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"

Matching is exact and case-sensitive, against the value as the API presents it. A wrong-case value returns no rows rather than an error, so "kvm" will not match "KVM".

: 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: meansExample
Scalar stringsubstring matchos.cmdline : "console"
Repeated (a list)element containmentcpu.features.name : "avx2"
Nested structurecontainmentos.boot_devices : "NETWORK"

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

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 AND, then OR (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. Use parentheses to override this.

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

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")

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.

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

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.