Skip to content

fix(common-utils): rewrite groupBy, aggregates, and orderBy to use materialized columns - #3562

Closed
CG-5228 wants to merge 1 commit into
ClickHouse:mainfrom
CG-5228:fix/issue-3539-materialized-groupby-aggregates
Closed

CG-5228 wants to merge 1 commit into
ClickHouse:mainfrom
CG-5228:fix/issue-3539-materialized-groupby-aggregates

Conversation

@CG-5228

@CG-5228 CG-5228 commented Nov 4, 2026

Copy link
Copy Markdown

Summary

Fixes #3539

Currently, fastifySQL in packages/common-utils/src/core/renderChartConfig.ts only rewrites map-access expressions (ResourceAttributes['...'], SpanAttributes['...'], LogAttributes['...'], etc.) to materialized columns in WHERE and simple SELECT expressions, leaving GROUP BY, SELECT aggregate functions, ORDER BY, and the __hdx_series_limit CTE referencing the unmaterialized Map column.

Changes

  • fastifySQL: Add an 'aggr_func' branch in traverse so AST traversal visits arguments inside SQL aggregate functions (COUNT(DISTINCT ...), AVG(...), SUM(...), MIN(...), MAX(...)).
  • renderSelectList:
    • Move the typeof selectList === 'string' handling after materializedFields resolution and rewrite string select/groupBy expressions via rewriteSelectExprWithMaterializedFields (used when groupBy is a string as in MCP/alert/dashboard configs).
    • Pre-rewrite select.valueExpression before aggFnExpr for ClickHouse-specific aggregate syntax (quantile(level)(...), histogram(...), countIf(DISTINCT ..., ...)) that node-sql-parser cannot parse directly.
  • renderSeriesLimitCte: Pass string chartConfig.groupBy columns through renderSelectList so the __hdx_series_limit CTE's tuple(...) and IS NOT NULL filter also use the materialized column.
  • renderOrderBy / renderSortSpecificationList: Resolve materializedFields and rewrite both string-form and array-form orderBy expressions to use materialized columns.
  • Tests: Add unit tests in packages/common-utils/src/__tests__/renderChartConfig.test.ts covering string groupBy, count_distinct, avg, quantile, __hdx_series_limit CTE tuple(...), and string/array orderBy.

@CG-5228
CG-5228 requested a review from a team as a code owner November 4, 2026 20:28
@CG-5228
CG-5228 requested a review from SpencerTorres November 4, 2026 20:28
@vercel

vercel Bot commented Nov 4, 2026

Copy link
Copy Markdown

@CG-5228 is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Nov 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 99a03c8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hyperdx/common-utils Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Hi @CG-5228, thanks for this.

We limit outside contributors to 1 open pull request at a time, and you already have #3478 open. Reviewing several changes from one author at once is more than we can give proper attention to, and it tends to mean none of them land.

I have closed this one to keep the queue honest — your branch and your work are untouched. Once that PR is merged or closed, reopen this and it will go through.

@github-actions github-actions Bot closed this Nov 4, 2026
// ignore
}

if (typeof selectList === 'string') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — String groupBy is rewritten as one unit, but ORDER BY and the series-limit tuple are rewritten one column at a time

renderSelectList passes the whole string (SELECT a, b FROM t) to fastifySQL. If the Postgres grammar rejects any column, the whole groupBy falls back to the raw text. renderSeriesLimitCte (line 1668) and renderSortSpecificationList rewrite each column on its own, so the columns that do parse still get rewritten. That produces SELECT tuple(__hdx_materialized_x, …) … GROUP BY ResourceAttributes['x'], … in the CTE (line 1729/1732), and an outer ORDER BY on a column that isn't in GROUP BY, which ClickHouse rejects as NOT_AN_AGGREGATE. Fix: in the string branch, split with splitAndTrimWithBracket, rewrite each item, and re-join, so all three sites rewrite at the same granularity.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

sortSpecificationList.trim() === ''
) {
return chSql`${{ UNSAFE_RAW_SQL: sortSpecificationList }}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — ORDER BY items with NULLS FIRST/LAST or other modifiers are never rewritten, while the GROUP BY now is

/^(.*?)\s+(ASC|DESC)$/i doesn't match ResourceAttributes['k8s.pod.name'] DESC NULLS LAST. The whole item is then parsed as a select expression, the parse fails, and the original text comes back. The string groupBy for the same key is now rewritten to the materialized column. A string groupBy plus string orderBy (the MCP/alert path) worked before this change, because both stayed raw. It now fails, since ORDER BY references a column that isn't in GROUP BY. Fix: split off the trailing ASC|DESC [NULLS FIRST|LAST] / COLLATE … / WITH FILL … modifiers before rewriting, or skip the orderBy rewrite whenever the item can't be split cleanly.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

}

if (typeof selectList === 'string') {
const resolvedSelectList = rewriteSelectExprWithMaterializedFields(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Rewriting string selects renames search result columns to internal materialized column names

The string-select branch is also the search path: DBRowTable builds its select with appendSelectWithAdditionalKeys, which returns a string. ClickHouse names output columns after the expression, so a selected ResourceAttributes['k8s.pod.name'] now comes back as __hdx_materialized_k8s.pod.name. That name becomes the table header, the CSV header and the row-where key, because DBRowTable.tsx takes column names from data.meta (lines 1842-1849). Fix: when a bare select item without an alias is rewritten, add AS "<original expr>", or limit the string rewrite to groupBy instead of every string select.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

let materializedFields: Map<string, string> | undefined;
if (chartConfig.orderBy != null) {
try {
materializedFields =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Materialized-fields lookup block copied a third time

This try/catch with the hasSubqueryCte || !databaseName guard repeats renderSelectList (line 752) and renderWhereExpressionStr (line 1373) exactly. Extract a getMaterializedFields(chartConfig, metadata) helper and call it from all three places.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

});
}

const rawSQL = `SELECT ${expr.sql} FROM \`t\``;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The new rewrite helper isn't used for the existing inline copy of the same logic

Lines 848-853 still build SELECT … FROM \t`, call fastifySQL, and strip the prefix and suffix by hand, which is exactly what rewriteSelectExprWithMaterializedFields(line 725) now does. Replace the inline code withexpr.sql = rewriteSelectExprWithMaterializedFields(expr.sql, materializedFields)when!needsPreAggRewrite`.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

// renderSelectList returns a single ChSql when given a string
(await renderSelectList(col, chartConfig, metadata, {
mergeRatio: false,
})) as ChSql,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — as ChSql cast on the renderSelectList result breaks the no-as convention

The as ChSql cast breaks the repository rule "Avoid as casts". Use the same normalization as the array branch below it (Array.isArray(rendered) ? rendered : [rendered]), or call rewriteSelectExprWithMaterializedFields directly with a shared materialized-fields lookup instead of renderSelectList.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

).not.toHaveBeenCalled();
});

it('rewrites string groupBy, count_distinct, and orderBy to use materialized columns', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Tests miss the pre-aggregate rewrite branches and the fallback paths

No test covers count_distinct with an aggCondition (countIf(DISTINCT …)), histogram, an orderBy item with NULLS LAST, or a string groupBy where one column doesn't parse. Those are the paths where needsPreAggRewrite and the per-item and whole-string rewrites diverge. Add cases that assert GROUP BY, ORDER BY, and the CTE tuple use the same expressions.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

7 finding(s): 🔴 0 critical · 🟠 3 major · 🔵 4 minor

7 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. No P0/P1 ship-blockers surfaced. The core mechanism — wrapping each expression in a throwaway SELECT … FROM \t`and reusing the existingfastifySQLAST rewriter, then string-stripping the wrapper — degrades safely: on parse failurefastifySQLreturns the input unchanged and the anchored strip regexes yield the original expression. Independent checks of the order-by direction regex, the bracket/quote-aware column split, theaggr_functraversal, and theneedsPreAggRewritegating found them sound. The change does not widen the SQL trust boundary: thesegroupBy/orderBy/aggregate expressions were already emitted via UNSAFE_RAW_SQL` before this PR.

🟡 P2 -- recommended

  • packages/common-utils/src/__tests__/renderChartConfig.test.ts:1603 -- New aggregate branches lack dedicated coverage: the count_distinct-with-aggCondition (countIf) and histogram pre-aggregate rewrite paths, the string select rewrite path (MCP/alert configs), and multi-column string orderBy are untested, while SUM/MIN/MAX are only covered transitively through the avg case.
    • Fix: Add cases that assert materialized-column rewriting for a count_distinct with an aggCondition, a histogram aggregate, a string-form select expression, and a two-column string orderBy with mixed ASC/DESC.
🔵 P3 nitpicks (5)
  • packages/common-utils/src/core/renderChartConfig.ts:849 -- The fastifySQL(...).replace(/^SELECT\s+/i,'').replace(/\s+FROM \t`$/i,'')strip pair now exists both inline inrenderSelectListand inside the newrewriteSelectExprWithMaterializedFields` helper, so the wrapper format must be changed in two places to stay consistent.
    • Fix: Route the inline post-aggregate strip through rewriteSelectExprWithMaterializedFields so the wrap/strip logic lives in one place.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:795 -- needsPreAggRewrite repeats the aggFn.startsWith('quantile') || startsWith('histogram') test that the branch selector below also uses, so the two can silently drift and mismatch which expression gets pre-rewritten.
    • Fix: Hoist a single isQuantileOrHistogram constant shared by both sites and add a comment explaining why these aggregate kinds must be rewritten before aggFnExpr wraps them.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:752 -- The hasSubqueryCte(...) || !databaseName ? undefined : getMaterializedColumnsLookupTable(...) try/catch block with its 4-line comment is now duplicated across renderSelectList, renderGroupBy, and renderOrderBy.
    • Fix: Extract a fetchMaterializedFields(chartConfig, metadata) helper (accepting an optional skip condition for the orderBy != null guard) and reuse it at all three sites.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:1671 -- The as ChSql cast on renderSelectList's return in renderSeriesLimitCte depends on the string branch always returning a single ChSql, an invariant not reflected in the function's union return type.
    • Fix: Add a renderSelectList(selectList: string, ...): Promise<ChSql> overload so the compiler enforces the invariant instead of an unsafe cast plus comment.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:1667 -- The string groupBy path in renderSeriesLimitCte calls renderSelectList once per split column, so each column independently rebuilds the materialized-column Map and runs a full node-sql-parser round-trip.
    • Fix: Fetch the lookup once before the split and map each column through rewriteSelectExprWithMaterializedFields directly; low priority since the lookup is a cache hit and column counts are small.
    • performance

Reviewers (11 dispatched; 7 returned before synthesis): security, performance, maintainability, project-standards, agent-native, learnings, previous-comments. Correctness, adversarial, testing, and kieran-typescript did not return before the synthesis deadline; their dimensions were covered by the orchestrator's own verification of the diff.

Testing gaps:

  • countIf (count_distinct + aggCondition) and histogram pre-aggregate rewrite branches have no dedicated test.
  • No test asserts byte-identical output when an expression fails to parse (parse-failure fallback path).
  • Multi-column string orderBy and the string-form select rewrite path are unexercised.
  • SUM/MIN/MAX are only validated transitively via the avg case.

Notes: Prior PR comments are automated bot messages only (Vercel, changeset-bot, an outside-contributor PR-limit notice) — no actionable review feedback to verify. Changeset is present with a correct patch bump for @hyperdx/common-utils. No agent-native parity gaps (internal query-rendering plumbing). No relevant prior learnings in docs/solutions/.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Log and trace charts read the whole attribute map for GROUP BY and aggregates, even when a materialized column exists

1 participant