Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cubejs-backend-native/src/bridge_test_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ use cubesqlplanner::cube_bridge::{
timeshift_definition::{
time_shift_definition_bridge_fields_meta, NativeTimeShiftDefinition, TimeShiftDefinition,
},
view_filter_definition::{
view_filter_definition_bridge_fields_meta, NativeViewFilterDefinition,
},
};
use neon::prelude::*;
use std::any::Any;
Expand Down Expand Up @@ -451,6 +454,7 @@ bridge_registry! {
"sqlUtils" => NativeSqlUtils, sql_utils_bridge_fields_meta, invoke_sql_utils;
"structWithSqlMember" => NativeStructWithSqlMember, struct_with_sql_member_bridge_fields_meta, invoke_struct_with_sql_member;
"timeShiftDefinition" => NativeTimeShiftDefinition, time_shift_definition_bridge_fields_meta, invoke_time_shift_definition;
"viewFilterDefinition" => NativeViewFilterDefinition, view_filter_definition_bridge_fields_meta, invoke_view_filter_definition;
}

fn list_bridge_fields_inner<IT: InnerTypes>(
Expand Down Expand Up @@ -708,10 +712,17 @@ fn invoke_time_shift_definition<IT: InnerTypes>(b: &NativeTimeShiftDefinition<IT
r
}

fn invoke_view_filter_definition<IT: InnerTypes>(
_b: &NativeViewFilterDefinition<IT>,
) -> InvokeResult {
InvokeResult::new()
}

fn invoke_cube_definition<IT: InnerTypes>(b: &NativeCubeDefinition<IT>) -> InvokeResult {
let mut r = InvokeResult::new();
r.record("sql_table", b.sql_table());
r.record("sql", b.sql());
r.record("filters", b.filters());
r
}

Expand Down
11 changes: 11 additions & 0 deletions packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,20 @@ export const preAggregationDescriptionFixture = (): unknown => ({
// measure_references, dimension_references, etc — all optional getters
});

export const viewFilterDefinitionFixture = (): unknown => ({
operator: 'equals',
memberReference: 'orders.currency',
// Values are stringified by CubeEvaluator.prepareViewFilters before reaching
// Tesseract; nulls are kept to exercise the Option<Vec<Option<String>>> shape.
valuesReferences: ['USD', null],
unlessReferences: ['orders.currency'],
});

export const cubeDefinitionFixture = (): unknown => ({
name: 'Orders',
// sqlAlias, isView, isCalendar, joinMap optional
// sql_table, sql optional getters
filters: [viewFilterDefinitionFixture()],
});

export const dimensionDefinitionFixture = (): unknown => ({
Expand Down Expand Up @@ -270,4 +280,5 @@ export const FIXTURES: Record<string, BridgeFixtureFactory> = {
sqlUtils: sqlUtilsFixture,
structWithSqlMember: structWithSqlMemberFixture,
timeShiftDefinition: timeShiftDefinitionFixture,
viewFilterDefinition: viewFilterDefinitionFixture,
};
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,16 @@ const BRIDGES: BridgeSpec[] = [
{ name: 'caseSwitchItem', expected: ['sql', 'value'] },
{
name: 'cubeDefinition',
expected: ['is_calendar', 'is_view', 'join_map', 'name', 'sql', 'sql_alias', 'sql_table'],
expected: [
'filters',
'is_calendar',
'is_view',
'join_map',
'name',
'sql',
'sql_alias',
'sql_table',
],
},
{
name: 'cubeEvaluator',
Expand Down Expand Up @@ -215,6 +224,10 @@ const BRIDGES: BridgeSpec[] = [
{ name: 'sqlUtils', expected: [] },
{ name: 'structWithSqlMember', expected: ['sql'] },
{ name: 'timeShiftDefinition', expected: ['interval', 'name', 'sql', 'timeshift_type'] },
{
name: 'viewFilterDefinition',
expected: ['member_reference', 'operator', 'unless_references', 'values_references'],
},
];

const describeBridge = bridgeHarnessAvailable ? describe : describe.skip;
Expand Down
79 changes: 79 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PreAggregationDefinition,
PreAggregationDefinitionRollup,
type ToString,
ViewDefaultValueFilter,
ViewIncludedMember
} from './CubeSymbols';
import { UserError } from './UserError';
Expand Down Expand Up @@ -147,6 +148,7 @@ export type EvaluatedCube = {
accessPolicy?: AccessPolicyDefinition[];
isView?: boolean;
includedMembers?: ViewIncludedMember[];
filters?: ViewDefaultValueFilter[];
};

export class CubeEvaluator extends CubeSymbols {
Expand Down Expand Up @@ -207,10 +209,87 @@ export class CubeEvaluator extends CubeSymbols {
this.prepareFolders(cube, errorReporter);

this.prepareAccessPolicy(cube, errorReporter);
this.prepareViewFilters(cube, errorReporter);

return cube;
}

private prepareViewFilters(cube: any, errorReporter: ErrorReporter) {
if (!cube.filters) {
return;
}
Comment on lines +217 to +220
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.

Nit (defensive): prepareViewFilters relies on !cube.filters to skip regular cubes. The Joi schema already prevents filters from appearing on non-view cubes, so this works in practice. But since prepareViewFilters accesses cube.includedMembers (which is undefined on regular cubes), an explicit isView guard here would make the contract clearer and more robust against future schema changes:

Suggested change
private prepareViewFilters(cube: any, errorReporter: ErrorReporter) {
if (!cube.filters) {
return;
}
private prepareViewFilters(cube: any, errorReporter: ErrorReporter) {
if (!cube.isView || !cube.filters) {
return;
}


const included = (cube.includedMembers as ViewIncludedMember[] | undefined) || [];

// Always returns view-scoped path `<view>.<member>` to match
// MemberSymbol::full_name on the Rust side, which is what `unless` and
// filter dispatch compare against.
const resolveViewMember = (memberType: string, reference: string): string | null => {
let lookupName = reference;
let lookupPath: string | null = null;

if (reference.indexOf('.') !== -1) {
const parts = reference.split('.');
if (parts[0] === cube.name) {
lookupName = parts.slice(1).join('.');
} else {
lookupPath = reference;
}
}

const match = lookupPath
? included.find((m) => m.memberPath === lookupPath)
: included.find((m) => m.name === lookupName);

if (!match) {
errorReporter.error(
`Member '${reference}' used as ${memberType} in default value filter is not included in view '${cube.name}'`
);
return null;
}
return `${cube.name}.${match.name}`;
};

for (const filter of cube.filters as ViewDefaultValueFilter[]) {
const rawMember = this.evaluateReferences(cube.name, filter.member);
const resolved = resolveViewMember('member', rawMember);
if (resolved !== null) {
filter.memberReference = resolved;
}

if (filter.values) {
const evaluated = filter.values();
if (!Array.isArray(evaluated)) {
errorReporter.error(
`'values' in default value filter for view '${cube.name}' must evaluate to an array, got: ${typeof evaluated}`
);
} else {
// Coerce to strings to match the FilterItem.values contract used by
// regular query filters (Option<Vec<Option<String>>> on the Rust side).
filter.valuesReferences = evaluated.map(
(v) => (v === null || v === undefined ? null : String(v))
);
}
}

if (filter.unless) {
const rawUnless = this.evaluateReferences(
cube.name,
filter.unless,
{ originalSorting: true }
);
const resolvedUnless: string[] = [];
for (const ref of rawUnless) {
const r = resolveViewMember('unless', ref);
if (r !== null) {
resolvedUnless.push(r);
}
}
filter.unlessReferences = resolvedUnless;
}
}
}

private allMembersOrList(cube: any, specifier: string | string[]): string[] {
const types = ['measures', 'dimensions', 'segments'];
if (specifier === '*') {
Expand Down
11 changes: 11 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ export type AccessPolicyDefinition = {
}[]
};

export type ViewDefaultValueFilter = {
member: (...args: Array<unknown>) => ToString;
memberReference?: string;
operator: string;
values?: (...args: Array<unknown>) => Array<unknown>;
valuesReferences?: Array<unknown>;
unless?: (...args: Array<unknown>) => Array<ToString>;
unlessReferences?: string[];
};

export type ViewIncludedMember = {
type: string;
memberPath: string;
Expand Down Expand Up @@ -216,6 +226,7 @@ export interface CubeDefinition {
isView?: boolean;
viewGroup?: string | ((...args: any[]) => any);
viewGroups?: string[] | ((...args: any[]) => any);
filters?: ViewDefaultValueFilter[];
calendar?: boolean;
isSplitView?: boolean;
includedMembers?: ViewIncludedMember[];
Expand Down
36 changes: 36 additions & 0 deletions packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,41 @@ const folderSchema = Joi.object().keys({
]).required(),
}).id('folderSchema');

const ViewFilterSchema = Joi.object().keys({
member: Joi.func().required(),
operator: Joi.any().valid(
'equals',
'notEquals',
'contains',
'notContains',
'startsWith',
'notStartsWith',
'endsWith',
'notEndsWith',
'in',
'notIn',
'gt',
'gte',
'lt',
'lte',
'set',
'notSet',
'inDateRange',
'notInDateRange',
'onTheDate',
'beforeDate',
'beforeOrOnDate',
'afterDate',
'afterOrOnDate',
).required(),
values: Joi.when('operator', {
is: Joi.valid('set', 'notSet'),
then: Joi.func().optional(),
otherwise: Joi.func().required()
}),
unless: Joi.func(),
});

const viewSchema = inherit(baseSchema, {
isView: Joi.boolean().strict(),
viewGroup: Joi.alternatives([Joi.string(), Joi.func()]),
Expand Down Expand Up @@ -1160,6 +1195,7 @@ const viewSchema = inherit(baseSchema, {
})
),
folders: Joi.array().items(folderSchema),
filters: Joi.array().items(ViewFilterSchema),
});

function formatErrorMessageFromDetails(explain, d) {
Expand Down
14 changes: 13 additions & 1 deletion packages/cubejs-schema-compiler/src/compiler/YamlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,27 @@ export class YamlCompiler {
for (const p of transpiledFieldsPatterns) {
const fullPath = propertyPath.join('.');
if (fullPath.match(p)) {
// View default filter `member` / `unless` are member references in
// the view's own namespace — not Python expressions — so they go
// through the same f-string path as `values`. The view's
// `includedMembers` are not resolvable at transpile time, so
// running them through the Python parser would treat the name
// as an undefined identifier.
const isViewFilterMember = /^filters\.\d+\.member$/.test(fullPath);
const isViewFilterUnless = /^filters\.\d+\.unless$/.test(fullPath);
if (typeof obj === 'string' && ['sql', 'sqlTable'].includes(propertyPath[propertyPath.length - 1])) {
return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport);
} else if (typeof obj === 'string' && isViewFilterMember) {
return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport);
Comment on lines +196 to +201
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.

Nit: The isViewFilterMember branch (line 199–200) duplicates the sql/sqlTable branch above it — both produce parsePythonIntoArrowFunction(f"...", ...). Consider merging them:

} else if (typeof obj === 'string' && (
  ['sql', 'sqlTable'].includes(propertyPath[propertyPath.length - 1]) ||
  isViewFilterMember
)) {
  return this.parsePythonIntoArrowFunction(`f"${this.escapeDoubleQuotes(obj)}"`, cubeName, obj, errorsReport);
}

Minor readability win — not blocking.

} else if (typeof obj === 'string') {
return this.parsePythonIntoArrowFunction(obj, cubeName, obj, errorsReport);
} else if (Array.isArray(obj)) {
const treatAsLiteral =
propertyPath[propertyPath.length - 1] === 'values' || isViewFilterUnless;
const resultAst = t.program([t.expressionStatement(t.arrayExpression(obj.map(code => {
let ast: t.Program | t.NullLiteral | t.BooleanLiteral | t.NumericLiteral | null = null;
// Special case for accessPolicy.rowLevel.filter.values and other values-like fields
if (propertyPath[propertyPath.length - 1] === 'values') {
if (treatAsLiteral) {
if (typeof code === 'string') {
ast = this.parsePythonAndTranspileToJs(`f"${this.escapeDoubleQuotes(code)}"`, errorsReport);
} else if (typeof code === 'boolean') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export const transpiledFieldsPatterns: Array<RegExp> = [
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.member$/,
/^(accessPolicy|access_policy)\.[0-9]+\.(rowLevel|row_level)\.filters\.[0-9]+.*\.values$/,
/^(accessPolicy|access_policy)\.[0-9]+\.conditions.[0-9]+\.if$/,
/^filters\.[0-9]+\.member$/,
/^filters\.[0-9]+\.values$/,
/^filters\.[0-9]+\.unless$/,
/^(measures|dimensions)\.[_a-zA-Z][_a-zA-Z0-9]*\.mask\.sql$/,
];

Expand Down
Loading
Loading