Skip to content

fix(tesseract): mask.sql with cross-cube refs through prefixed views#10727

Merged
paveltiunov merged 7 commits intomasterfrom
cursor/fix-mask-sql-through-views-43a8
Apr 23, 2026
Merged

fix(tesseract): mask.sql with cross-cube refs through prefixed views#10727
paveltiunov merged 7 commits intomasterfrom
cursor/fix-mask-sql-through-views-43a8

Conversation

@paveltiunov
Copy link
Copy Markdown
Member

Summary

When a cube member with mask.sql is re-exposed through a view (especially with prefix: true), mask references such as {CUBE.city}, {CUBE}.city, {users}.city were failing in Tesseract with errors like:

  • Error in 'CUBE.city': Cannot resolve: city
  • Can't find join path to join 'users', 'users_secure_view'
  • Member 'users_secure_view.users_city_sensitive_masked' references foreign cubes: users. Please split and move this definition to corresponding cubes.

This is the setup that was reported — a cube users with mask.sql on a dimension, exposed through users_secure_view with prefix: true:

- name: city_sensitive_masked
  sql: city
  mask:
    sql: |
      CASE
        WHEN {groups.filter("'sensitive_data_access'")}
        THEN {CUBE.city}        # or {CUBE}.city, {users}.city
        ELSE '***MASKED***'
      END
  type: string

The legacy BaseQuery path already handled this: memberMaskSql picks aliasMember.split('.')[0] as the cube context for mask.sql. Tesseract was using the symbol's current cube name (the view's name), which broke cross-cube/CUBE references inside masks.

Additionally, {groups.filter("…")} with groups: [] was emitting invalid col IN () SQL.

What's changed

fix(tesseract): compile mask.sql against the owning cube for view members

  • Propagate aliasMember through DimensionDefinitionStatic / MeasureDefinitionStatic to Tesseract.
  • In DimensionSymbolFactory::build and MeasureSymbolFactory::build, compile mask.sql against aliasMember's cube name when set. This mirrors the existing legacy BaseQuery behavior.
  • Exclude mask.sql from iter_sql_calls() so the generic validate_regular_member_cube_refs check does not flag legitimate cross-cube mask references on view members as foreign cubes.

fix(schema-compiler): empty array filter no longer emits IN ()

  • SECURITY_CONTEXT array filters (e.g. {groups.filter("…")}) with an empty array now render as 1 = 0 (or invoke the user callback with []) in both the legacy helper and the Tesseract security_context_filter_fn.

test(rbac): unit and E2E coverage

  • Unit tests in yaml-schema.test.ts exercise the four reference styles ({users.city}, {CUBE.city}, {CUBE}.city, {users}.city) on a prefixed view under both the legacy BaseQuery and the Tesseract (useNativeSqlPlanner: true) paths, plus the empty-groups case.
  • E2E smoke tests (smoke-rbac.test.ts) add SQL API + REST API + Tesseract coverage that a view-exposed member with such mask references actually executes end-to-end.

Reproduction

Before the fix (Tesseract only):

✕ case 2: {CUBE.city} works - Error in `CUBE.city`: Cannot resolve: city
✕ case 3: {CUBE}.city works - Can't find join path to join 'users', 'users_secure_view'
✕ case 4: {users}.city works - Can't find join path to join 'users', 'users_secure_view'
✕ case 1 (groups empty): no IN () - produces invalid SQL `'sensitive_data_access' IN ()`

After the fix:

✓ all four reference styles generate "users".city through the prefixed view
✓ empty groups yields `1 = 0` instead of `IN ()`

Files changed

  • rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs, measure_definition.rs — expose aliasMember.
  • rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/dimension_symbol.rs, measure_symbol.rs — use aliasMember cube for mask.sql compile; exclude from iter_sql_calls.
  • rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs — empty-array filter short-circuit.
  • packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts — empty-array filter short-circuit in contextSymbolsProxyFrom.
  • Fixtures + smoke tests for the reproduction.
Open in Web Open in Cursor 

cursoragent and others added 3 commits April 21, 2026 20:35
…bers

When a cube member with mask.sql is re-exposed through a view (especially
with prefix: true), mask.sql references such as {CUBE.city}, {CUBE}.city
or {users}.city used to fail inside Tesseract with errors like
'Cannot resolve: city', 'Can't find join path to join …' or
'Member references foreign cubes: users'.

The legacy BaseQuery path already compiles mask.sql against the owning
cube by consulting the symbol's aliasMember. Tesseract was compiling
mask.sql against the view's cube name, producing mismatched dependency
resolution.

Propagate aliasMember through DimensionDefinitionStatic /
MeasureDefinitionStatic and use the owning cube name when compiling
mask.sql in the Rust dimension/measure symbol factories.

Also exclude mask.sql from iter_sql_calls so the generic
validate_regular_member_cube_refs check doesn't flag mask references
as foreign cubes.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
…ymbols

SECURITY_CONTEXT array filters (e.g. {groups.filter("…")}) used to
render 'col IN ()' when the array was empty, which is invalid SQL in
most dialects.

Treat an empty array as 'matches nothing' and emit '1 = 0' (or invoke
the callback with an empty list) for both the legacy BaseQuery filter
helper and the Tesseract security_context_filter_fn.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
…ed views

Add unit tests (BaseQuery and Tesseract paths) and E2E smoke tests
covering:

* mask.sql authored with {cube.member}, {CUBE.member}, {CUBE}.column
  and {cube}.column references on a cube member re-exposed through a
  prefixed view.
* {groups.filter(...)} with an empty groups array: must not emit
  invalid 'IN ()' SQL.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Apr 21, 2026
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 21, 2026

Claude finished @paveltiunov's task in 1m 48s —— View job


Code Review: fix(tesseract): mask.sql with cross-cube refs through prefixed views

  • Gather context and read all changed files
  • Review Rust bridge changes (dimension/measure definitions)
  • Review Rust symbol factory changes (dimension_symbol.rs, measure_symbol.rs)
  • Review member_sql.rs empty filter fix
  • Review TypeScript CubeSymbols.ts changes
  • Review test coverage (unit + E2E)
  • Post comprehensive review

Overall Assessment

This is a well-structured fix that addresses three distinct but related issues in the Tesseract planner's handling of mask.sql through views. The approach correctly mirrors the existing legacy BaseQuery behavior (see BaseQuery.js:3462) and the changes are well-scoped. The PR is approve-worthy with a few observations below.


Code Quality & Correctness

The core fix is sound. The aliasMember propagation + mask_sql_cube_name extraction logic in both DimensionSymbolFactory::build and MeasureSymbolFactory::build exactly mirrors the legacy path at BaseQuery.js:3462:

// Legacy
const sqlCubeName = symbol.aliasMember ? symbol.aliasMember.split('.')[0] : cubeName;
// Tesseract (new)
let mask_sql_cube_name = definition
    .static_data()
    .alias_member
    .as_deref()
    .and_then(|alias| alias.split('.').next())
    .map(|s| s.to_string())
    .unwrap_or_else(|| path.cube_name().clone());

Both extract the cube name from the first segment of aliasMember (e.g., "users.city""users"). This ensures mask.sql is compiled in the owning cube's context, not the view's.

Excluding mask_sql from iter_sql_calls() is the right call. Since mask_sql is compiled against the owning cube (which may differ from the symbol's cube_name() when exposed through a view), including it in validate_regular_member_cube_refs would produce false positives. Importantly, mask_sql is still included in get_dependencies() and get_cube_refs(), so dependency tracking and join-path resolution are unaffected.

The empty-array filter fix (1 = 0 fallback) is clean and handles both the string-column and callback-column code paths correctly, in both the Rust (member_sql.rs) and TypeScript (CubeSymbols.ts) implementations.


Potential Issues

1. Duplicated logic between dimension and measure symbol factories

The mask_sql_cube_name extraction block is identical across dimension_symbol.rs:366-372 and measure_symbol.rs:526-532. This is a minor observation — the duplication is small and self-contained, and extracting it into a shared helper would add abstraction without much benefit at this point. Mentioning it for visibility.

2. split('.').next() is safe but relies on aliasMember format

The code uses alias.split('.').next() which always returns Some(...) for any non-empty string. The unwrap_or_else fallback is technically unreachable when alias_member is Some(non_empty_string), since .next() on a split iterator always yields at least one element. This is fine — the fallback is a good safety net regardless.

3. Comment verbosity in iter_sql_calls()

The 6-line comments in both DimensionSymbol::iter_sql_calls() and MeasureSymbol::iter_sql_calls() explaining why mask_sql is excluded are on the longer side — the core information could be distilled to 2 lines. That said, this is a non-obvious design decision and the extra context is arguably warranted for a tricky semantic gap between iter_sql_calls (used for validation) and get_dependencies/get_cube_refs (used for planning).


Security

No security concerns. The empty-array filter change is actually a security improvement — it converts a potential SQL syntax error (IN ()) into a well-defined falsy predicate (1 = 0), which prevents query failures from being exploitable as an information channel.


Testing

The test coverage is thorough:

  • Unit tests (yaml-schema.test.ts): cover all four reference styles ({users.city}, {CUBE.city}, {CUBE}.city, {users}.city) for both legacy BaseQuery and Tesseract paths, plus the empty-groups edge case. Well-structured with parameterized test generation.

  • E2E tests (smoke-rbac.test.ts):

    • SQL API tests for both legacy and Tesseract engine modes
    • REST API tests for all four reference styles through both legacy and Tesseract
    • Fixtures in masking_test.yaml with a dedicated yaml_view_mask_test_base cube and yaml_view_mask_test prefixed view
  • Mock updates (mock_dimension_definition.rs, mock_measure_definition.rs): correctly add the alias_member field to keep test fixtures aligned with the new struct definitions.

One test gap to consider: there's no explicit test for a non-view dimension/measure where aliasMember is None to verify the fallback path (unwrap_or_else(|| path.cube_name().clone())), though this is implicitly covered by the many existing tests that don't involve views.


Summary

Clean, well-tested fix that correctly aligns Tesseract's mask.sql compilation with the legacy BaseQuery behavior. The three changes (aliasMember propagation, iter_sql_calls exclusion, empty-array filter) are logically cohesive and the test coverage exercises the reported failure modes thoroughly. No blocking issues found.

@paveltiunov paveltiunov marked this pull request as ready for review April 21, 2026 20:47
@paveltiunov paveltiunov requested review from a team as code owners April 21, 2026 20:47
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 21, 2026

Claude finished @paveltiunov's task in 1m 38s —— View job


Code Review: fix(tesseract): mask.sql with cross-cube refs through prefixed views

  • Read all changed files and gather context
  • Review Rust bridge changes (dimension_definition.rs, measure_definition.rs)
  • Review Rust symbol factory changes (dimension_symbol.rs, measure_symbol.rs)
  • Review member_sql.rs empty filter fix
  • Review TypeScript CubeSymbols.ts changes
  • Review test fixtures and E2E tests
  • Review mock updates
  • Post comprehensive review

Overall Assessment

This is a well-structured, correctly-scoped fix for three related issues in how Tesseract handles mask.sql through prefixed views. The approach mirrors the existing legacy BaseQuery behavior (confirmed at BaseQuery.js:3462) and the changes are appropriately minimal. No blocking issues found.


Code Quality & Correctness

Core fix — aliasMember propagation (dimension_symbol.rs:366-372, measure_symbol.rs:526-532)

The mask_sql_cube_name extraction logic correctly mirrors the legacy path:

// Tesseract (new)
let mask_sql_cube_name = definition
    .static_data()
    .alias_member
    .as_deref()
    .and_then(|alias| alias.split('.').next())
    .map(|s| s.to_string())
    .unwrap_or_else(|| path.cube_name().clone());
// Legacy (BaseQuery.js:3462)
const sqlCubeName = symbol.aliasMember ? symbol.aliasMember.split('.')[0] : cubeName;

Both correctly extract the cube name from aliasMember (e.g., "users.city""users") so mask.sql compiles against the owning cube's context rather than the view's. The unwrap_or_else fallback is technically unreachable when alias_member is Some(non_empty_string) since split('.').next() always yields at least one element, but it's a reasonable safety net.

Excluding mask_sql from iter_sql_calls() (dimension_symbol.rs:235-242, measure_symbol.rs:315-328)

This is the correct decision. Since mask_sql is compiled against the owning cube (which differs from cube_name() for view members), including it in validate_regular_member_cube_refs would trigger false "references foreign cubes" errors. Critically, mask_sql is still included in both get_dependencies() and get_cube_refs(), so join-path resolution and dependency tracking remain intact:

  • dimension_symbol.rs:245-250get_dependencies() includes mask_sql
  • dimension_symbol.rs:253-258get_cube_refs() includes mask_sql
  • measure_symbol.rs:331-348get_dependencies() includes mask_sql
  • measure_symbol.rs:351-368get_cube_refs() includes mask_sql

Empty-array filter fix (member_sql.rs:423-463, CubeSymbols.ts:1486-1498)

Both implementations handle the empty-array edge case identically:

  • String column path → emit 1 = 0 (universally valid false predicate)
  • Callback column path → delegate with [] so user logic can handle it

This converts a SQL syntax error (IN ()) into well-defined behavior.


Potential Observations

1. Duplicated mask_sql_cube_name extraction block

The 7-line extraction block is identical between dimension_symbol.rs:366-372 and measure_symbol.rs:526-532. This is minor — the duplication is small and self-contained, and these two symbol factories already share a common pattern of being structurally parallel but independently maintained. Extracting to a shared helper is optional and would add indirection without much payoff.

2. Comment length in iter_sql_calls()

The 6-line block comments in both DimensionSymbol::iter_sql_calls() and MeasureSymbol::iter_sql_calls() are longer than this repo's usual style. However, this is a non-obvious semantic decision (why iter_sql_calls excludes it while get_dependencies/get_cube_refs includes it), so the extra context is justified — a future contributor might reasonably wonder "was this an oversight?" without the explanation.

3. apply_to_deps correctly includes mask_sql

Both dimension_symbol.rs:228-232 and measure_symbol.rs:307-311 correctly apply recursive transformations to mask_sql, ensuring symbol tree transformations propagate through masks. This is consistent with the dependency/ref tracking behavior.


Security

No security concerns. The empty-array filter fix is actually a security improvement — it eliminates a potential SQL syntax error path (IN ()) that could be triggered by crafted security context values, replacing it with the well-defined 1 = 0 predicate.


Performance

No performance concerns. The aliasMember access is a simple field lookup on already-deserialized static data, and the split('.').next() is trivial string work performed once during symbol compilation.


Testing

The test coverage is thorough and well-structured:

  • Unit tests (yaml-schema.test.ts): Parameterized across all four reference styles ({users.city}, {CUBE.city}, {CUBE}.city, {users}.city) for both legacy BaseQuery and Tesseract (useNativeSqlPlanner: true) paths, plus the empty-groups edge case. Good use of for...of loop for parameterized test generation.

  • E2E tests (smoke-rbac.test.ts): SQL API + REST API coverage for both legacy and Tesseract engine modes, exercising all four reference styles through the prefixed view. Tests verify the masked value (-1) is correctly returned when the security context lacks the 'sensitive_data_access' group.

  • Fixtures (masking_test.yaml): Clean fixture structure with a dedicated yaml_view_mask_test_base cube that encodes each reference style in a separate dimension name, and a yaml_view_mask_test prefixed view that re-exposes them.

  • Mock updates (mock_dimension_definition.rs, mock_measure_definition.rs): Correctly add the alias_member field with #[builder(default)] to keep test fixtures aligned with the new struct definition.

One minor gap: there's no explicit unit test for the alias_member = None fallback path (non-view dimension with mask.sql), though this is implicitly covered by the existing test suite's many non-view mask scenarios.


Summary

Clean, well-tested fix that correctly aligns Tesseract's mask.sql compilation with the legacy BaseQuery behavior. The three changes (aliasMember propagation, iter_sql_calls exclusion, empty-array filter) are logically cohesive and the test coverage exercises all reported failure modes. No blocking issues.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 50.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.80%. Comparing base (06f64cf) to head (12178f2).
⚠️ Report is 14 commits behind head on master.

Files with missing lines Patch % Lines
...cubejs-schema-compiler/src/compiler/CubeSymbols.ts 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #10727       +/-   ##
===========================================
+ Coverage   58.02%   78.80%   +20.78%     
===========================================
  Files         215      466      +251     
  Lines       16689    91990    +75301     
  Branches     3358     3360        +2     
===========================================
+ Hits         9683    72492    +62809     
- Misses       6514    19007    +12493     
+ Partials      492      491        -1     
Flag Coverage Δ
cube-backend 58.04% <50.00%> (+0.02%) ⬆️
cubesql 83.40% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cursoragent and others added 4 commits April 23, 2026 03:09
The previous fixture names produced Postgres aliases that exceeded the
63-char identifier limit, so the SQL engine's rewrite truncated them
and then failed with 'Member name not found for alias'.

Rename the cube / view / masked dimensions to short forms
(view_mask_base, view_mask_test, pid_full, etc.) and limit the
access_policy.member_masking scope to just the masked dimensions so
non-masked members (id, product_id, count) still resolve to their real
SQL instead of the default NULL mask.

The smoke tests now also select explicit columns instead of SELECT *.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
security_context_filter_fn had three near-identical blocks calling the
user-provided column callback and converting the result to a string.
Extract them into a single helper method.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
Drop the aliasMember plumbing through DimensionDefinitionStatic /
MeasureDefinitionStatic. Tesseract already computes is_sql_direct_ref
for the dimension / measure sql and has SqlCall::resolve_direct_reference
to fetch the referenced MemberSymbol. Use that existing machinery to
find the owning cube for mask.sql, keeping the behavior identical while
avoiding duplicating the owning-cube info across the native bridge.

The semantics match the legacy BaseQuery path: JS sets aliasMember only
when the view member's sql is a pure one-to-one reference to another
cube member, which is exactly the condition SqlCall::is_direct_reference
captures.

Co-authored-by: Pavel Tiunov <pavel.tiunov@gmail.com>
@paveltiunov paveltiunov merged commit e52f201 into master Apr 23, 2026
142 of 144 checks passed
@paveltiunov paveltiunov deleted the cursor/fix-mask-sql-through-views-43a8 branch April 23, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants