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
5 changes: 3 additions & 2 deletions packages/cubejs-schema-compiler/src/adapter/OracleQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class OracleFilter extends BaseFilter {
public likeIgnoreCase(column, not, param, type) {
const p = (!type || type === 'contains' || type === 'ends') ? '\'%\' || ' : '';
const s = (!type || type === 'contains' || type === 'starts') ? ' || \'%\'' : '';
return `${column}${not ? ' NOT' : ''} LIKE ${p}${this.allocateParam(param)}${s}`;
return `${column}${not ? ' NOT' : ''} LIKE ${p}${this.allocateParam(param)}${s} ESCAPE '\\'`;
}
}

Expand Down Expand Up @@ -249,8 +249,9 @@ export class OracleQuery extends BaseQuery {
'{% endfor %}' +
') dates';

templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}';
delete templates.expressions.ilike;
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }})';
templates.tesseract.ilike = 'LOWER({{ expr }}) {% if negated %}NOT {% endif %}LIKE LOWER({{ pattern }}){% if default_escape %} ESCAPE \'\\\'{% endif %}';

// Oracle has no `STRING` type (used by the default in CAST(... AS STRING),
// e.g. the multi-column count() concatenation). CAST to VARCHAR2 requires a
Expand Down
14 changes: 14 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/base-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,20 @@ describe('SQL Generation', () => {
);
});

it('CORE-541: measure filter casts bound param - bigquery tesseract planner', async () => {
await compilers.compiler.compile();

const query = new BigqueryQuery(compilers, {
measures: ['cards.count'],
filters: [
{ member: 'cards.count', operator: 'gt', values: ['10'] }
],
useNativeSqlPlanner: true,
});
const [sql] = query.buildSqlAndParams();
expect(sql).toContain('CAST(? AS FLOAT64)');
});

it('Test time series with different granularity - postgres', async () => {
await compilers.compiler.compile();

Expand Down
92 changes: 91 additions & 1 deletion packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2192,7 +2192,8 @@ cubes:
const [sql] = query.buildSqlAndParams();
expect(sql).not.toMatch(/CASE\s+WHEN/);
expect(sql).not.toMatch(/owner_id\s*=/);
expect(sql).toMatch(/-1\s+"transactions__total_cost"/);
// The Tesseract planner parenthesizes the masked literal, e.g. `(-1)`.
expect(sql).toMatch(/\(?-1\)?\s+"transactions__total_cost"/);
});

it('still applies conditional CASE WHEN masking for aggregate measures when the filter member is in the group by', async () => {
Expand Down Expand Up @@ -2236,6 +2237,48 @@ cubes:
expect(sql).toMatch(/owner_id/);
});

it('tesseract: still applies conditional CASE WHEN masking for aggregate measures when the filter member is in the group by', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: transactions
sql_table: public.transactions
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: org_id
sql: org_id
type: string
- name: owner_id
sql: owner_id
type: string
measures:
- name: total_cost
sql: cost
type: sum
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['transactions.total_cost'],
dimensions: ['transactions.org_id', 'transactions.owner_id'],
maskedMembers: [{
member: 'transactions.total_cost',
filter: {
member: 'transactions.owner_id',
operator: 'equals',
values: ['42'],
}
}],
useNativeSqlPlanner: true,
});
const [sql] = query.buildSqlAndParams();
expect(sql).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/owner_id/);
});

it('does not recurse when filter member is also masked', async () => {
const compilers = prepareYamlCompiler(`
cubes:
Expand Down Expand Up @@ -2279,6 +2322,53 @@ cubes:
expect(sql).toMatch(/product_id/);
expect(sql).not.toMatch(/Maximum call stack/);
});

it('tesseract: does not recurse when filter member is also masked', async () => {
const compilers = prepareYamlCompiler(`
cubes:
- name: items
sql_table: public.items
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: product_id
sql: product_id
type: number
- name: price
sql: price
type: number
mask: -1
measures:
- name: count
type: count
`);

await compilers.compiler.compile();

const query = new PostgresQuery(compilers, {
measures: ['items.count'],
dimensions: ['items.product_id', 'items.price'],
maskedMembers: [
{
member: 'items.product_id',
filter: { member: 'items.product_id', operator: 'lte', values: ['3'] }
},
{
member: 'items.price',
filter: { member: 'items.product_id', operator: 'lte', values: ['3'] }
},
],
useNativeSqlPlanner: true,
});
// The filter member (product_id) is itself masked; rendering the mask filter
// through the "skip"-masking unmasked tree must not recurse back into masking.
const [sql] = query.buildSqlAndParams();
expect(sql).toMatch(/CASE\s+WHEN/);
expect(sql).toMatch(/product_id/);
expect(sql).not.toMatch(/Maximum call stack/);
});
});

describe('Multi-stage filter directive', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8670,7 +8670,13 @@ Array [

exports[`Queries with the @cubejs-backend/oracle-driver filtering Products: contains + dimensions + order, third 1`] = `Array []`;

exports[`Queries with the @cubejs-backend/oracle-driver filtering Products: contains with special chars + dimensions 1`] = `Array []`;
exports[`Queries with the @cubejs-backend/oracle-driver filtering Products: contains with special chars + dimensions 1`] = `
Array [
Object {
"Products.productName": "Logitech di_Novo Edge Keyboard",
},
]
`;

exports[`Queries with the @cubejs-backend/oracle-driver filtering Products: endsWith filter + dimensions + order, first 1`] = `
Array [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::physical_plan::cube_ref_evaluator::CubeRefEvaluator;
use crate::physical_plan::sql_nodes::calendar_time_shift::CalendarTimeShiftSqlNode;
use crate::physical_plan::sql_nodes::RenderReferences;
use crate::planner::planners::multi_stage::TimeShiftState;
use crate::planner::query_tools::QueryTools;
use crate::planner::symbols::CalendarDimensionTimeShift;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
Expand Down Expand Up @@ -170,9 +171,34 @@ impl SqlNodesFactory {
/// multi-stage wraps → mask). The whole tree is then wrapped in
/// a top-level `RenderReferencesSqlNode` for query-wide reference
/// substitution.
pub fn default_node_processor(&self) -> Rc<dyn SqlNode> {
let evaluate_sql_processor =
MaskedSqlNode::new(EvaluateSqlNode::new(), self.group_by_members.clone());
pub fn default_node_processor(&self, query_tools: &QueryTools) -> Rc<dyn SqlNode> {
// Build an "unmasked" copy of the tree (masking disabled, but still
// dispatching by member kind) only when the query has masked members. It
// is handed to the masked nodes so they can render mask-filter member
// references through it — routing dimensions through the dimension chain
// and avoiding mask recursion. For queries with no masked members
// `MaskedSqlNode::resolve_mask` short-circuits and never dereferences the
// unmasked root, so building it would be pure waste; passing `None` falls
// back to the masked node's own input (see masked.rs).
let unmasked_root = if query_tools.has_masked_members() {
Some(self.build_node_processor(true, None))
} else {
None
};
self.build_node_processor(false, unmasked_root)
}

fn build_node_processor(
&self,
skip_masking: bool,
unmasked_root: Option<Rc<dyn SqlNode>>,
) -> Rc<dyn SqlNode> {
let evaluate_sql_processor = MaskedSqlNode::new(
EvaluateSqlNode::new(),
self.group_by_members.clone(),
skip_masking,
unmasked_root.clone(),
);
let auto_prefix_processor = AutoPrefixSqlNode::new(
evaluate_sql_processor.clone(),
self.cube_name_references.clone(),
Expand All @@ -188,9 +214,19 @@ impl SqlNodesFactory {
// Wrap the entire measure chain with MaskedSqlNode so masked measures
// are intercepted before aggregation/ungrouped wrapping.
let measure_processor = if self.ungrouped || self.ungrouped_measure {
MaskedSqlNode::new_ungrouped(measure_processor, self.group_by_members.clone())
MaskedSqlNode::new_ungrouped(
measure_processor,
self.group_by_members.clone(),
skip_masking,
unmasked_root.clone(),
)
} else {
MaskedSqlNode::new(measure_processor, self.group_by_members.clone())
MaskedSqlNode::new(
measure_processor,
self.group_by_members.clone(),
skip_masking,
unmasked_root.clone(),
)
};
let measure_processor = self
.add_multi_stage_window_if_needed(measure_processor, measure_filter_processor.clone());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,47 @@ pub struct MaskedSqlNode {
// Full names of the members present in the query GROUP BY. Used to decide
// whether conditional masking can be applied to an aggregate measure.
group_by_members: HashSet<String>,
// When true this node never applies masking and just delegates to `input`.
// Used to build an "unmasked" copy of the whole processor tree that still
// dispatches by member kind (see `unmasked_root`).
skip_masking: bool,
// A kind-dispatching processor (a full `RootSqlNode`-based tree built in
// `skip` mode) used to render mask-filter member references. It routes a
// dimension reference through the dimension chain and a measure reference
// through the measure chain, while skipping masking — so a measure mask
// filter that references a dimension is rendered correctly and a filter
// member that is itself masked does not recurse.
unmasked_root: Option<Rc<dyn SqlNode>>,
}

impl MaskedSqlNode {
pub fn new(input: Rc<dyn SqlNode>, group_by_members: HashSet<String>) -> Rc<Self> {
pub fn new(
input: Rc<dyn SqlNode>,
group_by_members: HashSet<String>,
skip_masking: bool,
unmasked_root: Option<Rc<dyn SqlNode>>,
) -> Rc<Self> {
Rc::new(Self {
input,
ungrouped: false,
group_by_members,
skip_masking,
unmasked_root,
})
}

pub fn new_ungrouped(input: Rc<dyn SqlNode>, group_by_members: HashSet<String>) -> Rc<Self> {
pub fn new_ungrouped(
input: Rc<dyn SqlNode>,
group_by_members: HashSet<String>,
skip_masking: bool,
unmasked_root: Option<Rc<dyn SqlNode>>,
) -> Rc<Self> {
Rc::new(Self {
input,
ungrouped: true,
group_by_members,
skip_masking,
unmasked_root,
})
}

Expand Down Expand Up @@ -106,12 +131,20 @@ impl MaskedSqlNode {
)?;
// TODO: support FILTER_PARAMS in mask filter SQL by passing
// proper FiltersContext with filter_params_columns.
// Use self.input as node_processor so member references inside the filter
// resolve through the unmasked chain — prevents recursion through MaskedSqlNode
// when the filter member is itself masked.
// Render the filter through the unmasked, kind-dispatching root so a
// filter member that is a dimension (e.g. an aggregate measure masked by
// a row filter on a group-by dimension) is routed through the dimension
// chain instead of the measure chain (which only accepts measures and
// would otherwise error). `skip` masking also prevents recursion when the
// filter member is itself masked. Falls back to `self.input` if no
// unmasked root was wired in.
let filter_processor = self
.unmasked_root
.clone()
.unwrap_or_else(|| self.input.clone());
let filter_sql = filter_item.to_sql(
visitor,
self.input.clone(),
filter_processor,
query_tools,
templates,
&FiltersContext::default(),
Expand All @@ -137,14 +170,16 @@ impl SqlNode for MaskedSqlNode {
node_processor: Rc<dyn SqlNode>,
templates: &PlanSqlTemplates,
) -> Result<String, CubeError> {
if let Some(masked) = self.resolve_mask(
node,
visitor,
node_processor.clone(),
query_tools.clone(),
templates,
)? {
return Ok(masked);
if !self.skip_masking {
if let Some(masked) = self.resolve_mask(
node,
visitor,
node_processor.clone(),
query_tools.clone(),
templates,
)? {
return Ok(masked);
}
}
self.input
.to_sql(visitor, node, query_tools, node_processor, templates)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ impl VisitorContext {
filter_params_columns: HashMap::new(),
reading_pre_aggregation: nodes_factory.reading_pre_aggregation(),
};
let node_processor = nodes_factory.default_node_processor(&query_tools);
Self {
query_tools,
node_processor: nodes_factory.default_node_processor(),
node_processor,
cube_ref_evaluator: Rc::new(nodes_factory.cube_ref_evaluator()),
all_filters,
filters_context,
Expand All @@ -48,9 +49,10 @@ impl VisitorContext {
filter_params_columns,
reading_pre_aggregation: nodes_factory.reading_pre_aggregation(),
};
let node_processor = nodes_factory.default_node_processor(&query_tools);
Self {
query_tools,
node_processor: nodes_factory.default_node_processor(),
node_processor,
cube_ref_evaluator: Rc::new(nodes_factory.cube_ref_evaluator()),
all_filters: None,
filters_context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,17 @@ impl TypedFilterBuilder {
let symbol = resolve_base_symbol(member_evaluator);
match symbol.as_ref() {
MemberSymbol::Dimension(d) => Some(d.dimension_type().to_string()),
MemberSymbol::Measure(m) => Some(m.measure_type().to_string()),
// The cast type drives how a bound comparison value is wrapped.
// Aggregations (count, sum, ...) and number measures compare as
// numbers. String/time measures are non-numeric scalars and must
// not be coerced to a number; date comparisons take the dedicated
// date operators instead. min/max carry their operand type, which
// isn't known here, so they fall through to the numeric default.
MemberSymbol::Measure(m) => match m.measure_type() {
"boolean" => Some("boolean".to_string()),
"string" | "time" => None,
_ => Some("number".to_string()),
},
_ => None,
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ impl QueryTools {
self.masked_members.contains(member_path)
}

/// Whether the query has any masked members at all. `masked_members` is
/// fixed at `try_new`, so this is a cheap, stable signal used to skip
/// building the unmasked reference tree for queries with no masking.
pub fn has_masked_members(&self) -> bool {
!self.masked_members.is_empty()
}

pub fn member_mask_filter(&self, member_path: &str) -> Option<FilterItem> {
self.member_mask_filters.borrow().get(member_path).cloned()
}
Expand Down
Loading
Loading