How-to: query
Read and verify recipes, taken from the CRMWorx build (§4). See the CLI reference for every flag.
Run an OData query with a filter and projection
crm --json query odata cwx_tickets \
--filter "cwx_priority eq 3" --select cwx_name,cwx_severity --top 10
data (data[0] is the first row);
queries the entity-set (plural) name. The CLI unwraps the raw OData envelope (ADR
0008): paging moves to meta.next_link (← @odata.nextLink) and meta.count
(← @odata.count), and per-row @odata.* protocol keys (@odata.etag, …) are
stripped. Formatted-value annotations (*@OData.Community.…) are kept by default
(--annotations; opt out with --no-annotations).
The positional argument is the URL path and accepts three forms: a bare entity-set name
(e.g. contacts), a bound-function path (e.g. RetrieveAppComponents(...)), or a
metadata path (e.g. EntityDefinitions(LogicalName='account')/Keys). OData query options
go through --select/--filter/etc., never inline — a ? or $ in the argument is
rejected client-side with a validation error before the request.
The bare in operator (workflowid in ('a','b')) is OData 4.01 and the
Dataverse Web API (OData 4.0) rejects it — query odata detects it and errors
before the request. Use the native In query function, or query fetchxml:
crm --json query odata workflows \
--filter "Microsoft.Dynamics.CRM.In(PropertyName='workflowid',PropertyValues=['<id1>','<id2>'])"
Shrink a fat payload with --fields (client-side projection)
--fields is a global flag (valid on every command, like --json) that
projects the response down to a few top-level keys, so a fat list verb fits an
agent's context budget. It is post-response and distinct from --select, which
narrows the request server-side:
# 260 KB → ~90 KB: keep only two columns of every solution row
crm --json --fields uniquename,version solution list
Semantics: a list payload projects each row to the named keys (in flag order,
missing keys omitted per row); a single-record payload projects that record; a
non-object payload (e.g. a form export formxml string) passes through unchanged
with a meta.warnings advisory. A field name that matched no row adds a
meta.warnings typo tripwire. The ok/error/meta envelope — including
meta.next_link/meta.count — is never touched, so you can still page after
projecting. In human mode --fields selects and orders the table columns. Error
envelopes are never shaped; --dry-run previews are (ADR 0023).
--select (server-side, request) and --fields (client-side, response) compose:
--select cuts what the server sends, --fields cuts what you read.
Reshape or summarize a payload with --jq (client-side jq)
When projecting to a few columns isn't enough — you want a count, a filtered
subset, or a group_by summary — --jq PROGRAM runs a jq
program over the same curated data payload and puts the result back in the
envelope. Like --fields it is a global flag applied client-side at the emit
seam; it implies --json and is mutually exclusive with --fields
(using both is a usage error, exit 2).
# 932-row solution list → a single number
crm --jq 'length' solution list
# census summary: how many managed vs unmanaged solutions
crm --jq 'group_by(.ismanaged) | map({managed: .[0].ismanaged, n: length})' solution list
The expression's input is the curated data value only (never ok/error/meta),
and its result — scalar, string, array, or object — replaces data; the rest of
the envelope is untouched. A jq program is a stream of outputs: one output becomes
that value (so length → a bare number), no output (e.g. empty) becomes null,
and many outputs (e.g. .[]) become a list. The program is compiled before any
network call, so an invalid program fails fast (exit 2) without hitting the org;
a program that compiles but fails at run time (e.g. indexing a number) returns an
error envelope. Error envelopes bypass shaping; --dry-run previews are shaped
(ADR 0023).
Aggregate / group by with $apply (--apply)
# Count records per status
crm --json query odata accounts \
--apply "groupby((statuscode),aggregate(\$count as count))"
# Sum a measure across a grouping
crm --json query odata opportunities \
--apply "groupby((ownerid),aggregate(estimatedvalue with sum as total))"
--apply passes an OData $apply expression through to the Web API for
server-side aggregation, grouping, and distinct. Because the positional path
validator rejects an inline $ (see above), --apply is the only way to run
a $apply query. The result data shape is whatever the aggregation returns
(the grouped/aggregated rows), wrapped in the standard envelope — not the source
entity's columns.
Follow pagination automatically (--all / --max-records)
By default query odata returns a single server page and exposes the cursor in
meta.next_link when more rows exist. Two opt-in flags follow the cursor
automatically:
--all — follows every @odata.nextLink to exhaustion and merges all
pages into one data array. meta.next_link is absent in the result (paging
was followed completely).
crm --json query odata contacts --filter "statecode eq 0" --select fullname --all
--max-records N — follows pages only until N total rows are accumulated,
then stops. The data array contains at most N rows. When the cap was
actually hit (more rows existed beyond what was returned), the envelope carries
meta.truncated: true. meta.next_link is absent — a resume cursor is not
emitted because the final page may have been sliced to reach the exact cap.
--max-records implies page-following on its own; combining it with --all
adds the cap as a bound on the otherwise-unbounded follow.
crm --json query odata contacts --filter "statecode eq 0" --select fullname \
--max-records 200
# meta.truncated: true when more than 200 rows matched
Default behaviour (neither flag) still returns a single server page — the row
data is unchanged from pre-flag behaviour — with meta.next_link present when
more pages exist. Use --page-size to control how many rows the server puts on
each page. That default page is now self-describing in meta (see below).
Self-describing single page. When a read comes back with a meta.next_link
cursor, the envelope also sets meta.has_more: true and appends a
meta.warnings advisory ("Returned one server page; more rows exist — follow
meta.next_link (in the --json envelope) to page through them, or use
--all/--max-records on query odata."). If --count was requested and the
returned meta.count lands exactly on the server's standard-table ceiling of
5000 while a cursor is present, a second warning flags that count as a clamped
lower bound, not an exact total — re-run with --all for an exact count of
this query (query count gives a whole-table total only, ignoring --filter).
A query that fits in a single page (no cursor) gets neither has_more nor a
warning, and an honest small --count is unaffected.
These signals fire whenever the response carries an @odata.nextLink cursor —
so on query odata, query saved, and query user. query fetchxml pages via
a FetchXML paging cookie rather than a cursor, so it never surfaces them. The
--all/--max-records and --count flags are query odata-only; query
saved/query user page by following meta.next_link (or widen --page-size).
Strip annotations for token-efficient JSON (--minimal)
crm --json query odata cwx_tickets \
--filter "cwx_priority eq 3" --select cwx_name,cwx_severity --annotations --minimal
@odata.* protocol keys (etag/context/…) but keeps formatted-value annotations (*@OData.Community.Display.V1.FormattedValue, *@…lookuplogicalname). In --json mode --minimal goes further: it drops every @-containing key (including those formatted values) from each record, keeping business fields, _*_value lookup GUIDs, and the primary id. Paging stays in meta (meta.count/meta.next_link) regardless. It is a no-op in human/table mode and also works on query fetchxml, query saved, query user, and entity get.
Run a FetchXML query
# ENTITY_SET can be omitted — derived from <entity name="..."> via one metadata GET
crm --json query fetchxml --xml '
<fetch top="20">
<entity name="cwx_ticket">
<attribute name="cwx_name"/>
<attribute name="cwx_priority"/>
<order attribute="cwx_name"/>
</entity>
</fetch>'
# Pass ENTITY_SET explicitly to skip the resolution GET
crm --json query fetchxml cwx_tickets --xml '<fetch>...</fetch>'
FetchXML is the server-side XML query language; the <entity name> attribute is the
logical name, while the entity-set name (the URL path) is the OData plural name.
When ENTITY_SET is omitted, the logical name is parsed from <entity name="..."> and
resolved to the entity-set name via EntityDefinitions — one extra metadata GET.
If the XML has no <entity name="...">, pass ENTITY_SET explicitly.
Track changes and resume from a delta token (--track-changes / --delta-token)
Dataverse change tracking lets you retrieve only the rows that were created,
updated, or deleted since a prior snapshot — useful for sync and incremental
export. Change tracking must be enabled on the table (it is on by default for
many system tables including account and contact; list the enabled tables
with crm --json query odata EntityDefinitions --select LogicalName --filter
"ChangeTrackingEnabled eq true").
Initiate a tracked query — add --track-changes to any query odata call.
The response envelope carries two extra keys in meta:
meta.delta_link— the opaque resume URL (@odata.deltaLink) for the next round.meta.delta_token— the bare$deltatokenvalue lifted out of that URL (pass this to--delta-tokenon the next call instead of parsing the link yourself).
crm --json query odata contacts --track-changes --select fullname,statecode
# meta.delta_link: "https://.../contacts?$deltatoken=<tok>"
# meta.delta_token: "<tok>"
Resume from a prior token — pass the saved meta.delta_token as
--delta-token. Only rows created, updated, or deleted since the prior round
are returned. Each round surfaces a fresh meta.delta_link / meta.delta_token
to chain from:
PREV_TOKEN=$(crm --json query odata contacts --track-changes --select fullname | jq -r .meta.delta_token)
crm --json query odata contacts --delta-token "$PREV_TOKEN" --select fullname
Deletions arrive as rows shaped {"id": "<guid>", "reason": "deleted"} —
the per-row $deletedEntity context is stripped along with other @odata.*
keys by the normal envelope normalisation.
Conflicting options. The Dataverse Web API forbids system query options
alongside change tracking. --track-changes and --delta-token both reject
combination with --filter, --order-by, --expand, --top, --all, and
--max-records; the command errors client-side before any request.
--select, --count, and --page-size are compatible.
--track-changes and --delta-token are mutually exclusive with each other.
Call a bound function or metadata path on the URL path
query odata accepts three forms for its positional argument — all pass through to the
Web API as the URL path; OData query options always go through the flags:
| Form | Example |
|---|---|
| bare entity set | contacts, solutions |
| bound-function path | RetrieveAppComponents(AppModuleId=<guid>) |
| metadata path | EntityDefinitions(LogicalName='account')/Keys |
A ? or $ in the positional arg is rejected client-side before the request — move
those values onto --select, --filter, etc.
Bound function — unquoted GUID parameter (§11):
crm --json query odata "RetrieveAppComponents(AppModuleId=00000000-0000-0000-0000-000000000001)"
For Edm.Guid parameters, embed the GUID directly in the path — quoting it causes the
server to reject it.
Metadata path — list an entity's lookup relationships:
crm --json query odata "EntityDefinitions(LogicalName='account')/ManyToOneRelationships" \
--select ReferencedEntity,ReferencingAttribute
Metadata navigation paths (/Keys, /ManyToOneRelationships, /Attributes, …) are
forwarded verbatim to the Web API.