From 8d8d2a96b243cecbf2ffaa479e8fee8a6dfbf2a8 Mon Sep 17 00:00:00 2001 From: waralexrom <108349432+waralexrom@users.noreply.github.com> Date: Fri, 8 May 2026 13:31:55 +0200 Subject: [PATCH] chore(tesseract): add bridge regression test harness (#10838) --- .github/workflows/bridge-tests.yml | 69 ++++ packages/cubejs-backend-native/Cargo.toml | 3 + .../jest-bridge.config.js | 19 + packages/cubejs-backend-native/jest.config.js | 5 + packages/cubejs-backend-native/package.json | 3 + .../src/bridge_test_exports.rs | 258 ++++++++++++++ packages/cubejs-backend-native/src/lib.rs | 2 + .../cubejs-backend-native/src/node_export.rs | 3 + .../test/bridge/args-names.test.ts | 82 +++++ .../test/bridge/filter-group.test.ts | 87 +++++ .../bridge/filter-params-callback.test.ts | 64 ++++ .../test/bridge/filter-params.test.ts | 74 ++++ .../test/bridge/helpers.ts | 58 +++ .../test/bridge/multi-arg.test.ts | 32 ++ .../test/bridge/result-shape.test.ts | 65 ++++ .../test/bridge/security-context.test.ts | 332 ++++++++++++++++++ .../test/bridge/symbol-paths.test.ts | 176 ++++++++++ 17 files changed, 1332 insertions(+) create mode 100644 .github/workflows/bridge-tests.yml create mode 100644 packages/cubejs-backend-native/jest-bridge.config.js create mode 100644 packages/cubejs-backend-native/src/bridge_test_exports.rs create mode 100644 packages/cubejs-backend-native/test/bridge/args-names.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/filter-group.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/filter-params-callback.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/filter-params.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/helpers.ts create mode 100644 packages/cubejs-backend-native/test/bridge/multi-arg.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/result-shape.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/security-context.test.ts create mode 100644 packages/cubejs-backend-native/test/bridge/symbol-paths.test.ts diff --git a/.github/workflows/bridge-tests.yml b/.github/workflows/bridge-tests.yml new file mode 100644 index 0000000000000..30469aa1df583 --- /dev/null +++ b/.github/workflows/bridge-tests.yml @@ -0,0 +1,69 @@ +name: Bridge tests + +on: + pull_request: + paths: + - '.github/workflows/bridge-tests.yml' + - 'rust/cube/cubesqlplanner/**' + - 'rust/cube/cubenativeutils/**' + - 'packages/cubejs-backend-native/src/bridge_test_exports.rs' + - 'packages/cubejs-backend-native/test/bridge/**' + - 'packages/cubejs-backend-native/jest-bridge.config.js' + +permissions: + contents: read + +jobs: + bridge-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 40 + name: Bridge tests (debug, --features bridge-test-harness) + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: 1.90.0 + rustflags: "" + cache: false + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ./packages/cubejs-backend-native -> target + key: bridge-tests-${{ runner.OS }}-x86_64-unknown-linux-gnu + shared-key: bridge-tests-${{ runner.OS }}-x86_64-unknown-linux-gnu + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Set Yarn version + run: yarn policies set-version v1.22.22 + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT" + - name: Restore yarn cache + uses: actions/cache@v4 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Yarn install + uses: nick-fields/retry@v3 + env: + CUBESTORE_SKIP_POST_INSTALL: true + with: + max_attempts: 3 + retry_on: error + retry_wait_seconds: 15 + timeout_minutes: 20 + command: yarn install --frozen-lockfile + - name: Build native (debug, --features bridge-test-harness) + working-directory: ./packages/cubejs-backend-native + run: yarn run native:build-debug-bridge-tests + - name: TypeScript compile (lerna, root) + run: yarn tsc + - name: Run bridge tests + working-directory: ./packages/cubejs-backend-native + run: yarn jest --config jest-bridge.config.js --forceExit diff --git a/packages/cubejs-backend-native/Cargo.toml b/packages/cubejs-backend-native/Cargo.toml index 5288266efcd2f..5801a68f9d259 100644 --- a/packages/cubejs-backend-native/Cargo.toml +++ b/packages/cubejs-backend-native/Cargo.toml @@ -57,6 +57,9 @@ neon-debug = [] neon-entrypoint = [] python = ["pyo3", "pyo3-asyncio"] async-log = ["log_nonblock"] +# Exposes `__testBridge_*` endpoints used by the bridge regression test +# harness. Off by default — never enable in production builds. +bridge-test-harness = [] [lints.clippy] too_many_arguments = "allow" diff --git a/packages/cubejs-backend-native/jest-bridge.config.js b/packages/cubejs-backend-native/jest-bridge.config.js new file mode 100644 index 0000000000000..3eb830bd69aa1 --- /dev/null +++ b/packages/cubejs-backend-native/jest-bridge.config.js @@ -0,0 +1,19 @@ +const base = require('../../jest.base.config'); + +// Dedicated config for the Tesseract bridge regression test harness. +// +// Intentionally does NOT use `test/snapshotResolver.js` from the main jest +// config — that resolver wipes the entire `test/__snapshots__/` directory +// when running on a non-python build (see `isFallbackBuild()` check there). +// Bridge tests don't use snapshots, but the side effect would still nuke +// unrelated snapshots in this package. + +/** @type {import('jest').Config} */ +module.exports = { + ...base, + rootDir: '.', + roots: [ + '/dist/test/bridge/' + ], + collectCoverage: false, +}; diff --git a/packages/cubejs-backend-native/jest.config.js b/packages/cubejs-backend-native/jest.config.js index 26fa4b5a6f19f..30aa7aa407274 100644 --- a/packages/cubejs-backend-native/jest.config.js +++ b/packages/cubejs-backend-native/jest.config.js @@ -10,5 +10,10 @@ module.exports = { roots: [ '/dist/test/' ], + // Bridge tests live under `dist/test/bridge/` and require a native module + // built with `--features bridge-test-harness`. They are run separately via + // `yarn test:bridge` (see jest-bridge.config.js); excluding them here keeps + // `yarn unit` working with a regular debug build. + testPathIgnorePatterns: ['/dist/test/bridge/'], snapshotResolver: '/test/snapshotResolver.js', }; diff --git a/packages/cubejs-backend-native/package.json b/packages/cubejs-backend-native/package.json index 972ac41e99c8d..77710ea1321e0 100644 --- a/packages/cubejs-backend-native/package.json +++ b/packages/cubejs-backend-native/package.json @@ -18,12 +18,15 @@ "native:build-release": "npm run native:build -- --release", "native:build-debug-python": "npm run native:build -- --features python", "native:build-release-python": "npm run native:build -- --release --features python", + "native:build-debug-bridge-tests": "npm run native:build -- --features bridge-test-harness", + "native:build-release-bridge-tests": "npm run native:build -- --release --features bridge-test-harness", "postinstall": "post-installer || echo 'Your system is not supported by @cubejs-backend/native, some feature will be unavailable.'", "test:server": "CUBEJS_NATIVE_INTERNAL_DEBUG=true CUBESQL_LOG_LEVEL=trace CUBESQL_PG_PORT=5555 node dist/test/server.js", "test:server:stream": "CUBESQL_STREAM_MODE=true CUBESQL_LOG_LEVEL=error CUBESQL_PG_PORT=5555 node dist/test/server.js", "test:python": "CUBEJS_NATIVE_INTERNAL_DEBUG=true CUBESQL_LOG_LEVEL=trace CUBESQL_PG_PORT=5555 node dist/test/python.js", "unit": "jest --forceExit", "test:unit": "yarn run unit", + "test:bridge": "npm run native:build-debug-bridge-tests && npm run tsc && jest --config jest-bridge.config.js --forceExit", "test:cargo": "cargo test", "bench": "jest --config jest-bench.config.js --forceExit", "lint": "eslint test/ js/ --ext .ts", diff --git a/packages/cubejs-backend-native/src/bridge_test_exports.rs b/packages/cubejs-backend-native/src/bridge_test_exports.rs new file mode 100644 index 0000000000000..bbc9cb329a796 --- /dev/null +++ b/packages/cubejs-backend-native/src/bridge_test_exports.rs @@ -0,0 +1,258 @@ +//! Test endpoints for the Tesseract bridge layer. +//! +//! These functions are exported on the native module under names prefixed with +//! `__testBridge` (e.g. `__testBridgeCompileMemberSql`). They drive real V8 +//! through the production bridge code so +//! that bridge logic can be regression-tested at the unit level rather than +//! only via end-to-end JS planner tests. +//! +//! Stub implementations for trait dependencies (e.g. `BaseTools`) live in this +//! module; they should fail loudly when an unsupported code path is exercised. + +use cubenativeutils::wrappers::neon::neon_guarded_funcion_call; +use cubenativeutils::wrappers::object::{NativeArray, NativeFunction, NativeStruct, NativeType}; +use cubenativeutils::wrappers::serializer::NativeSerialize; +use cubenativeutils::wrappers::{inner_types::InnerTypes, NativeContextHolder, NativeObjectHandle}; +use cubenativeutils::CubeError; +use cubesqlplanner::cube_bridge::base_tools::BaseTools; +use cubesqlplanner::cube_bridge::driver_tools::DriverTools; +use cubesqlplanner::cube_bridge::filter_params_callback::{ + FilterParamsCallback, NativeFilterParamsCallback, +}; +use cubesqlplanner::cube_bridge::join_definition::JoinDefinition; +use cubesqlplanner::cube_bridge::join_hints::JoinHintItem; +use cubesqlplanner::cube_bridge::member_sql::{ + FilterGroupItem, FilterParamsItem, MemberSql, NativeMemberSql, SqlTemplate, SqlTemplateArgs, +}; +use cubesqlplanner::cube_bridge::pre_aggregation_obj::PreAggregationObj; +use cubesqlplanner::cube_bridge::security_context::{NativeSecurityContext, SecurityContext}; +use cubesqlplanner::cube_bridge::sql_templates_render::SqlTemplatesRender; +use cubesqlplanner::cube_bridge::sql_utils::SqlUtils; +use neon::prelude::*; +use std::any::Any; +use std::rc::Rc; + +struct StubBaseTools; + +fn stub_err(method: &str) -> CubeError { + CubeError::internal(format!( + "StubBaseTools::{} called from bridge test harness — \ + this test path requires a real BaseTools implementation", + method + )) +} + +impl BaseTools for StubBaseTools { + fn as_any(self: Rc) -> Rc { + self + } + fn driver_tools(&self, _external: bool) -> Result, CubeError> { + Err(stub_err("driver_tools")) + } + fn sql_templates(&self) -> Result, CubeError> { + Err(stub_err("sql_templates")) + } + fn sql_utils_for_rust(&self) -> Result, CubeError> { + Err(stub_err("sql_utils_for_rust")) + } + fn generate_time_series( + &self, + _granularity: String, + _date_range: Vec, + ) -> Result>, CubeError> { + Err(stub_err("generate_time_series")) + } + fn generate_custom_time_series( + &self, + _granularity: String, + _date_range: Vec, + _origin: String, + ) -> Result>, CubeError> { + Err(stub_err("generate_custom_time_series")) + } + fn get_allocated_params(&self) -> Result, CubeError> { + Err(stub_err("get_allocated_params")) + } + fn all_cube_members(&self, _path: String) -> Result, CubeError> { + Err(stub_err("all_cube_members")) + } + fn interval_and_minimal_time_unit(&self, _interval: String) -> Result, CubeError> { + Err(stub_err("interval_and_minimal_time_unit")) + } + fn get_pre_aggregation_by_name( + &self, + _cube_name: String, + _name: String, + ) -> Result, CubeError> { + Err(stub_err("get_pre_aggregation_by_name")) + } + fn pre_aggregation_table_name( + &self, + _cube_name: String, + _name: String, + ) -> Result { + Err(stub_err("pre_aggregation_table_name")) + } + fn join_tree_for_hints( + &self, + _hints: Vec, + ) -> Result, CubeError> { + Err(stub_err("join_tree_for_hints")) + } +} + +fn handles_to_array( + items: Vec>, + context: NativeContextHolder, +) -> Result, CubeError> { + let arr = context.empty_array()?; + for (i, item) in items.into_iter().enumerate() { + arr.set(i as u32, item)?; + } + Ok(NativeObjectHandle::new(arr.into_object())) +} + +fn template_to_native( + template: &SqlTemplate, + context: NativeContextHolder, +) -> Result, CubeError> { + match template { + SqlTemplate::String(s) => s.to_native(context), + SqlTemplate::StringVec(strings) => strings.to_native(context), + } +} + +fn filter_params_to_native( + items: &[FilterParamsItem], + context: NativeContextHolder, +) -> Result, CubeError> { + let serialized = items + .iter() + .map(|itm| itm.to_native(context.clone())) + .collect::, _>>()?; + handles_to_array(serialized, context) +} + +fn filter_group_to_native( + group: &FilterGroupItem, + context: NativeContextHolder, +) -> Result, CubeError> { + let result = context.empty_struct()?; + result.set_field( + "filter_params", + filter_params_to_native(&group.filter_params, context.clone())?, + )?; + Ok(NativeObjectHandle::new(result.into_object())) +} + +fn args_to_native( + args: &SqlTemplateArgs, + context: NativeContextHolder, +) -> Result, CubeError> { + let result = context.empty_struct()?; + result.set_field( + "symbol_paths", + args.symbol_paths.to_native(context.clone())?, + )?; + result.set_field( + "filter_params", + filter_params_to_native(&args.filter_params, context.clone())?, + )?; + let groups = args + .filter_groups + .iter() + .map(|g| filter_group_to_native(g, context.clone())) + .collect::, _>>()?; + result.set_field("filter_groups", handles_to_array(groups, context.clone())?)?; + let security_context = context.empty_struct()?; + security_context.set_field( + "values", + args.security_context.values.to_native(context.clone())?, + )?; + result.set_field( + "security_context", + NativeObjectHandle::new(security_context.into_object()), + )?; + Ok(NativeObjectHandle::new(result.into_object())) +} + +fn compile_member_sql_inner( + context_holder: NativeContextHolder, + js_fn: NativeObjectHandle, + security_context_obj: NativeObjectHandle, +) -> Result, CubeError> { + let member_sql = NativeMemberSql::try_new(js_fn)?; + let security_context: Rc = + Rc::new(NativeSecurityContext::try_new(security_context_obj)?); + let base_tools: Rc = Rc::new(StubBaseTools); + + let (template, args) = member_sql.compile_template_sql(base_tools, security_context)?; + + let result = context_holder.empty_struct()?; + result.set_field( + "template", + template_to_native(&template, context_holder.clone())?, + )?; + result.set_field("args", args_to_native(&args, context_holder.clone())?)?; + Ok(NativeObjectHandle::new(result.into_object())) +} + +fn compile_member_sql(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, + js_fn: NativeObjectHandle<_>, + security_context_obj: NativeObjectHandle<_>| { + compile_member_sql_inner(context_holder, js_fn, security_context_obj) + }, + ) +} + +fn parse_args_names_inner( + context_holder: NativeContextHolder, + js_fn: NativeObjectHandle, +) -> Result, CubeError> { + let func = js_fn.to_function()?; + let names = func.args_names()?; + names.to_native(context_holder) +} + +fn parse_args_names(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, js_fn: NativeObjectHandle<_>| { + parse_args_names_inner(context_holder, js_fn) + }, + ) +} + +fn invoke_filter_params_callback_inner( + context_holder: NativeContextHolder, + js_fn: NativeObjectHandle, + args: Vec, +) -> Result, CubeError> { + let callback = NativeFilterParamsCallback::new(js_fn); + let result = callback.call(&args)?; + result.to_native(context_holder) +} + +fn invoke_filter_params_callback(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, + js_fn: NativeObjectHandle<_>, + args: Vec| { + invoke_filter_params_callback_inner(context_holder, js_fn, args) + }, + ) +} + +pub fn register_module(cx: &mut ModuleContext) -> NeonResult<()> { + cx.export_function("__testBridgeCompileMemberSql", compile_member_sql)?; + cx.export_function("__testBridgeParseArgsNames", parse_args_names)?; + cx.export_function( + "__testBridgeInvokeFilterParamsCallback", + invoke_filter_params_callback, + )?; + Ok(()) +} diff --git a/packages/cubejs-backend-native/src/lib.rs b/packages/cubejs-backend-native/src/lib.rs index 34c1188933f2d..1226f037fd3fe 100644 --- a/packages/cubejs-backend-native/src/lib.rs +++ b/packages/cubejs-backend-native/src/lib.rs @@ -4,6 +4,8 @@ extern crate findshlibs; pub mod auth; +#[cfg(feature = "bridge-test-harness")] +pub mod bridge_test_exports; pub mod channel; pub mod config; pub mod cross; diff --git a/packages/cubejs-backend-native/src/node_export.rs b/packages/cubejs-backend-native/src/node_export.rs index c844055f6623b..45818a611f014 100644 --- a/packages/cubejs-backend-native/src/node_export.rs +++ b/packages/cubejs-backend-native/src/node_export.rs @@ -801,6 +801,9 @@ pub fn register_module_exports( crate::template::template_register_module(&mut cx)?; + #[cfg(feature = "bridge-test-harness")] + crate::bridge_test_exports::register_module(&mut cx)?; + #[cfg(feature = "python")] crate::python::python_register_module(&mut cx)?; diff --git a/packages/cubejs-backend-native/test/bridge/args-names.test.ts b/packages/cubejs-backend-native/test/bridge/args-names.test.ts new file mode 100644 index 0000000000000..c61b29c4c7eeb --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/args-names.test.ts @@ -0,0 +1,82 @@ +import { bridgeHarnessAvailable, parseArgsNames } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: args_names parser', () => { + describe('working forms', () => { + it('parses arrow with a single parenthesized arg', () => { + expect(parseArgsNames((x: any) => x)).toEqual(['x']); + }); + + it('parses arrow with multiple args', () => { + expect(parseArgsNames((x: any, y: any) => [x, y])).toEqual(['x', 'y']); + }); + + it('parses arrow with no args', () => { + expect(parseArgsNames(() => 42)).toEqual([]); + }); + + it('parses async arrow', () => { + expect(parseArgsNames(async (x: any) => x)).toEqual(['x']); + }); + + it('recognises FILTER_PARAMS / FILTER_GROUP / SECURITY_CONTEXT names', () => { + expect( + parseArgsNames( + (CUBE: any, FILTER_PARAMS: any, SECURITY_CONTEXT: any) => [CUBE, FILTER_PARAMS, SECURITY_CONTEXT] + ) + ).toEqual(['CUBE', 'FILTER_PARAMS', 'SECURITY_CONTEXT']); + }); + }); + + // The skipped tests below assert how a correct parser should behave. + // They are buggy today because both this bridge and the JS-side + // schema-compiler use the same regex; a fix must touch both. + describe('known bugs (skip = expected behavior after fix)', () => { + // The named-function branch of the regex captures [A-Za-z0-9_,]* — + // no whitespace allowed. V8 renders `function f(x, y)` with a space + // after the comma, so capture stops after the first arg. + it.skip('parses named function declaration with multiple args', () => { + function named(x: any, y: any) { + return [x, y]; + } + expect(parseArgsNames(named)).toEqual(['x', 'y']); + }); + + it.skip('parses async named function declaration with multiple args', () => { + async function named(x: any, y: any) { + return [x, y]; + } + expect(parseArgsNames(named)).toEqual(['x', 'y']); + }); + + // Default args land inside the (...) capture as a single token "x = 1" + // and survive the comma split unchanged. Special names like + // SECURITY_CONTEXT in this position fail to dispatch. + it.skip('parses default args, returning just the identifier', () => { + expect(parseArgsNames((x: any = 1) => x)).toEqual(['x']); + }); + + // Rest args keep their leading dots after capture+split, yielding + // "...args" instead of "args". + it.skip('parses rest args, dropping the spread dots', () => { + expect(parseArgsNames((...args: any[]) => args)).toEqual(['args']); + }); + + // Destructuring patterns get split by the comma inside the braces, + // breaking the pattern into half-tokens. + it.skip('parses destructuring args, returning the destructured identifiers', () => { + expect(parseArgsNames(({ a, b }: any) => [a, b])).toEqual(['a', 'b']); + }); + + // Anonymous function expressions (`function (x){}`) match no branch + // of the regex at all. Today Rust silently returns []; the JS side + // throws `Can't match args for: ...`. Neither is the desired + // behavior — the parser should just return the args. + it.skip('parses anonymous function expressions', () => { + // eslint-disable-next-line func-names, prefer-arrow-callback + const fn = function (x: any) { return x; }; + expect(parseArgsNames(fn)).toEqual(['x']); + }); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/filter-group.test.ts b/packages/cubejs-backend-native/test/bridge/filter-group.test.ts new file mode 100644 index 0000000000000..f59ed09221961 --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/filter-group.test.ts @@ -0,0 +1,87 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: FILTER_GROUP', () => { + it('wraps a single FILTER_PARAMS arg into a group', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any, FILTER_GROUP: any) => FILTER_GROUP(FILTER_PARAMS.orders.status.filter('col')) + ); + + expect(result.template).toBe('{fg:0}'); + expect(result.args.filter_groups).toHaveLength(1); + expect(result.args.filter_groups[0].filter_params).toHaveLength(1); + expect(result.args.filter_groups[0].filter_params[0]).toEqual({ + cube_name: 'orders', + name: 'status', + column: 'col', + }); + // Filter params used only inside FILTER_GROUP are not promoted to the + // top-level filter_params list. + expect(result.args.filter_params).toEqual([]); + }); + + it('preserves member identity across multiple grouped FILTER_PARAMS', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any, FILTER_GROUP: any) => FILTER_GROUP( + FILTER_PARAMS.orders.status.filter('a'), + FILTER_PARAMS.users.tier.filter('b') + ) + ); + + expect(result.template).toBe('{fg:0}'); + expect(result.args.filter_groups[0].filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 'a' }, + { cube_name: 'users', name: 'tier', column: 'b' }, + ]); + }); + + it('coexists with a top-level FILTER_PARAMS reference in the same template', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any, FILTER_GROUP: any) => `${FILTER_PARAMS.orders.region.filter('r')} AND ${FILTER_GROUP( + FILTER_PARAMS.orders.status.filter('s') + )}` + ); + + expect(result.template).toBe('{fp:0} AND {fg:0}'); + expect(result.args.filter_params).toHaveLength(1); + expect(result.args.filter_params[0]).toEqual({ + cube_name: 'orders', + name: 'region', + column: 'r', + }); + expect(result.args.filter_groups[0].filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 's' }, + ]); + }); + + it('throws a user error when FILTER_GROUP receives a non-FILTER_PARAMS arg', () => { + expect(() => compileMemberSql((FILTER_GROUP: any) => FILTER_GROUP('not a filter'))).toThrow(/FILTER_GROUP expects FILTER_PARAMS args to be passed/); + }); + + it('produces an empty group with no filter_params when FILTER_GROUP() is called with no args', () => { + const result = compileMemberSql((FILTER_GROUP: any) => FILTER_GROUP()); + + expect(result.template).toBe('{fg:0}'); + expect(result.args.filter_groups).toHaveLength(1); + expect(result.args.filter_groups[0].filter_params).toEqual([]); + }); + + it('does NOT dedup the same FILTER_PARAMS across two FILTER_GROUP() calls', () => { + // Mirrors filter-params anti-dedup: each FILTER_GROUP gets its own copy + // of the items via push, no UniqueVector. + const result = compileMemberSql( + (FILTER_PARAMS: any, FILTER_GROUP: any) => `${FILTER_GROUP(FILTER_PARAMS.orders.status.filter('s'))} OR ` + + `${FILTER_GROUP(FILTER_PARAMS.orders.status.filter('s'))}` + ); + + expect(result.template).toBe('{fg:0} OR {fg:1}'); + expect(result.args.filter_groups).toHaveLength(2); + expect(result.args.filter_groups[0].filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 's' }, + ]); + expect(result.args.filter_groups[1].filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 's' }, + ]); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/filter-params-callback.test.ts b/packages/cubejs-backend-native/test/bridge/filter-params-callback.test.ts new file mode 100644 index 0000000000000..7595f1f69e060 --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/filter-params-callback.test.ts @@ -0,0 +1,64 @@ +import { + bridgeHarnessAvailable, + invokeFilterParamsCallback, +} from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: FilterParamsCallback', () => { + // The bridge spreads the Vec of filter values into positional + // arguments of the JS callback (matching how typed_filter.rs invokes + // it). A callback for an equality filter takes one arg; a between + // filter takes two; a callback that wants the array form should use + // rest params. + + it('passes a single placeholder as one positional arg', () => { + const result = invokeFilterParamsCallback( + (a: string) => `= ${a}`, + ['{sv:0}'] + ); + + expect(result).toBe('= {sv:0}'); + }); + + it('spreads multiple placeholders as positional args', () => { + const result = invokeFilterParamsCallback( + (from: string, to: string) => `BETWEEN ${from} AND ${to}`, + ['{sv:0}', '{sv:1}'] + ); + + expect(result).toBe('BETWEEN {sv:0} AND {sv:1}'); + }); + + it('makes the placeholder array reachable via rest params', () => { + const result = invokeFilterParamsCallback( + (...vals: string[]) => `IN (${vals.join(',')})`, + ['{sv:0}', '{sv:1}', '{sv:2}'] + ); + + expect(result).toBe('IN ({sv:0},{sv:1},{sv:2})'); + }); + + it('passes no args when the filter values are empty', () => { + const result = invokeFilterParamsCallback( + (...rest: string[]) => `count=${rest.length}`, + [] + ); + + expect(result).toBe('count=0'); + }); + + it('returns a user error when the callback returns a non-string', () => { + expect(() => invokeFilterParamsCallback(() => 42 as any, [])).toThrow(/Callback for FILTER_PARAMS should return string/); + }); + + it('returns a user error when the callback returns undefined', () => { + expect(() => invokeFilterParamsCallback(() => undefined as any, [])).toThrow(/Callback for FILTER_PARAMS should return string/); + }); + + it('propagates a JS-thrown error', () => { + expect(() => invokeFilterParamsCallback(() => { + throw new Error('boom'); + }, [])).toThrow(/boom/); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/filter-params.test.ts b/packages/cubejs-backend-native/test/bridge/filter-params.test.ts new file mode 100644 index 0000000000000..059fba2de6498 --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/filter-params.test.ts @@ -0,0 +1,74 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: FILTER_PARAMS', () => { + it('records cube_name, name, and string column for a single filter', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any) => FILTER_PARAMS.orders.status.filter('col') + ); + + expect(result.template).toBe('{fp:0}'); + expect(result.args.filter_params).toHaveLength(1); + expect(result.args.filter_params[0]).toEqual({ + cube_name: 'orders', + name: 'status', + column: 'col', + }); + }); + + it('exposes a callback column as a JS function and forwards the prepared arg to it', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any) => FILTER_PARAMS.orders.status.filter((c: string) => `${c} IN (1,2)`) + ); + + expect(result.template).toBe('{fp:0}'); + expect(result.args.filter_params).toHaveLength(1); + + const fp = result.args.filter_params[0]; + expect(fp.cube_name).toBe('orders'); + expect(fp.name).toBe('status'); + expect(typeof fp.column).toBe('function'); + expect((fp.column as Function)('orders.status')).toBe( + 'orders.status IN (1,2)' + ); + }); + + it('records distinct filter params for different cubes/members without dedup', () => { + const result = compileMemberSql( + (FILTER_PARAMS: any) => `${FILTER_PARAMS.orders.status.filter('a')} AND ` + + `${FILTER_PARAMS.orders.region.filter('b')} AND ` + + `${FILTER_PARAMS.users.tier.filter('c')}` + ); + + expect(result.template).toBe('{fp:0} AND {fp:1} AND {fp:2}'); + expect(result.args.filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 'a' }, + { cube_name: 'orders', name: 'region', column: 'b' }, + { cube_name: 'users', name: 'tier', column: 'c' }, + ]); + }); + + it('does NOT dedup identical filter params — each .filter() call adds a new entry', () => { + // Capturing this on purpose: filter_params uses Vec::push, not + // unique_insert, unlike symbol_paths. If this changes, the test will + // fail and force a deliberate decision. + const result = compileMemberSql( + (FILTER_PARAMS: any) => `${FILTER_PARAMS.orders.status.filter('col')} OR ` + + `${FILTER_PARAMS.orders.status.filter('col')}` + ); + + expect(result.template).toBe('{fp:0} OR {fp:1}'); + expect(result.args.filter_params).toHaveLength(2); + expect(result.args.filter_params[0]).toEqual({ + cube_name: 'orders', + name: 'status', + column: 'col', + }); + expect(result.args.filter_params[1]).toEqual({ + cube_name: 'orders', + name: 'status', + column: 'col', + }); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/helpers.ts b/packages/cubejs-backend-native/test/bridge/helpers.ts new file mode 100644 index 0000000000000..8c372ecf5b2de --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/helpers.ts @@ -0,0 +1,58 @@ +import { loadNative } from '../../js'; + +const native = loadNative(); + +// Bridge test endpoints are only present when the native module was built with +// `--features bridge-test-harness` (e.g. `yarn native:build-debug-bridge-tests`). +// Test suites should gate themselves on this flag rather than calling the +// helpers blindly, so a regular debug build doesn't blow up the unit run. +export const bridgeHarnessAvailable: boolean = + typeof native.__testBridgeCompileMemberSql === 'function'; + +export type SqlTemplate = string | string[]; + +export interface FilterParamsItem { + cube_name: string; + name: string; + // String column → string. Callback column → JS function (call to inspect). + column: string | Function; +} + +export interface FilterGroupItem { + filter_params: FilterParamsItem[]; +} + +export interface SqlTemplateArgs { + symbol_paths: string[][]; + filter_params: FilterParamsItem[]; + filter_groups: FilterGroupItem[]; + security_context: { values: string[] }; +} + +export interface CompiledMemberSql { + template: SqlTemplate; + args: SqlTemplateArgs; +} + +export function compileMemberSql( + fn: Function, + securityContext: object = {} +): CompiledMemberSql { + if (!bridgeHarnessAvailable) { + throw new Error( + 'Bridge test harness is not built. Rebuild with `yarn native:build-debug-bridge-tests`.' + ); + } + return native.__testBridgeCompileMemberSql(fn, securityContext); +} + +export function parseArgsNames(fn: Function): string[] { + return native.__testBridgeParseArgsNames(fn); +} + +export function invokeFilterParamsCallback( + fn: Function, + args: string[] +): string { + return native.__testBridgeInvokeFilterParamsCallback(fn, args); +} diff --git a/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts b/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts new file mode 100644 index 0000000000000..3e985061c346d --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts @@ -0,0 +1,32 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: multi-arg dispatch', () => { + it('wires CUBE, FILTER_PARAMS, and SECURITY_CONTEXT independently in one function', () => { + const result = compileMemberSql( + (CUBE: any, FILTER_PARAMS: any, SECURITY_CONTEXT: any) => `SUM(${CUBE.amount}) WHERE ` + + `${FILTER_PARAMS.orders.status.filter('col')} AND ` + + `${SECURITY_CONTEXT.tenant.filter('t')}`, + { tenant: 'acme' } + ); + + expect(result.template).toBe( + 'SUM({arg:0}) WHERE {fp:0} AND t = {sv:1}' + ); + expect(result.args.symbol_paths).toEqual([['CUBE', 'amount']]); + expect(result.args.filter_params).toEqual([ + { cube_name: 'orders', name: 'status', column: 'col' }, + ]); + expect(result.args.security_context.values).toEqual(['acme', 'acme']); + }); + + it('throws an internal error from the StubBaseTools when SQL_UTILS is referenced', () => { + // The bridge harness deliberately does not provide a real BaseTools. + // Tests that exercise SQL_UTILS need a richer stub; this test pins + // current behavior so we notice if/when we add one. + expect(() => compileMemberSql( + (SQL_UTILS: any) => `${SQL_UTILS.someHelper()}` + )).toThrow(/StubBaseTools::sql_utils_for_rust/); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/result-shape.test.ts b/packages/cubejs-backend-native/test/bridge/result-shape.test.ts new file mode 100644 index 0000000000000..3b58b0357030d --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/result-shape.test.ts @@ -0,0 +1,65 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: result shape', () => { + it('returns a string-vec template when the user function returns an array', () => { + // Pre-aggregation refs use this path. + const result = compileMemberSql( + // eslint-disable-next-line camelcase + (orders: any, line_items: any) => [orders.status, line_items.id] + ); + + expect(result.template).toEqual(['{arg:0}', '{arg:1}']); + expect(result.args.symbol_paths).toEqual([ + ['orders', 'status'], + ['line_items', 'id'], + ]); + }); + + it('returns a string template for any non-array return (including objects coerced via toString)', () => { + // If the user returns a single proxy node, convert_to_string falls back + // to its toString interceptor, which in turn registers the path. + const result = compileMemberSql((orders: any) => orders.status); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([['orders', 'status']]); + }); + + it('errors when the user function returns a primitive number', () => { + // Bug: bridge eagerly coerces the return value via convert_to_string, + // which only handles JsString and null — it then tries to call + // toString as a struct method, which errors on primitives. JS + // reference returns the value unchanged and lets downstream template + // literals coerce naturally. See skipped 'JS-ref: numeric return…'. + expect(() => compileMemberSql(() => 42 as any)).toThrow( + /Object is not the Struct object/ + ); + }); + + it('errors when the user function returns a primitive boolean', () => { + expect(() => compileMemberSql(() => true as any)).toThrow( + /Object is not the Struct object/ + ); + }); + + it('returns an empty string template when the user function returns null', () => { + const result = compileMemberSql(() => null as any); + + expect(result.template).toBe(''); + expect(result.args.symbol_paths).toEqual([]); + }); + + // JS reference does no coercion at the bridge boundary — the user + // function's return value flows through resolveSymbolsCall verbatim and + // is later embedded via template literal, which yields String(value). + it.skip('JS-ref: numeric return is coerced to its string form', () => { + const result = compileMemberSql(() => 42 as any); + expect(result.template).toBe('42'); + }); + + it.skip('JS-ref: boolean return is coerced to its string form', () => { + const result = compileMemberSql(() => true as any); + expect(result.template).toBe('true'); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/security-context.test.ts b/packages/cubejs-backend-native/test/bridge/security-context.test.ts new file mode 100644 index 0000000000000..885999d0f6442 --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/security-context.test.ts @@ -0,0 +1,332 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: SECURITY_CONTEXT — filter input shapes', () => { + it('handles a string filter value as col = {sv:N}', () => { + // Pin current Rust behavior. Eager double-registration is a known + // divergence vs JS — see skipped 'JS-ref: .filter pushes value once'. + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, + { tenant: 'acme' } + ); + + expect(result.template).toBe('col = {sv:1}'); + expect(result.args.security_context.values).toEqual(['acme', 'acme']); + }); + + it('handles a string array as col IN (sv0, sv1, ...)', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups.filter('col')}`, + { groups: ['a', 'b', 'c'] } + ); + + // Eager double-registration: 3 from leaf-proxy construction + 3 from + // .filter call. See skipped 'JS-ref: .filter pushes value once'. + expect(result.template).toBe('col IN ({sv:3}, {sv:4}, {sv:5})'); + expect(result.args.security_context.values).toEqual([ + 'a', + 'b', + 'c', + 'a', + 'b', + 'c', + ]); + }); + + it('renders an empty string array as 1 = 0 with no values registered for the filter', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups.filter('col')}`, + { groups: [] } + ); + + expect(result.template).toBe('1 = 0'); + // Leaf proxy still constructed; an empty array contributes no eager + // registrations and the filter callback also produces none. + expect(result.args.security_context.values).toEqual([]); + }); + + it('passes an empty array to a callback column for the empty-array case', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups.filter( + (vals: string[]) => `received:${vals.length}` + )}`, + { groups: [] } + ); + + expect(result.template).toBe('received:0'); + }); + + it('formats an integer-valued number without a decimal point', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.user_id.filter('uid')}`, + { user_id: 42 } + ); + + expect(result.template).toBe('uid = {sv:1}'); + expect(result.args.security_context.values).toEqual(['42', '42']); + }); + + it('formats a non-integer number as a decimal string', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.factor.filter('f')}`, + { factor: 1.5 } + ); + + expect(result.template).toBe('f = {sv:1}'); + expect(result.args.security_context.values).toEqual(['1.5', '1.5']); + }); + + it('formats a boolean as the string true/false', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.flag.filter('f')}`, + { flag: true } + ); + + expect(result.template).toBe('f = {sv:1}'); + expect(result.args.security_context.values).toEqual(['true', 'true']); + }); + + it('returns 1 = 1 when an optional filter field is missing', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, + {} + ); + + expect(result.template).toBe('1 = 1'); + expect(result.args.security_context.values).toEqual([]); + }); + + it('throws a user error when requiredFilter field is missing', () => { + expect(() => compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.requiredFilter('col')}`, + {} + )).toThrow(/Filter for col is required/); + }); + + it('rejects an unsupported value type with a user error', () => { + expect(() => compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.bad.filter('col')}`, + { bad: { nested: 'object' } } + )).toThrow(/Invalid param for security context/); + }); +}); + +describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { + it('navigates nested struct values through the recursive proxy', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.id.filter('col')}`, + { tenant: { id: '123' } } + ); + + expect(result.template).toBe('col = {sv:1}'); + expect(result.args.security_context.values).toEqual(['123', '123']); + }); + + it('does not crash on a deep leaf-proxy path that does not exist in the context', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.a.b.c.filter('col')}`, + {} + ); + + // Filter on a fully-undefined leaf path: the value resolves to None and + // the filter falls back to "1 = 1" (the path is treated as an absent + // optional filter, not an error). + expect(result.template).toBe('1 = 1'); + }); + + it('exposes unsafeValue() that returns the raw value without registering a placeholder', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `prefix-${SECURITY_CONTEXT.tenant.unsafeValue()}-suffix`, + { tenant: 'acme' } + ); + + expect(result.template).toBe('prefix-acme-suffix'); + // Eager toString registration still pushes once; unsafeValue itself + // does not register anything. + expect(result.args.security_context.values).toEqual(['acme']); + }); + + it('lets the user branch the template at compile time via unsafeValue()', () => { + // Real prod pattern: unsafeValue() returns the actual JS value, so a + // ternary in the template literal picks one branch at compile time + // and the resulting template is just the picked literal. + const adminResult = compileMemberSql( + (SECURITY_CONTEXT: any) => `SELECT * FROM ${ + SECURITY_CONTEXT.cubeCloud.groups.unsafeValue() === 'admin' + ? 'admin_orders' + : 'public_orders' + }`, + { cubeCloud: { groups: 'admin' } } + ); + const viewerResult = compileMemberSql( + (SECURITY_CONTEXT: any) => `SELECT * FROM ${ + SECURITY_CONTEXT.cubeCloud.groups.unsafeValue() === 'admin' + ? 'admin_orders' + : 'public_orders' + }`, + { cubeCloud: { groups: 'viewer' } } + ); + + expect(adminResult.template).toBe('SELECT * FROM admin_orders'); + expect(viewerResult.template).toBe('SELECT * FROM public_orders'); + // unsafeValue() itself does not push to values, BUT just accessing + // .groups constructs a leaf proxy whose toString function eagerly + // pushes the leaf value. So the bridge state still records the leaf + // even though the rendered template never references {sv:N}. + expect(adminResult.args.security_context.values).toEqual(['admin']); + expect(viewerResult.args.security_context.values).toEqual(['viewer']); + }); + + it('renders a leaf used directly in a template (no filter call) without duplicating values', () => { + // tenant_id = ${SECURITY_CONTEXT.cubeCloud.tenantId} — common in prod. + // Here the eager to_string_fn baked in during leaf-proxy construction + // returns the placeholder when JS coerces; nothing else pushes. + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `tenant_id = ${SECURITY_CONTEXT.cubeCloud.tenantId}`, + { cubeCloud: { tenantId: '123' } } + ); + + expect(result.template).toBe('tenant_id = {sv:0}'); + expect(result.args.security_context.values).toEqual(['123']); + }); + + it('supports the canonical array-filter callback pattern with groups.join(...)', () => { + // Canonical prod pattern. The callback receives the prepared + // placeholder strings; join glues them into the SQL fragment. + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.cubeCloud.groups.filter( + (groups: string[]) => `source IN (${groups.join(',')})` + )}`, + { cubeCloud: { groups: ['a', 'b'] } } + ); + + // 2 from eager toString registration + 2 from .filter() — the last two + // ({sv:2}, {sv:3}) are passed to the callback. + expect(result.template).toBe('source IN ({sv:2},{sv:3})'); + expect(result.args.security_context.values).toEqual(['a', 'b', 'a', 'b']); + }); + + it('accepts both camelCase securityContext and snake_case security_context arg names', () => { + const camel = compileMemberSql( + (securityContext: any) => `${securityContext.tenant.filter('col')}`, + { tenant: 'acme' } + ); + const snake = compileMemberSql( + // eslint-disable-next-line camelcase + (security_context: any) => `${security_context.tenant.filter('col')}`, + { tenant: 'acme' } + ); + + expect(camel.template).toBe('col = {sv:1}'); + expect(snake.template).toBe('col = {sv:1}'); + }); +}); + +// Each skipped test below asserts what the JS reference proxy +// (`contextSymbolsProxyFrom` in schema-compiler) does today. The Rust +// bridge diverges; unskip together with a fix. +describeBridge('bridge: SECURITY_CONTEXT — known divergences from JS reference', () => { + // Bug: bridge treats falsy non-null values as real values and emits a + // bind. JS short-circuits truthy on the param: false / 0 / '' return + // "1 = 1". Rust cascades through String/f64/bool deserialization and + // pushes the value. + it.skip('JS-ref: .filter on false returns 1 = 1', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.flag.filter('f')}`, + { flag: false } + ); + expect(result.template).toBe('1 = 1'); + expect(result.args.security_context.values).toEqual([]); + }); + + it.skip('JS-ref: .filter on 0 returns 1 = 1', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.user_id.filter('uid')}`, + { user_id: 0 } + ); + expect(result.template).toBe('1 = 1'); + expect(result.args.security_context.values).toEqual([]); + }); + + it.skip('JS-ref: .filter on \'\' returns 1 = 1', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, + { tenant: '' } + ); + expect(result.template).toBe('1 = 1'); + expect(result.args.security_context.values).toEqual([]); + }); + + // Bug: requiredFilter accepts falsy non-null values. JS throws on any + // falsy value (including 0 / false / ''). Rust only throws when the + // value is undefined or null. + it.skip('JS-ref: .requiredFilter on 0 throws', () => { + expect(() => compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.requiredFilter('col')}`, + { tenant: 0 } + )).toThrow(/Filter for col is required/); + }); + + it.skip('JS-ref: .requiredFilter on false throws', () => { + expect(() => compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.flag.requiredFilter('f')}`, + { flag: false } + )).toThrow(/Filter for f is required/); + }); + + // Bug: numeric arrays in security context error out. JS maps each + // element through allocateParam without type coercion and produces an + // IN clause. Rust requires every element to deserialize as a string and + // falls through to "Invalid param for security context". + it.skip('JS-ref: .filter on number[] emits IN clause', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.ids.filter('id')}`, + { ids: [1, 2, 3] } + ); + expect(result.template).toMatch( + /^id IN \(\{sv:\d+\}, \{sv:\d+\}, \{sv:\d+\}\)$/ + ); + }); + + // Bug: leaf proxy eagerly allocates the value at construction time, so + // .filter pushes a duplicate and {sv:N} starts at 1. JS allocates + // lazily — each .filter call pushes exactly once. Rust pre-bakes the + // toString output when the leaf proxy is built. + it.skip('JS-ref: .filter pushes value once at {sv:0}', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, + { tenant: 'acme' } + ); + expect(result.template).toBe('col = {sv:0}'); + expect(result.args.security_context.values).toEqual(['acme']); + }); + + it.skip('JS-ref: .filter on string[] uses indices 0..N once', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups.filter('col')}`, + { groups: ['a', 'b'] } + ); + expect(result.template).toBe('col IN ({sv:0}, {sv:1})'); + expect(result.args.security_context.values).toEqual(['a', 'b']); + }); + + it.skip('JS-ref: unsafeValue() does not register a placeholder', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `prefix-${SECURITY_CONTEXT.tenant.unsafeValue()}-suffix`, + { tenant: 'acme' } + ); + expect(result.template).toBe('prefix-acme-suffix'); + expect(result.args.security_context.values).toEqual([]); + }); + + // Bug: array toString joins with ", " (with space). JS joins with + // "," (no space). + it.skip('JS-ref: array toString joins without a space', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups}`, + { groups: ['a', 'b'] } + ); + expect(result.template).toBe('{sv:0},{sv:1}'); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/symbol-paths.test.ts b/packages/cubejs-backend-native/test/bridge/symbol-paths.test.ts new file mode 100644 index 0000000000000..8e82b8fdb3a30 --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/symbol-paths.test.ts @@ -0,0 +1,176 @@ +import { bridgeHarnessAvailable, compileMemberSql } from './helpers'; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +describeBridge('bridge: symbol paths via property_proxy', () => { + describe('basic capture', () => { + it('captures a simple cube ref via toString interception', () => { + const result = compileMemberSql((CUBE: any) => `${CUBE.amount}`); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([['CUBE', 'amount']]); + }); + + it('captures a nested path', () => { + const result = compileMemberSql((users: any) => `${users.address.city}`); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([['users', 'address', 'city']]); + }); + + it('handles direct return without template literal (convert_to_string fallback)', () => { + const result = compileMemberSql((CUBE: any) => CUBE.amount); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([['CUBE', 'amount']]); + }); + + it('exposes a constant template when the function returns a string literal', () => { + const result = compileMemberSql(() => 'NOW()'); + + expect(result.template).toBe('NOW()'); + expect(result.args.symbol_paths).toEqual([]); + }); + }); + + describe('coercion paths', () => { + it('captures via String(x) explicit coercion', () => { + const result = compileMemberSql((CUBE: any) => `value=${String(CUBE.x)}`); + + expect(result.template).toBe('value={arg:0}'); + expect(result.args.symbol_paths).toEqual([['CUBE', 'x']]); + }); + + it('captures via + "" concatenation (valueOf path)', () => { + // eslint-disable-next-line prefer-template + const result = compileMemberSql((CUBE: any) => `${'' + CUBE.x}`); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([['CUBE', 'x']]); + }); + }); + + describe('.sql() accessor', () => { + // Canonical user-facing usage: `.sql()` for subquery references — + // e.g. `select v.* from ${visitors.sql()} as v` (see prod schemas in + // packages/cubejs-schema-compiler/test/integration/postgres/...). + // .sql is itself a function — JS does NOT auto-invoke it like + // toString/valueOf, so user code must call it explicitly. + + it('records {arg:N} with __sql_fn suffix for cube.sql() at the cube root', () => { + const result = compileMemberSql( + (visitors: any) => `select v.* from ${visitors.sql()} v where v.source = 'google'` + ); + + expect(result.template).toBe( + 'select v.* from {arg:0} v where v.source = \'google\'' + ); + expect(result.args.symbol_paths).toEqual([['visitors', '__sql_fn']]); + }); + + it('keeps cube.sql() and a member ref as distinct paths', () => { + const result = compileMemberSql( + (orders: any) => `SELECT * FROM ${orders.sql()} WHERE created = ${orders.createdAt}` + ); + + expect(result.template).toBe( + 'SELECT * FROM {arg:0} WHERE created = {arg:1}' + ); + expect(result.args.symbol_paths).toEqual([ + ['orders', '__sql_fn'], + ['orders', 'createdAt'], + ]); + }); + }); + + describe('dedup behavior', () => { + it('dedups identical paths in the same template', () => { + const result = compileMemberSql((CUBE: any) => `${CUBE.x} + ${CUBE.x}`); + + expect(result.template).toBe('{arg:0} + {arg:0}'); + expect(result.args.symbol_paths).toEqual([['CUBE', 'x']]); + }); + + it('dedups same path across different surrounding SQL contexts', () => { + // Bare reference and the same reference wrapped in ceil() share one + // {arg:N}. Parenthesizing/safety is handled downstream by SqlCall; + // the bridge only records the path once via UniqueVector. + const result = compileMemberSql( + (cube: any) => `${cube.a} + ceil(${cube.a})` + ); + + expect(result.template).toBe('{arg:0} + ceil({arg:0})'); + expect(result.args.symbol_paths).toEqual([['cube', 'a']]); + }); + + it('treats different leaves under the same top-level as distinct paths', () => { + const result = compileMemberSql( + (CUBE: any) => `${CUBE.a} + ${CUBE.b}` + ); + + expect(result.template).toBe('{arg:0} + {arg:1}'); + expect(result.args.symbol_paths).toEqual([ + ['CUBE', 'a'], + ['CUBE', 'b'], + ]); + }); + }); + + describe('view chains', () => { + it('captures sibling view chains as distinct paths sharing a top-level', () => { + const result = compileMemberSql( + (view: any) => `${view.v1.field} + ${view.v2.field}` + ); + + expect(result.template).toBe('{arg:0} + {arg:1}'); + expect(result.args.symbol_paths).toEqual([ + ['view', 'v1', 'field'], + ['view', 'v2', 'field'], + ]); + }); + + it('dedups identical view chains', () => { + const result = compileMemberSql( + (view: any) => `${view.v1.field} + ${view.v1.field}` + ); + + expect(result.template).toBe('{arg:0} + {arg:0}'); + expect(result.args.symbol_paths).toEqual([['view', 'v1', 'field']]); + }); + + it('keeps different leaves under a shared chain prefix as distinct', () => { + const result = compileMemberSql( + (view: any) => `${view.v1.a} + ${view.v1.b}` + ); + + expect(result.template).toBe('{arg:0} + {arg:1}'); + expect(result.args.symbol_paths).toEqual([ + ['view', 'v1', 'a'], + ['view', 'v1', 'b'], + ]); + }); + + it('captures deeper view chains (4+ levels)', () => { + const result = compileMemberSql( + (view: any) => `${view.v1.sub.field}` + ); + + expect(result.template).toBe('{arg:0}'); + expect(result.args.symbol_paths).toEqual([ + ['view', 'v1', 'sub', 'field'], + ]); + }); + + it('handles mixed-depth view chains in one template', () => { + const result = compileMemberSql( + (view: any) => `${view.v1.field} + ${view.v2.sub.field}` + ); + + expect(result.template).toBe('{arg:0} + {arg:1}'); + expect(result.args.symbol_paths).toEqual([ + ['view', 'v1', 'field'], + ['view', 'v2', 'sub', 'field'], + ]); + }); + }); +});