Skip to content
Merged
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
64 changes: 63 additions & 1 deletion docs-mintlify/reference/data-modeling/context-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,66 @@ cube(`multi_filter`, {
})
```

### Example with segment

A `FILTER_PARAMS` argument can also name a [segment][ref-ref-segments]. A segment
is not compared to a value, so the expression passed to `filter()` is the whole
predicate rather than a column: it is rendered as-is when the query selects that
segment, and as `1 = 1` when it does not.

<CodeGroup>

```yaml title="YAML"
cubes:
- name: events
sql: |
SELECT *
FROM events
WHERE {FILTER_PARAMS.events.start_load.filter(
"evid = 115 AND action_group = 'load'"
)}

segments:
- name: start_load
sql: "{CUBE}.evid = 115 AND {CUBE}.action_group = 'load'"
```

```javascript title="JavaScript"
cube(`events`, {
sql: `
SELECT *
FROM events
WHERE ${FILTER_PARAMS.events.start_load.filter(
`evid = 115 AND action_group = 'load'`
)}
`,

segments: {
start_load: {
sql: `${CUBE}.evid = 115 AND ${CUBE}.action_group = 'load'`
}
}
})
```

</CodeGroup>

The segment's own `sql` prefixes its columns with the cube, which is not in
scope inside the `sql` that builds that cube, so the pushed-down predicate is
restated in `filter()` — the same way a dimension's column is.

A function may be passed instead of a string, as long as it takes no arguments:
a segment carries no filter values to pass to it. A function that does take
arguments renders as `1 = 1`.

<Info>

Naming a segment is only supported by the default SQL planner. With
[`CUBEJS_TESSERACT_SQL_PLANNER`][ref-env-tesseract] set to `false`, such a
binding always renders as `1 = 1`.

</Info>

## `FILTER_GROUP`

If you use `FILTER_PARAMS` in your query more than once, you must wrap them
Expand Down Expand Up @@ -817,4 +877,6 @@ cube(`orders`, {
[ref-query-filter]: /reference/core-data-apis/rest-api/query-format#query-properties
[ref-dynamic-jinja]: /docs/data-modeling/dynamic/jinja
[ref-filter-boolean]: /reference/core-data-apis/rest-api/query-format#boolean-logical-operators
[ref-links]: /reference/data-modeling/dimensions#links
[ref-links]: /reference/data-modeling/dimensions#links
[ref-ref-segments]: /reference/data-modeling/segments
[ref-env-tesseract]: /reference/configuration/environment-variables#cubejs_tesseract_sql_planner
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { getEnv } from '@cubejs-backend/shared';
import { PostgresQuery } from '../../../src/adapter/PostgresQuery';
import { prepareJsCompiler } from '../../unit/PrepareCompiler';
import { dbRunner } from './PostgresDBRunner';

// A cube wrapping a large scan pushes predicates into its own `sql` through
// FILTER_PARAMS. A segment can be named there like any other member: its
// binding renders the column it was given whenever the query selects that
// segment, and `1 = 1` when it does not.
describe('FILTER_PARAMS referencing a segment', () => {
jest.setTimeout(200000);

const events = `
SELECT * FROM (
SELECT 1 as id, 115 as evid, 'load' as action_group, 'us' as region
union all
SELECT 2 as id, 115 as evid, 'load' as action_group, 'eu' as region
union all
SELECT 3 as id, 200 as evid, 'click' as action_group, 'us' as region
) AS t
`;

const compilers = prepareJsCompiler(`
cube('events', {
sql: \`${events} WHERE \${FILTER_PARAMS.events.start_load.filter("evid = 115 AND action_group = 'load'")}
AND \${FILTER_PARAMS.events.region.filter('region')}\`,
measures: {
count: { type: 'count' },
},
dimensions: {
id: { sql: 'id', type: 'number', primaryKey: true },
region: { sql: 'region', type: 'string' },
},
segments: {
start_load: { sql: \`\${CUBE}.evid = 115 AND \${CUBE}.action_group = 'load'\` },
us_only: { sql: \`\${CUBE}.region = 'us'\` },
},
});

cube('grouped_events', {
sql: \`${events} WHERE \${FILTER_GROUP(
FILTER_PARAMS.grouped_events.start_load.filter("evid = 115 AND action_group = 'load'"),
FILTER_PARAMS.grouped_events.region.filter('region')
)}\`,
measures: {
count: { type: 'count' },
},
dimensions: {
id: { sql: 'id', type: 'number', primaryKey: true },
region: { sql: 'region', type: 'string' },
},
segments: {
start_load: { sql: \`\${CUBE}.evid = 115 AND \${CUBE}.action_group = 'load'\` },
},
});

cube('callback_events', {
sql: \`${events} WHERE \${FILTER_PARAMS.callback_events.start_load.filter(() => "evid = 115")}
AND \${FILTER_PARAMS.callback_events.needs_value.filter((v) => 'evid = ' + v)}\`,
measures: {
count: { type: 'count' },
},
dimensions: {
id: { sql: 'id', type: 'number', primaryKey: true },
},
segments: {
start_load: { sql: \`\${CUBE}.evid = 115\` },
needs_value: { sql: \`\${CUBE}.evid = 115\` },
},
});
`);

// The cube's `sql` is a subquery aliased as the cube, so everything before
// that alias is what the pushdown produced.
const baseSql = (sql: string, cube: string) => {
const alias = sql.indexOf(`AS "${cube}"`);
// Without the alias the slice would be the whole query, and the negative
// assertions would silently stop testing the pushed-down part.
expect(alias).toBeGreaterThan(-1);
return sql.slice(0, alias);
};

const buildSql = async (query: any) => {
await compilers.compiler.compile();
return new PostgresQuery(compilers, { timezone: 'UTC', ...query }).buildSqlAndParams()[0];
};

if (getEnv('nativeSqlPlanner')) {
it('pushes the segment predicate into the cube sql when the segment is selected', async () => {
const sql = await buildSql({
measures: ['events.count'],
segments: ['events.start_load'],
});

expect(baseSql(sql, 'events')).toMatch(/evid = 115 AND action_group = 'load'/);
});

it('leaves the binding always-true when the segment is not selected', async () => {
const sql = await buildSql({ measures: ['events.count'] });

expect(baseSql(sql, 'events')).not.toMatch(/evid = 115/);
expect(baseSql(sql, 'events')).toMatch(/1\s*=\s*1/);
});

it('does not activate the binding for a different segment', async () => {
const sql = await buildSql({
measures: ['events.count'],
segments: ['events.us_only'],
});

expect(baseSql(sql, 'events')).not.toMatch(/evid = 115/);
});

it('pushes the segment down alongside a dimension filter', async () => {
const sql = await buildSql({
measures: ['events.count'],
segments: ['events.start_load'],
filters: [{ member: 'events.region', operator: 'equals', values: ['us'] }],
});

const base = baseSql(sql, 'events');
expect(base).toMatch(/evid = 115 AND action_group = 'load'/);
expect(base).toMatch(/region = \$\d/);
expect(base).not.toMatch(/1\s*=\s*1/);
});

it('renders the segment as one member of a FILTER_GROUP', async () => {
const sql = await buildSql({
measures: ['grouped_events.count'],
segments: ['grouped_events.start_load'],
});

expect(baseSql(sql, 'grouped_events')).toMatch(/evid = 115 AND action_group = 'load'/);
});

it('renders a callback column that takes no filter values', async () => {
const sql = await buildSql({
measures: ['callback_events.count'],
segments: ['callback_events.start_load'],
});

// Nothing else in this cube's sql states the predicate, so it can only
// have come from the callback the binding compiled.
expect(baseSql(sql, 'callback_events')).toMatch(/WHERE \(evid = 115\)/);
});

// A segment supplies no values, so a column that takes one cannot render.
// Dropping only its restatement is narrower than binding a value the
// segment never gave — the segment still filters the query on its own.
it('leaves a value-taking callback column always-true', async () => {
const sql = await buildSql({
measures: ['callback_events.count'],
segments: ['callback_events.needs_value'],
});

// The parentheses are what the filter renderer adds around a binding it
// reached, so they tell an activated-then-dropped column apart from the
// bare `1 = 1` of a binding whose segment was never selected.
expect(baseSql(sql, 'callback_events')).toMatch(/AND \(1\s*=\s*1\)/);
expect(baseSql(sql, 'callback_events')).not.toMatch(/undefined|\{fpv:/);
});

// The predicate now applies both inside the cube's sql and in the outer
// WHERE the segment always produced. Both restrict the same rows, so the
// result must be what the segment alone selected.
it('counts the segment rows once', async () => dbRunner.runQueryTest({
measures: ['events.count'],
segments: ['events.start_load'],
timezone: 'UTC',
}, [
{ events__count: '2' },
], compilers));

it('counts every row when no segment is selected', async () => dbRunner.runQueryTest({
measures: ['events.count'],
timezone: 'UTC',
}, [
{ events__count: '3' },
], compilers));
} else {
// Segment pushdown is implemented in the Tesseract planner only.
test.skip('FILTER_PARAMS referencing a segment', () => { expect(1).toBe(1); });
}
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use super::ToSql;
use crate::cube_bridge::member_sql::FilterParamsColumn;
use crate::physical_plan::sql_nodes::SqlNode;
use crate::physical_plan::SqlEvaluatorVisitor;
use crate::planner::filter::BaseSegment;
use crate::planner::query_tools::QueryTools;
use crate::planner::sql_call::SqlCallFilterParamsItem;
use crate::planner::sql_templates::PlanSqlTemplates;
use crate::planner::FiltersContext;
use cubenativeutils::CubeError;
Expand All @@ -13,10 +15,20 @@ impl ToSql for BaseSegment {
&self,
visitor: &SqlEvaluatorVisitor,
node_processor: Rc<dyn SqlNode>,
_query_tools: Rc<QueryTools>,
query_tools: Rc<QueryTools>,
templates: &PlanSqlTemplates,
filters_ctx: &FiltersContext,
) -> Result<String, CubeError> {
if let Some(item) = self.matching_filter_params_column(filters_ctx) {
return self.filter_params_column_sql(
item,
visitor,
node_processor,
query_tools,
templates,
);
}

let sql = visitor.apply(&self.member_evaluator(), node_processor, templates)?;
if filters_ctx.reading_pre_aggregation {
// The segment is a stored pre-aggregation column; compare it to its
Expand All @@ -27,3 +39,57 @@ impl ToSql for BaseSegment {
}
}
}

impl BaseSegment {
// The binding named by the path the query asked for is the one the model
// meant. A view re-exporting a segment leaves only the underlying cube's
// path to match, which takes a scan; the name breaks the tie when a group
// binds both paths, since the map is unordered.
fn matching_filter_params_column<'a>(
&self,
filters_ctx: &'a FiltersContext,
) -> Option<&'a SqlCallFilterParamsItem> {
if let Some(item) = filters_ctx.filter_params_columns.get(&self.full_name()) {
return Some(item);
}
filters_ctx
.filter_params_columns
.iter()
.filter(|(name, _)| self.matches_member_name(name))
.min_by_key(|(name, _)| *name)
.map(|(_, item)| item)
}

// A segment restricts the rows without comparing anything to a filter value,
// so its `FILTER_PARAMS` column is the whole predicate rather than the left
// side of one.
fn filter_params_column_sql(
&self,
item: &SqlCallFilterParamsItem,
visitor: &SqlEvaluatorVisitor,
node_processor: Rc<dyn SqlNode>,
query_tools: Rc<QueryTools>,
templates: &PlanSqlTemplates,
) -> Result<String, CubeError> {
match &item.column {
FilterParamsColumn::String(column_sql) => Ok(column_sql.clone()),
FilterParamsColumn::Compiled(compiled) if compiled.value_params_count == 0 => {
let Some(call) = &item.compiled_call else {
return Err(CubeError::internal(format!(
"Compiled filter params column for `{}` has no call",
item.filter_symbol_name
)));
};
call.eval(visitor, node_processor, query_tools, templates)
}
// A column that takes filter values cannot render for a segment,
// which supplies none — a rest parameter consumes as many as the
// query happens to give, and a parameter list that could not be read
// is assumed to take some. Only the restatement inside this SQL is
// dropped; the segment still reaches the query on its own.
FilterParamsColumn::Compiled(_) | FilterParamsColumn::Callback(_) => {
templates.always_true()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,36 @@ impl BaseSegment {
pub fn is_member_expression(&self) -> bool {
self.is_member_expression
}

/// Whether `member` names this segment, as a `FILTER_PARAMS` binding or a
/// filter-tree target does. A view exposes a segment under its own path
/// while a binding in the underlying cube's sql names the cube's, so every
/// segment in the reference chain counts, not only the name the query asked
/// for. The chain stops at the first non-segment: a segment whose sql is a
/// bare reference resolves on to that dimension, whose own binding states a
/// column to compare a value against rather than a predicate.
pub fn matches_member_name(&self, member: &str) -> bool {
if self.is_member_expression {
return false;
}
if self.full_name == member {
return true;
}
let mut current = Some(self.member_evaluator.clone());
while let Some(symbol) = current {
if symbol.as_member_expression().is_err() {
return false;
}
// A segment symbol lives in the `expr:` namespace, so the path is
// reassembled from the cube and member names it was compiled under.
if format!("{}.{}", symbol.cube_name(), symbol.name()) == member {
return true;
}
current = symbol.reference_member();
}
false
}

pub fn full_name(&self) -> String {
self.full_name.clone()
}
Expand Down
Loading
Loading