Repository navigation
fix(common-utils): split comma-separated string groupBy in increase ranking CTE #3478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CG-5228
wants to merge
8
commits into
ClickHouse:main
Choose a base branch
from
CG-5228:fix/issue-3477
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+169
−55
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
98ecc1f
fix(common-utils): split comma-separated string groupBy in increase r…
CG-5228 32dc190
refactor(common-utils): extract shared renderGroupByColumns helper
CG-5228 79e0aa2
chore(common-utils): add changeset and clarify renderGroupByColumns c…
CG-5228 d347903
chore(common-utils): drop redundant non-null assertion and assert sin…
CG-5228 a42059a
chore(common-utils): clarify renderGroupBy comment and align chartCon…
CG-5228 9602ca0
chore(common-utils): guard empty groupByCols and tighten increase str…
CG-5228 8dc7152
Merge branch 'main' into fix/issue-3477
CG-5228 f3270d3
test(common-utils): cover aggFn=increase with whitespace/comma-only s…
CG-5228 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@hyperdx/common-utils": patch | ||
| --- | ||
|
|
||
| fix(common-utils): split comma-separated string groupBy in increase ranking CTE |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1519,6 +1519,9 @@ async function renderGroupBy( | |
| chartConfig: BuilderChartConfigWithOptDateRangeEx, | ||
| metadata: Metadata, | ||
| ): Promise<ChSql | undefined> { | ||
| // The outer GROUP BY keeps a comma-separated string groupBy intact as a | ||
| // single expression; per-column splitting for tuple(...)/IN contexts is | ||
| // handled separately by renderGroupByColumns. | ||
| return concatChSql( | ||
| ',', | ||
| isUsingGroupBy(chartConfig) | ||
|
|
@@ -1540,6 +1543,32 @@ async function renderGroupBy( | |
| ); | ||
| } | ||
|
|
||
| async function renderGroupByColumns( | ||
| groupBy: NonNullable<BuilderChartConfigWithOptDateRangeEx['groupBy']>, | ||
| chartConfig: BuilderChartConfigWithOptDateRangeEx, | ||
| metadata: Metadata, | ||
| ): Promise<ChSql[]> { | ||
| // One ChSql per group-by column (groupBy may be an array or a comma-separated | ||
| // string). The string branch emits raw SQL per split column and does not | ||
| // consult `chartConfig` or `metadata`; only the array branch resolves columns | ||
| // via `renderSelectList(chartConfig, metadata)`. splitAndTrimWithBracket | ||
| // respects []/()/quotes so it won't split inside Map['a,b']. | ||
| if (typeof groupBy === 'string') { | ||
| return splitAndTrimWithBracket(groupBy).map( | ||
| col => chSql`${{ UNSAFE_RAW_SQL: col }}`, | ||
| ); | ||
| } | ||
| // Strip aliases: these go inside tuple(...)/`IS NOT NULL`, where an | ||
| // `AS "alias"` suffix is a syntax error (unlike the outer GROUP BY). | ||
| const rendered = await renderSelectList( | ||
| groupBy.map(col => ({ ...col, alias: undefined })), | ||
| chartConfig, | ||
| metadata, | ||
| { mergeRatio: false }, | ||
| ); | ||
| return Array.isArray(rendered) ? rendered : [rendered]; | ||
| } | ||
|
|
||
| async function renderSeriesLimitCte( | ||
| chartConfig: BuilderChartConfigWithOptDateRangeEx, | ||
| metadata: Metadata, | ||
|
|
@@ -1589,25 +1618,11 @@ async function renderSeriesLimitCte( | |
| ]) | ||
| : []; | ||
|
|
||
| // One ChSql per group-by column (groupBy may be an array or a comma-separated | ||
| // string). splitAndTrimWithBracket respects []/()/quotes so it won't split | ||
| // inside Map['a,b']; the per-column null filter below needs them separated. | ||
| let groupByCols: ChSql[]; | ||
| if (typeof chartConfig.groupBy === 'string') { | ||
| groupByCols = splitAndTrimWithBracket(chartConfig.groupBy).map( | ||
| col => chSql`${{ UNSAFE_RAW_SQL: col }}`, | ||
| ); | ||
| } else { | ||
| // Strip aliases: these go inside tuple(...)/`IS NOT NULL`, where an | ||
| // `AS "alias"` suffix is a syntax error (unlike the outer GROUP BY). | ||
| const rendered = await renderSelectList( | ||
| chartConfig.groupBy.map(col => ({ ...col, alias: undefined })), | ||
| chartConfig, | ||
| metadata, | ||
| { mergeRatio: false }, | ||
| ); | ||
| groupByCols = Array.isArray(rendered) ? rendered : [rendered]; | ||
| } | ||
| const groupByCols = await renderGroupByColumns( | ||
| chartConfig.groupBy, | ||
| chartConfig, | ||
| metadata, | ||
| ); | ||
| const groupByTuple = concatChSql(',', groupByCols); | ||
|
|
||
| // Rank by the value the chart actually plots, so "top N" means top N by what | ||
|
|
@@ -2319,40 +2334,35 @@ async function translateMetricChartConfig( | |
| let outerWhere: string = ''; | ||
|
|
||
| if (shouldApplyIncreaseGroupLimit) { | ||
| // Render the user's groupBy against the Bucketed CTE so column | ||
| // references resolve to the CTE's projection. Aliases are stripped: | ||
| // these expressions go inside tuple(...) in the ranking CTE and the | ||
| // outer IN predicate, where an `AS "alias"` suffix is a syntax error | ||
| // (mirrors the strip in renderSeriesLimitCte). | ||
| const groupByForRank = await renderSelectList( | ||
| typeof chartConfig.groupBy === 'string' | ||
| ? chartConfig.groupBy | ||
| : chartConfig.groupBy!.map(col => ({ ...col, alias: undefined })), | ||
| // For array groupBy, resolve column references against the Bucketed CTE | ||
| // projection; string groupBy is split directly into per-column SQL. | ||
| const groupByCols = await renderGroupByColumns( | ||
| chartConfig.groupBy!, | ||
| { | ||
| ...chartConfig, | ||
| from: { databaseName: '', tableName: 'Bucketed' }, | ||
| with: sumWith, | ||
| } as BuilderChartConfigWithOptDateRangeEx, | ||
| metadata, | ||
| { mergeRatio: false }, | ||
| ); | ||
| const groupBySql = concatChSql(',', groupByForRank); | ||
|
|
||
| // Exclude rows where any groupBy column is NULL/empty so they don't | ||
| // collapse into a single dominating '-' series. | ||
| const groupByEmptyFilter = concatChSql( | ||
| ' AND ', | ||
| (Array.isArray(groupByForRank) ? groupByForRank : [groupByForRank]).map( | ||
| g => chSql`(${g} IS NOT NULL AND toString(${g}) != '')`, | ||
| ), | ||
| ); | ||
| if (groupByCols.length > 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can a If it can send it, return chartConfig.groupBy != null &&
(typeof chartConfig.groupBy === 'string'
? splitAndTrimWithBracket(chartConfig.groupBy).length > 0
: chartConfig.groupBy.length > 0); |
||
| const groupBySql = concatChSql(',', groupByCols); | ||
|
|
||
| // Exclude rows where any groupBy column is NULL/empty so they don't | ||
| // collapse into a single dominating '-' series. | ||
| const groupByEmptyFilter = concatChSql( | ||
| ' AND ', | ||
| groupByCols.map( | ||
| g => chSql`(${g} IS NOT NULL AND toString(${g}) != '')`, | ||
| ), | ||
| ); | ||
|
|
||
| // Rank by max-per-bucket summed Rate so a group that spikes in one | ||
| // bucket still makes the top N. tuple() wraps multi-column groupBys | ||
| // into a single comparable column. | ||
| sumWith.push({ | ||
| name: 'TopGroups', | ||
| sql: chSql` | ||
| // Rank by max-per-bucket summed Rate so a group that spikes in one | ||
| // bucket still makes the top N. tuple() wraps multi-column groupBys | ||
| // into a single comparable column. | ||
| sumWith.push({ | ||
| name: 'TopGroups', | ||
| sql: chSql` | ||
| SELECT \`group\` | ||
| FROM ( | ||
| SELECT | ||
|
|
@@ -2366,18 +2376,19 @@ async function translateMetricChartConfig( | |
| ORDER BY max(\`bucket_value\`) DESC, \`group\` | ||
| LIMIT ${{ Int32: INCREASE_MAX_NUM_GROUPS }} | ||
| `, | ||
| }); | ||
| }); | ||
|
|
||
| // Safety: groupBySql is built from metric groupBy expressions which are | ||
| // always simple column references (UNSAFE_RAW_SQL). Verify no parameterized | ||
| // values leaked through — if they did, .sql would contain param placeholders | ||
| // but the string-based outer WHERE would lose the param bindings. | ||
| if (Object.keys(groupBySql.params).length > 0) { | ||
| throw new Error( | ||
| 'increase + groupBy: unexpected parameterized groupBy expressions', | ||
| ); | ||
| // Safety: groupBySql is built from metric groupBy expressions which are | ||
| // always simple column references (UNSAFE_RAW_SQL). Verify no parameterized | ||
| // values leaked through — if they did, .sql would contain param placeholders | ||
| // but the string-based outer WHERE would lose the param bindings. | ||
| if (Object.keys(groupBySql.params).length > 0) { | ||
| throw new Error( | ||
| 'increase + groupBy: unexpected parameterized groupBy expressions', | ||
| ); | ||
| } | ||
| outerWhere = `tuple(${groupBySql.sql}) IN (SELECT \`group\` FROM TopGroups)`; | ||
| } | ||
| outerWhere = `tuple(${groupBySql.sql}) IN (SELECT \`group\` FROM TopGroups)`; | ||
| } | ||
|
|
||
| return { | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — New empty-groupBy fallback (
groupByCols.length > 0) has no testA whitespace-only or comma-only string groupBy (e.g.
' 'or',') passesisUsingGroupBy(it only checkslength > 0), butsplitAndTrimWithBracketreturns[]for it. That is the only input that reaches this new branch, which silently drops TopGroups and outerWhere. Before this PR the same input producedtuple( )and( IS NOT NULL …). Add a renderChartConfig.test.ts case withaggFn: 'increase'andgroupBy: ' , 'that asserts noTopGroupsCTE and noIN (SELECT \group``. Without it, nothing stops this branch from regressing.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