Skip to content

Add granularity and multi-dimension grouping to cost queries - #8

Merged
StanBarrows merged 1 commit into
mainfrom
feature/cost-query-granularity
Aug 7, 2026
Merged

Add granularity and multi-dimension grouping to cost queries#8
StanBarrows merged 1 commit into
mainfrom
feature/cost-query-granularity

Conversation

@StanBarrows

Copy link
Copy Markdown
Contributor

Why

CostQueryPayload hardcoded granularity: 'None' and exactly one grouping dimension. A caller could only ever get one row per dimension value for the whole period — attributing Azure spend to a day, or slicing it by resource group and service at once, was not expressible.

This came out of building per-workspace cost attribution in flows-portal.docuhub, which had to settle for monthly totals as an interim.

What

  • CostGranularity enum (None / Daily). Monthly is deliberately absent — the query endpoint does not offer it, and a one-month period at None is how you ask for that.
  • CostQueryPayload::$grouping now accepts string|list<string>. The bare string is kept because it was the original signature and is the common case; the list matches ARM's own dataset.grouping, which is always an array.
  • CostQueryPayload::$granularity defaults to None.
  • CostManagementResource::queryDaily() as the named daily variant.

Backwards compatibility

Non-breaking. $granularity defaults to None and $grouping still accepts a string, so every existing caller produces a byte-identical request body — pinned by the first test in CostQueryPayloadTest.

Two ARM quirks encoded rather than left to callers

  • grouping: [] is a 400, while omitting the key entirely is valid and means "one total for the whole period". An empty list therefore drops the key instead of sending an empty array.
  • array_filter preserves keys, so dropping an empty dimension name from the middle of a list would make the grouping encode as a JSON object and ARM would reject it. Reindexed with array_values().

What callers need to know about Daily

It changes the response shape, not just its resolution: rows gain a UsageDate column (yyyyMMdd, as an integer) and the row count multiplies by the days in the period. That is why it is opt-in, and why queryDaily() exists as a named method rather than something you pass as a fourth argument without noticing what comes back.

Tests

14, covering every branch — both ARM quirks above, the string and list forms, the default body, and the request body actually sent through the connector.

Gate Result
composer test 597 passed (1612 assertions)
composer analyse 0 errors
composer format clean
composer inventory:parity regenerated, clean
composer docs:api regenerated

Coverage was not verifiable locally (no xdebug/pcov on this machine — the 100% gate runs in CI); every branch has a test written for it deliberately.

One thing for the reviewer

queryDaily() does not appear in the generated docs/api-reference.md resource table. The generator's regex requires new SomeRequest( inside the method body, and queryDaily() delegates to query(). I chose not to duplicate the send logic just to satisfy the generator — happy to either inline it or drop the convenience method if you would rather the docs stay complete.

🤖 Generated with Claude Code

CostQueryPayload hardcoded `granularity: 'None'` and a single grouping
dimension, so a caller could only ever get one row per dimension value for
the whole period. Attributing Azure spend to a day, or slicing it by resource
group AND service at once, was not expressible.

- `CostGranularity` enum (None/Daily). Monthly is deliberately absent: the
  query endpoint does not offer it, and a one-month period at None is how you
  ask for that.
- `CostQueryPayload::$grouping` now accepts `string|list<string>`. The bare
  string is kept because it was the original signature and is the common case.
- `CostQueryPayload::$granularity` defaults to None, so every existing caller
  gets byte-identical bodies.
- `CostManagementResource::queryDaily()` as the named daily variant.

Two ARM quirks are encoded rather than left to callers:

- `grouping: []` is a 400, while omitting the key entirely is valid and means
  "one total for the whole period". Empty lists drop the key.
- `array_filter` preserves keys, so a dropped empty dimension name would make
  the grouping encode as a JSON object and ARM would reject it. Reindexed.

Daily changes the response SHAPE, not just its resolution — rows gain a
`UsageDate` column (yyyyMMdd, as an integer) and the row count multiplies by
the days in the period. That is why it is opt-in and why `queryDaily()` exists
as a named method rather than a fourth argument callers pass without noticing.

14 tests covering every branch, including the two ARM quirks above and the
request body actually sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@StanBarrows
StanBarrows merged commit b9058b5 into main Aug 7, 2026
10 checks passed
@StanBarrows
StanBarrows deleted the feature/cost-query-granularity branch August 8, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant