Skip to content

Commit 3477776

Browse files
authored
feat(tesseract): default value filters for views (CORE-357) (#10892)
1 parent 3d59c59 commit 3477776

33 files changed

Lines changed: 1579 additions & 10 deletions

packages/cubejs-backend-native/src/bridge_test_exports.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ use cubesqlplanner::cube_bridge::{
9191
timeshift_definition::{
9292
time_shift_definition_bridge_fields_meta, NativeTimeShiftDefinition, TimeShiftDefinition,
9393
},
94+
view_filter_definition::{
95+
view_filter_definition_bridge_fields_meta, NativeViewFilterDefinition,
96+
},
9497
};
9598
use neon::prelude::*;
9699
use std::any::Any;
@@ -451,6 +454,7 @@ bridge_registry! {
451454
"sqlUtils" => NativeSqlUtils, sql_utils_bridge_fields_meta, invoke_sql_utils;
452455
"structWithSqlMember" => NativeStructWithSqlMember, struct_with_sql_member_bridge_fields_meta, invoke_struct_with_sql_member;
453456
"timeShiftDefinition" => NativeTimeShiftDefinition, time_shift_definition_bridge_fields_meta, invoke_time_shift_definition;
457+
"viewFilterDefinition" => NativeViewFilterDefinition, view_filter_definition_bridge_fields_meta, invoke_view_filter_definition;
454458
}
455459

456460
fn list_bridge_fields_inner<IT: InnerTypes>(
@@ -708,10 +712,17 @@ fn invoke_time_shift_definition<IT: InnerTypes>(b: &NativeTimeShiftDefinition<IT
708712
r
709713
}
710714

715+
fn invoke_view_filter_definition<IT: InnerTypes>(
716+
_b: &NativeViewFilterDefinition<IT>,
717+
) -> InvokeResult {
718+
InvokeResult::new()
719+
}
720+
711721
fn invoke_cube_definition<IT: InnerTypes>(b: &NativeCubeDefinition<IT>) -> InvokeResult {
712722
let mut r = InvokeResult::new();
713723
r.record("sql_table", b.sql_table());
714724
r.record("sql", b.sql());
725+
r.record("filters", b.filters());
715726
r
716727
}
717728

packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,20 @@ export const preAggregationDescriptionFixture = (): unknown => ({
131131
// measure_references, dimension_references, etc — all optional getters
132132
});
133133

134+
export const viewFilterDefinitionFixture = (): unknown => ({
135+
operator: 'equals',
136+
memberReference: 'orders.currency',
137+
// Values are stringified by CubeEvaluator.prepareViewFilters before reaching
138+
// Tesseract; nulls are kept to exercise the Option<Vec<Option<String>>> shape.
139+
valuesReferences: ['USD', null],
140+
unlessReferences: ['orders.currency'],
141+
});
142+
134143
export const cubeDefinitionFixture = (): unknown => ({
135144
name: 'Orders',
136145
// sqlAlias, isView, isCalendar, joinMap optional
137146
// sql_table, sql optional getters
147+
filters: [viewFilterDefinitionFixture()],
138148
});
139149

140150
export const dimensionDefinitionFixture = (): unknown => ({
@@ -270,4 +280,5 @@ export const FIXTURES: Record<string, BridgeFixtureFactory> = {
270280
sqlUtils: sqlUtilsFixture,
271281
structWithSqlMember: structWithSqlMemberFixture,
272282
timeShiftDefinition: timeShiftDefinitionFixture,
283+
viewFilterDefinition: viewFilterDefinitionFixture,
273284
};

packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ const BRIDGES: BridgeSpec[] = [
8787
{ name: 'caseSwitchItem', expected: ['sql', 'value'] },
8888
{
8989
name: 'cubeDefinition',
90-
expected: ['is_calendar', 'is_view', 'join_map', 'name', 'sql', 'sql_alias', 'sql_table'],
90+
expected: [
91+
'filters',
92+
'is_calendar',
93+
'is_view',
94+
'join_map',
95+
'name',
96+
'sql',
97+
'sql_alias',
98+
'sql_table',
99+
],
91100
},
92101
{
93102
name: 'cubeEvaluator',
@@ -215,6 +224,10 @@ const BRIDGES: BridgeSpec[] = [
215224
{ name: 'sqlUtils', expected: [] },
216225
{ name: 'structWithSqlMember', expected: ['sql'] },
217226
{ name: 'timeShiftDefinition', expected: ['interval', 'name', 'sql', 'timeshift_type'] },
227+
{
228+
name: 'viewFilterDefinition',
229+
expected: ['member_reference', 'operator', 'unless_references', 'values_references'],
230+
},
218231
];
219232

220233
const describeBridge = bridgeHarnessAvailable ? describe : describe.skip;

packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PreAggregationDefinition,
1414
PreAggregationDefinitionRollup,
1515
type ToString,
16+
ViewDefaultValueFilter,
1617
ViewIncludedMember
1718
} from './CubeSymbols';
1819
import { UserError } from './UserError';
@@ -147,6 +148,7 @@ export type EvaluatedCube = {
147148
accessPolicy?: AccessPolicyDefinition[];
148149
isView?: boolean;
149150
includedMembers?: ViewIncludedMember[];
151+
filters?: ViewDefaultValueFilter[];
150152
};
151153

152154
export class CubeEvaluator extends CubeSymbols {
@@ -207,10 +209,87 @@ export class CubeEvaluator extends CubeSymbols {
207209
this.prepareFolders(cube, errorReporter);
208210

209211
this.prepareAccessPolicy(cube, errorReporter);
212+
this.prepareViewFilters(cube, errorReporter);
210213

211214
return cube;
212215
}
213216

217+
private prepareViewFilters(cube: any, errorReporter: ErrorReporter) {
218+
if (!cube.filters) {
219+
return;
220+
}
221+
222+
const included = (cube.includedMembers as ViewIncludedMember[] | undefined) || [];
223+
224+
// Always returns view-scoped path `<view>.<member>` to match
225+
// MemberSymbol::full_name on the Rust side, which is what `unless` and
226+
// filter dispatch compare against.
227+
const resolveViewMember = (memberType: string, reference: string): string | null => {
228+
let lookupName = reference;
229+
let lookupPath: string | null = null;
230+
231+
if (reference.indexOf('.') !== -1) {
232+
const parts = reference.split('.');
233+
if (parts[0] === cube.name) {
234+
lookupName = parts.slice(1).join('.');
235+
} else {
236+
lookupPath = reference;
237+
}
238+
}
239+
240+
const match = lookupPath
241+
? included.find((m) => m.memberPath === lookupPath)
242+
: included.find((m) => m.name === lookupName);
243+
244+
if (!match) {
245+
errorReporter.error(
246+
`Member '${reference}' used as ${memberType} in default value filter is not included in view '${cube.name}'`
247+
);
248+
return null;
249+
}
250+
return `${cube.name}.${match.name}`;
251+
};
252+
253+
for (const filter of cube.filters as ViewDefaultValueFilter[]) {
254+
const rawMember = this.evaluateReferences(cube.name, filter.member);
255+
const resolved = resolveViewMember('member', rawMember);
256+
if (resolved !== null) {
257+
filter.memberReference = resolved;
258+
}
259+
260+
if (filter.values) {
261+
const evaluated = filter.values();
262+
if (!Array.isArray(evaluated)) {
263+
errorReporter.error(
264+
`'values' in default value filter for view '${cube.name}' must evaluate to an array, got: ${typeof evaluated}`
265+
);
266+
} else {
267+
// Coerce to strings to match the FilterItem.values contract used by
268+
// regular query filters (Option<Vec<Option<String>>> on the Rust side).
269+
filter.valuesReferences = evaluated.map(
270+
(v) => (v === null || v === undefined ? null : String(v))
271+
);
272+
}
273+
}
274+
275+
if (filter.unless) {
276+
const rawUnless = this.evaluateReferences(
277+
cube.name,
278+
filter.unless,
279+
{ originalSorting: true }
280+
);
281+
const resolvedUnless: string[] = [];
282+
for (const ref of rawUnless) {
283+
const r = resolveViewMember('unless', ref);
284+
if (r !== null) {
285+
resolvedUnless.push(r);
286+
}
287+
}
288+
filter.unlessReferences = resolvedUnless;
289+
}
290+
}
291+
}
292+
214293
private allMembersOrList(cube: any, specifier: string | string[]): string[] {
215294
const types = ['measures', 'dimensions', 'segments'];
216295
if (specifier === '*') {

packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ export type AccessPolicyDefinition = {
148148
}[]
149149
};
150150

151+
export type ViewDefaultValueFilter = {
152+
member: (...args: Array<unknown>) => ToString;
153+
memberReference?: string;
154+
operator: string;
155+
values?: (...args: Array<unknown>) => Array<unknown>;
156+
valuesReferences?: Array<unknown>;
157+
unless?: (...args: Array<unknown>) => Array<ToString>;
158+
unlessReferences?: string[];
159+
};
160+
151161
export type ViewIncludedMember = {
152162
type: string;
153163
memberPath: string;
@@ -216,6 +226,7 @@ export interface CubeDefinition {
216226
isView?: boolean;
217227
viewGroup?: string | ((...args: any[]) => any);
218228
viewGroups?: string[] | ((...args: any[]) => any);
229+
filters?: ViewDefaultValueFilter[];
219230
calendar?: boolean;
220231
isSplitView?: boolean;
221232
includedMembers?: ViewIncludedMember[];

packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,41 @@ const folderSchema = Joi.object().keys({
11301130
]).required(),
11311131
}).id('folderSchema');
11321132

1133+
const ViewFilterSchema = Joi.object().keys({
1134+
member: Joi.func().required(),
1135+
operator: Joi.any().valid(
1136+
'equals',
1137+
'notEquals',
1138+
'contains',
1139+
'notContains',
1140+
'startsWith',
1141+
'notStartsWith',
1142+
'endsWith',
1143+
'notEndsWith',
1144+
'in',
1145+
'notIn',
1146+
'gt',
1147+
'gte',
1148+
'lt',
1149+
'lte',
1150+
'set',
1151+
'notSet',
1152+
'inDateRange',
1153+
'notInDateRange',
1154+
'onTheDate',
1155+
'beforeDate',
1156+
'beforeOrOnDate',
1157+
'afterDate',
1158+
'afterOrOnDate',
1159+
).required(),
1160+
values: Joi.when('operator', {
1161+
is: Joi.valid('set', 'notSet'),
1162+
then: Joi.func().optional(),
1163+
otherwise: Joi.func().required()
1164+
}),
1165+
unless: Joi.func(),
1166+
});
1167+
11331168
const viewSchema = inherit(baseSchema, {
11341169
isView: Joi.boolean().strict(),
11351170
viewGroup: Joi.alternatives([Joi.string(), Joi.func()]),
@@ -1160,6 +1195,7 @@ const viewSchema = inherit(baseSchema, {
11601195
})
11611196
),
11621197
folders: Joi.array().items(folderSchema),
1198+
filters: Joi.array().items(ViewFilterSchema),
11631199
});
11641200

11651201
function formatErrorMessageFromDetails(explain, d) {

packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,27 @@ export class YamlCompiler {
187187
for (const p of transpiledFieldsPatterns) {
188188
const fullPath = propertyPath.join('.');
189189
if (fullPath.match(p)) {
190+
// View default filter `member` / `unless` are member references in
191+
// the view's own namespace — not Python expressions — so they go
192+
// through the same f-string path as `values`. The view's
193+
// `includedMembers` are not resolvable at transpile time, so
194+
// running them through the Python parser would treat the name
195+
// as an undefined identifier.
196+
const isViewFilterMember = /^filters\.\d+\.member$/.test(fullPath);
197+
const isViewFilterUnless = /^filters\.\d+\.unless$/.test(fullPath);
190198
if (typeof obj === 'string' && ['sql', 'sqlTable'].includes(propertyPath[propertyPath.length - 1])) {
191199
return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport);
200+
} else if (typeof obj === 'string' && isViewFilterMember) {
201+
return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport);
192202
} else if (typeof obj === 'string') {
193203
return this.parsePythonIntoArrowFunction(obj, cubeName, obj, errorsReport);
194204
} else if (Array.isArray(obj)) {
205+
const treatAsLiteral =
206+
propertyPath[propertyPath.length - 1] === 'values' || isViewFilterUnless;
195207
const resultAst = t.program([t.expressionStatement(t.arrayExpression(obj.map(code => {
196208
let ast: t.Program | t.NullLiteral | t.BooleanLiteral | t.NumericLiteral | null = null;
197209
// Special case for accessPolicy.rowLevel.filter.values and other values-like fields
198-
if (propertyPath[propertyPath.length - 1] === 'values') {
210+
if (treatAsLiteral) {
199211
if (typeof code === 'string') {
200212
ast = this.parsePythonAndTranspileToJs(`f"${this.escapeDoubleQuotes(code)}"`, errorsReport);
201213
} else if (typeof code === 'boolean') {

packages/cubejs-schema-compiler/src/compiler/transpilers/CubePropContextTranspiler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
3737
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.member$/,
3838
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.values$/,
3939
/^(accessPolicy|access_policy)\.[0-9]+\.conditions.[0-9]+\.if$/,
40+
/^filters\.[0-9]+\.member$/,
41+
/^filters\.[0-9]+\.values$/,
42+
/^filters\.[0-9]+\.unless$/,
4043
/^(measures|dimensions)\.[_a-zA-Z][_a-zA-Z0-9]*\.mask\.sql$/,
4144
];
4245

0 commit comments

Comments
 (0)