Skip to content

Commit e6e9296

Browse files
authored
feat(data-marts): unified column identifiers for output control filters (#1329)
* feat(data-marts): unified column identifiers for output control filters Slice (pre-join) filters now reference a column by the same fully qualified identifier as post-join filters (e.g. category_details__item_event_count) instead of a raw column name plus a separate aliasPath. A single blended-field index resolves the unified name to its source/CTE/raw column for both validation and SQL generation across all storages; the wire schema and OpenAPI contract drop aliasPath, and a TypeORM migration converts existing reports. The frontend slice UI emits the unified identifier. * style(web): wrap long line in FilterRow to satisfy prettier
1 parent dad37e4 commit e6e9296

41 files changed

Lines changed: 991 additions & 631 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'owox': minor
3+
---
4+
5+
# Unified column identifiers for output control filters
6+
7+
Slice (pre-join) filters now reference a column by the same fully qualified identifier as
8+
regular output filters — for example `category_details__item_event_count` — instead of a raw
9+
column name plus a separate `aliasPath`. This makes every filter, slice, and sort refer to a
10+
column the same way across the API and the Web/Extension report editor. Existing saved reports
11+
are migrated automatically, so no manual changes are needed.

apps/backend/src/data-marts/controllers/spec/report-openapi.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ export class ReportRelativeDateFilterValueApiDto {
127127
}
128128

129129
export class ReportFilterRuleApiDto {
130-
@ApiProperty({ minLength: 1 })
130+
@ApiProperty({
131+
minLength: 1,
132+
description:
133+
'Output column name. For slice filters (placement=pre-join), use the fully qualified blended column identifier, e.g. category_details__item_event_count.',
134+
})
131135
column: string;
132136

133137
@ApiProperty({
@@ -156,14 +160,6 @@ export class ReportFilterRuleApiDto {
156160
description: 'Use pre-join for slice filters. Omit for normal output filters.',
157161
})
158162
placement?: 'pre-join' | 'post-join';
159-
160-
@ApiPropertyOptional({
161-
minLength: 1,
162-
maxLength: 255,
163-
pattern: '^[a-z0-9_]+(\\.[a-z0-9_]+)*$',
164-
description: 'Required for slice filters when placement is pre-join.',
165-
})
166-
aliasPath?: string;
167163
}
168164

169165
export class ReportSortRuleApiDto {
@@ -232,7 +228,8 @@ const commonReportRequestProperties = {
232228
nullable: true,
233229
maxItems: 50,
234230
items: { $ref: getSchemaPath(ReportFilterRuleApiDto) },
235-
description: 'Output filters. Use placement=pre-join and aliasPath for slice filters.',
231+
description:
232+
'Output filters. Use placement=pre-join for slice filters (column = unified blended identifier).',
236233
},
237234
sortConfig: {
238235
type: 'array',

apps/backend/src/data-marts/controllers/spec/report.openapi.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ describe('ReportController OpenAPI', () => {
127127
);
128128
expect(filterRule.properties.value.oneOf).toHaveLength(3);
129129
expect(filterRule.properties.placement.enum).toEqual(['pre-join', 'post-join']);
130-
expect(filterRule.properties.aliasPath.description).toContain('slice');
130+
expect(filterRule.properties.aliasPath).toBeUndefined();
131+
expect(filterRule.properties.column.description).toContain(
132+
'category_details__item_event_count'
133+
);
131134

132135
expect(updateProperties.sortConfig).toMatchObject({
133136
type: 'array',

apps/backend/src/data-marts/data-storage-types/athena/services/athena-blended-query-builder.spec.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { AthenaBlendedQueryBuilder } from './athena-blended-query-builder';
99
import { AthenaClauseRenderer } from './athena-clause-renderer';
1010
import { BlendedQueryContext } from '../../interfaces/blended-query-builder.interface';
11+
import { buildBlendedFieldIndex } from '../../../services/blended-field-index';
1112

1213
const buildContext = createBuildContext('"mydb"."customers"');
1314

@@ -202,20 +203,26 @@ describe('AthenaBlendedQueryBuilder — output controls', () => {
202203
{ targetFieldName: 'role', outputAlias: 'role', isHidden: true, aggregateFunction: 'MAX' },
203204
],
204205
});
206+
const fieldIndex = buildBlendedFieldIndex({
207+
blendedFields: [
208+
{ name: 'users__role', aliasPath: 'users', originalFieldName: 'role', type: 'STRING' },
209+
],
210+
availableSources: [{ aliasPath: 'users', isIncluded: true }],
211+
} as never);
205212
const { params } = builder.buildBlendedQuery(
206213
ctx({
207214
chains: [chain],
208215
columns: ['a'],
209216
filters: [
210217
{
211-
column: 'role',
218+
column: 'users__role',
212219
operator: 'eq',
213220
value: 'admin',
214221
placement: 'pre-join',
215-
aliasPath: 'users',
216222
},
217223
{ column: 'a', operator: 'eq', value: 'post', placement: 'post-join' },
218224
],
225+
fieldIndex,
219226
})
220227
);
221228
// pre-join value first (lives inside the users_raw CTE), post-join value last.
@@ -253,20 +260,30 @@ describe('AthenaBlendedQueryBuilder — output controls', () => {
253260
},
254261
],
255262
});
263+
const fieldIndex = buildBlendedFieldIndex({
264+
blendedFields: [
265+
{
266+
name: 'users__signup_date',
267+
aliasPath: 'users',
268+
originalFieldName: 'signup_date',
269+
type: 'DATE',
270+
},
271+
],
272+
availableSources: [{ aliasPath: 'users', isIncluded: true }],
273+
} as never);
256274
const { sql, params } = builder.buildBlendedQuery(
257275
ctx({
258276
chains: [chain],
259277
columns: ['a'],
260278
filters: [
261279
{
262-
column: 'signup_date',
280+
column: 'users__signup_date',
263281
operator: 'gte',
264282
value: '2024-01-01',
265283
placement: 'pre-join',
266-
aliasPath: 'users',
267284
},
268285
],
269-
columnTypes: { preJoin: new Map([['users', new Map([['signup_date', 'DATE']])]]) },
286+
fieldIndex,
270287
})
271288
);
272289
expect(sql).toContain('signup_date >= CAST(? AS DATE)');

apps/backend/src/data-marts/data-storage-types/bigquery/services/bigquery-blended-query-builder-edge-cases.spec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BlendedQueryContext } from '../../interfaces/blended-query-builder.interface';
2+
import { buildBlendedFieldIndex } from '../../../services/blended-field-index';
23
import {
34
makeChain,
45
makeRelationship,
@@ -122,6 +123,17 @@ describe('BigQueryBlendedQueryBuilder — SQL safety / quoting', () => {
122123
],
123124
});
124125

126+
const fieldIndex = buildBlendedFieldIndex({
127+
blendedFields: [
128+
{
129+
name: 'users__first name',
130+
aliasPath: 'users',
131+
originalFieldName: 'first name',
132+
type: 'STRING',
133+
},
134+
],
135+
availableSources: [{ aliasPath: 'users', isIncluded: true }],
136+
} as never);
125137
const ctx: BlendedQueryContext = {
126138
mainTableReference: '`project.dataset.events`',
127139
mainDataMartTitle: 'Events',
@@ -130,12 +142,12 @@ describe('BigQueryBlendedQueryBuilder — SQL safety / quoting', () => {
130142
columns: ['event_id', 'first_name_agg'],
131143
filters: [
132144
{
133-
column: 'first name',
145+
column: 'users__first name',
134146
operator: 'is_not_null',
135147
placement: 'pre-join',
136-
aliasPath: 'users',
137148
},
138149
],
150+
fieldIndex,
139151
};
140152

141153
const { sql } = builder.buildBlendedQuery(ctx);

apps/backend/src/data-marts/data-storage-types/databricks/services/databricks-blended-query-builder.spec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { DatabricksBlendedQueryBuilder } from './databricks-blended-query-builder';
99
import { DatabricksClauseRenderer } from './databricks-clause-renderer';
1010
import { BlendedQueryContext } from '../../interfaces/blended-query-builder.interface';
11+
import { buildBlendedFieldIndex } from '../../../services/blended-field-index';
1112

1213
const buildContext = createBuildContext('`catalog`.`schema`.`customers`');
1314

@@ -177,19 +178,25 @@ describe('DatabricksBlendedQueryBuilder — output controls', () => {
177178
{ targetFieldName: 'role', outputAlias: 'role', isHidden: true, aggregateFunction: 'MAX' },
178179
],
179180
});
181+
const fieldIndex = buildBlendedFieldIndex({
182+
blendedFields: [
183+
{ name: 'users__role', aliasPath: 'users', originalFieldName: 'role', type: 'STRING' },
184+
],
185+
availableSources: [{ aliasPath: 'users', isIncluded: true }],
186+
} as never);
180187
const { sql, params } = builder.buildBlendedQuery(
181188
ctx({
182189
chains: [chain],
183190
columns: ['a'],
184191
filters: [
185192
{
186-
column: 'role',
193+
column: 'users__role',
187194
operator: 'eq',
188195
value: 'admin',
189196
placement: 'pre-join',
190-
aliasPath: 'users',
191197
},
192198
],
199+
fieldIndex,
193200
})
194201
);
195202
// The inlined predicate must sit inside the subsidiary raw CTE, before the outer SELECT.

0 commit comments

Comments
 (0)