Skip to content

Commit f401118

Browse files
authored
fix(tesseract): pre-aggregation table name corruption when one name is a prefix of another (#11000)
1 parent 38b7d8c commit f401118

2 files changed

Lines changed: 100 additions & 3 deletions

File tree

packages/cubejs-query-orchestrator/src/orchestrator/QueryCache.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,24 @@ export class QueryCache {
426426
const [keyQuery, params, queryOptions] = Array.isArray(queryAndParams)
427427
? queryAndParams
428428
: [queryAndParams, []];
429-
const replacedKeyQuery: string = preAggregationsTablesToTempTables.reduce(
430-
(query, [tableName, { targetTableName }]) => QueryCache.replaceAll(tableName, targetTableName, query),
431-
keyQuery
429+
// Single-pass replacement with longest-first alternation: sequential
430+
// per-name replacement would corrupt names that are prefixes of other
431+
// names (e.g. `name1` vs `name10`) and rescan already inserted target
432+
// names, which contain the source name as a prefix
433+
const sorted = [...preAggregationsTablesToTempTables]
434+
.sort(([a], [b]) => b.length - a.length);
435+
const replacements = new Map(
436+
sorted.map(([tableName, { targetTableName }]) => [tableName, targetTableName])
432437
);
438+
const replaceRegex = new RegExp(
439+
sorted
440+
.map(([tableName]) => tableName.replace(/([/,!\\^${}[\]().*+?|<>\-&])/g, '\\$&'))
441+
.join('|'),
442+
'g'
443+
);
444+
const replacedKeyQuery: string = sorted.length
445+
? keyQuery.replace(replaceRegex, (match) => replacements.get(match) as string)
446+
: keyQuery;
433447
return Array.isArray(queryAndParams)
434448
? [replacedKeyQuery, params, queryOptions]
435449
: replacedKeyQuery;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { QueryCache } from '../../src';
2+
import type { PreAggTableToTempTableNames } from '../../src';
3+
4+
describe('QueryCache.replacePreAggregationTableNames', () => {
5+
test('replaces a single table name', () => {
6+
const result = QueryCache.replacePreAggregationTableNames(
7+
'SELECT * FROM dev_pre_aggregations.orders_rollup',
8+
[['dev_pre_aggregations.orders_rollup', { targetTableName: 'dev_pre_aggregations.orders_rollup_20250401_abc' }]],
9+
);
10+
expect(result).toBe('SELECT * FROM dev_pre_aggregations.orders_rollup_20250401_abc');
11+
});
12+
13+
test('does not corrupt names that are prefixes of other names (name1 vs name10)', () => {
14+
const baseName = 'dev_pre_aggregations.orders_rollup';
15+
const entries: PreAggTableToTempTableNames[] = Array.from(
16+
{ length: 12 },
17+
(_, i): PreAggTableToTempTableNames => [
18+
`${baseName}${i}`,
19+
{ targetTableName: `(SELECT * FROM ${baseName}_20250401_part${i})` },
20+
],
21+
);
22+
const query = entries
23+
.map(([tableName], i) => `SELECT * FROM ${tableName} AS "alias${i}"`)
24+
.join(' UNION ALL ');
25+
26+
const result = QueryCache.replacePreAggregationTableNames(query, entries) as string;
27+
28+
entries.forEach(([, { targetTableName }], i) => {
29+
expect(result).toContain(`${targetTableName} AS "alias${i}"`);
30+
});
31+
// No stray suffix digits left behind, e.g. `...)0 AS "alias10"`
32+
expect(result).not.toMatch(/\)\d+ AS/);
33+
expect(result).not.toContain(`${baseName}10`);
34+
});
35+
36+
test('does not match source names inside already inserted target names', () => {
37+
// Real-world target shape: tableName + '_' + versions, so the target
38+
// of `rollup10` contains `rollup1` as a substring
39+
const entries: PreAggTableToTempTableNames[] = [
40+
['pa.rollup1', { targetTableName: 'pa.rollup1_aaa_bbb_111' }],
41+
['pa.rollup10', { targetTableName: 'pa.rollup10_ccc_ddd_222' }],
42+
];
43+
const result = QueryCache.replacePreAggregationTableNames(
44+
'SELECT * FROM pa.rollup10 JOIN pa.rollup1',
45+
entries,
46+
);
47+
expect(result).toBe('SELECT * FROM pa.rollup10_ccc_ddd_222 JOIN pa.rollup1_aaa_bbb_111');
48+
});
49+
50+
test('keeps params and query options for QueryWithParams input', () => {
51+
const result = QueryCache.replacePreAggregationTableNames(
52+
['SELECT * FROM dev_pre_aggregations.orders_rollup WHERE id = ?', ['1'], { external: true }],
53+
[['dev_pre_aggregations.orders_rollup', { targetTableName: 'dev_pre_aggregations.orders_rollup_20250401_abc' }]],
54+
);
55+
expect(result).toEqual([
56+
'SELECT * FROM dev_pre_aggregations.orders_rollup_20250401_abc WHERE id = ?',
57+
['1'],
58+
{ external: true },
59+
]);
60+
});
61+
62+
test('returns query as is for empty replacements', () => {
63+
const result = QueryCache.replacePreAggregationTableNames('SELECT 1', []);
64+
expect(result).toBe('SELECT 1');
65+
});
66+
67+
test('treats $ in target names literally', () => {
68+
const result = QueryCache.replacePreAggregationTableNames(
69+
'SELECT * FROM pa.rollup',
70+
[['pa.rollup', { targetTableName: 'pa.rollup_$&_$1' }]],
71+
);
72+
expect(result).toBe('SELECT * FROM pa.rollup_$&_$1');
73+
});
74+
75+
test('does not mutate the incoming array order', () => {
76+
const entries: PreAggTableToTempTableNames[] = [
77+
['name1', { targetTableName: 'target1' }],
78+
['name10', { targetTableName: 'target10' }],
79+
];
80+
QueryCache.replacePreAggregationTableNames('SELECT * FROM name1, name10', entries);
81+
expect(entries.map(([tableName]) => tableName)).toEqual(['name1', 'name10']);
82+
});
83+
});

0 commit comments

Comments
 (0)