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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/cubejs-backend-native/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,50 @@ export const buildSqlAndParams = (cubeEvaluator: any): any[] => {
return native.buildSqlAndParams(cubeEvaluator);
};

/**
* JS-side wrapper around the long-lived Tesseract model `JsBox`. The
* `handle` field is the opaque `JsBox<NativeRustHandle>` produced by
* the Rust side; it stays public for tests / debug introspection but
* production callers should go through the methods. Lifetime is
* managed by the JS GC — when no reference to a `TesseractModel`
* survives, the underlying Rust `Model` is finalized through
* `NativeRustHandle`'s Drop chain.
*/
export class TesseractModel {
/** @internal — opaque JsBox handle, do not interpret in JS. */
public readonly handle: unknown;

/** @internal — instances are produced by `prepareModel`. */
public constructor(handle: unknown) {
this.handle = handle;
}

/**
* Same shape as the standalone `buildSqlAndParams` but skips the
* per-request `cubeEvaluator` roundtrip for structural lookups —
* the planner reads the compiled model from this handle instead.
*/
public buildSqlAndParams(queryParams: any): any[] {
const native = loadNative();
return native.modelBuildSqlAndParams(this.handle, queryParams);
}
}

/**
* Build the long-lived Tesseract model from a JS `SchemaSource`
* wrapper around `CubeEvaluator`. Returns a `TesseractModel` whose
* methods route per-request calls to the cached model handle. Returns
* `null` when the native module doesn't expose `prepareModel`
* (older build) so callers can detect the feature.
*/
export const prepareModel = (schemaSource: unknown): TesseractModel | null => {
const native = loadNative();
if (typeof native.prepareModel !== 'function') {
return null;
}
return new TesseractModel(native.prepareModel(schemaSource));
};

export type ResultRow = Record<string, string>;

export const parseCubestoreResultMessage = async (message: ArrayBuffer): Promise<ResultWrapper> => {
Expand Down
69 changes: 69 additions & 0 deletions packages/cubejs-backend-native/src/bridge_test_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,13 @@ fn invoke_cube_definition<IT: InnerTypes>(b: &NativeCubeDefinition<IT>) -> Invok
r.record("sql_table", b.sql_table());
r.record("sql", b.sql());
r.record("default_filters", b.default_filters());
r.record("measures", b.measures());
r.record("dimensions", b.dimensions());
r.record("segments", b.segments());
r.record("joins", b.joins());
r.record("pre_aggregations", b.pre_aggregations());
r.record("access_policies", b.access_policies());
r.record("included_members", b.included_members());
r
}

Expand All @@ -746,6 +753,7 @@ fn invoke_dimension_definition<IT: InnerTypes>(b: &NativeDimensionDefinition<IT>
r.record("time_shift", b.time_shift());
r.record("filter", b.filter());
r.record("mask_sql", b.mask_sql());
r.record("granularities", b.granularities());
r
}

Expand Down Expand Up @@ -803,6 +811,10 @@ fn invoke_pre_aggregation_description<IT: InnerTypes>(
r.record("segment_references", b.segment_references());
r.record("rollup_references", b.rollup_references());
r.record("time_dimension_references", b.time_dimension_references());
r.record("build_range_start", b.build_range_start());
r.record("build_range_end", b.build_range_end());
r.record("indexes", b.indexes());
r.record("refresh_key", b.refresh_key());
r
}

Expand Down Expand Up @@ -991,6 +1003,62 @@ fn rust_box_unwrap(cx: FunctionContext) -> JsResult<JsValue> {
)
}

/// Inspector used by the JS roundtrip test: opens a model handle
/// returned by `prepareModel` and reports the cubes it contains.
/// Production code reaches the model through downcast — this endpoint
/// just makes the introspection visible to JS for tests.
#[derive(serde::Serialize)]
struct ModelView {
cubes: Vec<CubeView>,
}

#[derive(serde::Serialize)]
struct CubeView {
name: String,
is_view: bool,
measure_count: usize,
dimension_count: usize,
segment_count: usize,
join_count: usize,
pre_aggregation_count: usize,
access_policy_count: usize,
}

fn model_describe_inner<IT: InnerTypes>(
_context: NativeContextHolder<IT>,
obj: NativeObjectHandle<IT>,
) -> Result<ModelView, CubeError> {
let rust_box = obj.into_rust_box()?;
let model = rust_box
.handle()
.downcast::<cubesqlplanner::model::Model>()?;
let mut cubes = Vec::with_capacity(model.cubes.len());
for cube in model.cubes_iter() {
cubes.push(CubeView {
name: cube.name.to_string(),
is_view: cube.is_view,
measure_count: cube.measures.len(),
dimension_count: cube.dimensions.len(),
segment_count: cube.segments.len(),
join_count: cube.joins.len(),
pre_aggregation_count: cube.pre_aggregations.len(),
access_policy_count: cube.access_policies.len(),
});
}
// Stable order so the JS test can match by index.
cubes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ModelView { cubes })
}

fn model_describe(cx: FunctionContext) -> JsResult<JsValue> {
neon_guarded_funcion_call(
cx,
|context_holder: NativeContextHolder<_>, obj: NativeObjectHandle<_>| {
model_describe_inner(context_holder, obj)
},
)
}

pub fn register_module(cx: &mut ModuleContext) -> NeonResult<()> {
cx.export_function("__testBridgeCompileMemberSql", compile_member_sql)?;
cx.export_function("__testBridgeParseArgsNames", parse_args_names)?;
Expand All @@ -1005,5 +1073,6 @@ pub fn register_module(cx: &mut ModuleContext) -> NeonResult<()> {
cx.export_function("__testBridgeRustBoxCreate", rust_box_create)?;
cx.export_function("__testBridgeRustBoxCreateAlt", rust_box_create_alt)?;
cx.export_function("__testBridgeRustBoxUnwrap", rust_box_unwrap)?;
cx.export_function("__testBridgeModelDescribe", model_describe)?;
Ok(())
}
51 changes: 50 additions & 1 deletion packages/cubejs-backend-native/src/node_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ use crate::stream::{OnCloseHandler, OnDrainHandler};
use crate::tokio_runtime_node;
use crate::transport::NodeBridgeTransport;
use crate::utils::{batch_to_rows, NonDebugInRelease};
use cubenativeutils::wrappers::inner_types::InnerTypes;
use cubenativeutils::wrappers::neon::neon_guarded_funcion_call;
use cubenativeutils::wrappers::NativeContextHolder;
use cubenativeutils::wrappers::object::{NativeRustBox, NativeType};
use cubenativeutils::wrappers::rust_handle::NativeRustHandle;
use cubenativeutils::wrappers::{NativeContextHolder, NativeObjectHandle};
use cubesqlplanner::cube_bridge::base_query_options::NativeBaseQueryOptions;
use cubesqlplanner::cube_bridge::schema_source::{NativeSchemaSource, SchemaSource};
use cubesqlplanner::model::{Model, SchemaModelBuilder};
use cubesqlplanner::planner::base_query::BaseQuery;
use std::rc::Rc;
use std::sync::Arc;
Expand Down Expand Up @@ -789,6 +794,48 @@ fn build_sql_and_params(cx: FunctionContext) -> JsResult<JsValue> {
)
}

/// Build the Tesseract domain Model from a JS `SchemaSource` and
/// return a `JsBox<NativeRustHandle>` that JS can keep across calls.
///
/// This is the entry point for the model-caching flow: JS calls
/// `prepareModel(schemaSource)` once after schema compilation; later
/// `buildSqlAndParams` calls go through `modelBuildSqlAndParams` with
/// that handle.
fn prepare_model_inner<IT: InnerTypes>(
context_holder: NativeContextHolder<IT>,
source: NativeSchemaSource<IT>,
) -> Result<NativeObjectHandle<IT>, cubenativeutils::CubeError> {
let source: Rc<dyn SchemaSource> = Rc::new(source);
let model = SchemaModelBuilder::new(source).build()?;
let handle = NativeRustHandle::new(model);
let rust_box = context_holder.rust_box(handle)?;
Ok(NativeObjectHandle::new(rust_box.into_object()))
}

fn prepare_model(cx: FunctionContext) -> JsResult<JsValue> {
neon_guarded_funcion_call(cx, prepare_model_inner)
}

/// Same signature as `buildSqlAndParams` but takes the model handle
/// returned by `prepareModel` as the first argument. The handle is
/// downcast back to `Model` so future planner code can read structure
/// from it; for now we just validate the round-trip and delegate to
/// the existing per-request `BaseQuery` flow.
fn model_build_sql_and_params_inner<IT: InnerTypes>(
context_holder: NativeContextHolder<IT>,
model_handle: NativeObjectHandle<IT>,
options: NativeBaseQueryOptions<IT>,
) -> Result<NativeObjectHandle<IT>, cubenativeutils::CubeError> {
let rust_box = model_handle.into_rust_box()?;
let _model = rust_box.handle().downcast::<Model>()?;
let base_query = BaseQuery::try_new(context_holder.clone(), Rc::new(options))?;
base_query.build_sql_and_params()
}

fn model_build_sql_and_params(cx: FunctionContext) -> JsResult<JsValue> {
neon_guarded_funcion_call(cx, model_build_sql_and_params_inner)
}

fn debug_js_to_clrepr_to_js(mut cx: FunctionContext) -> JsResult<JsValue> {
let arg = cx.argument::<JsValue>(0)?;
let arg_clrep = CLRepr::from_js_ref(arg, &mut cx)?;
Expand All @@ -811,6 +858,8 @@ pub fn register_module_exports<C: NodeConfiguration + 'static>(

//============ sql planner exports ===================
cx.export_function("buildSqlAndParams", build_sql_and_params)?;
cx.export_function("prepareModel", prepare_model)?;
cx.export_function("modelBuildSqlAndParams", model_build_sql_and_params)?;

//========= sql orchestrator exports =================
crate::orchestrator::register_module(&mut cx)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ export const cubeDefinitionFixture = (): unknown => ({
// sqlAlias, isView, isCalendar, joinMap optional
// sql_table, sql optional getters
defaultFilters: [viewFilterDefinitionFixture()],
// measures/dimensions/segments are required vec fields
measures: [measureDefinitionFixture()],
dimensions: [dimensionDefinitionFixture()],
segments: [segmentDefinitionFixture()],
// joins/preAggregations/accessPolicy/includedMembers are optional vec
// getters — omitted, the getters return None
});

export const dimensionDefinitionFixture = (): unknown => ({
Expand Down
23 changes: 23 additions & 0 deletions packages/cubejs-backend-native/test/bridge/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,26 @@ export function createRustBoxProbeAlt(note: string): unknown {
export function unwrapRustBoxProbe(handle: unknown): RustBoxProbeView {
return native.__testBridgeRustBoxUnwrap(handle);
}

export interface ModelCubeView {
name: string;
is_view: boolean;
measure_count: number;
dimension_count: number;
segment_count: number;
join_count: number;
pre_aggregation_count: number;
access_policy_count: number;
}

export interface ModelView {
cubes: ModelCubeView[];
}

export function prepareModelRaw(schemaSource: unknown): unknown {
return native.prepareModel(schemaSource);
}

export function describeModel(handle: unknown): ModelView {
return native.__testBridgeModelDescribe(handle);
}
112 changes: 112 additions & 0 deletions packages/cubejs-backend-native/test/bridge/model-roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
bridgeHarnessAvailable,
describeModel,
prepareModelRaw as prepareModel,
} from './helpers';

const describeBridge = bridgeHarnessAvailable ? describe : describe.skip;

/**
* Minimal stand-in for the production `SchemaSource` (from
* cubejs-schema-compiler). The Rust side only consumes `primaryKeys`
* and `cubes()`; each cube shape mirrors what the real
* `SchemaSource.cubes()` wrapper exposes after prepareCompiler runs.
*/
function makeSchemaSource(cubes: any[], primaryKeys: Record<string, string[]> = {}) {
return {
primaryKeys,
cubes: () => cubes,
};
}

function makeCube(overrides: Partial<any> = {}): any {
return {
name: 'Users',
sqlAlias: undefined,
isView: false,
calendar: false,
measures: [],
dimensions: [],
segments: [],
joins: [],
preAggregations: [],
accessPolicy: [],
includedMembers: [],
...overrides,
};
}

describeBridge('bridge: model roundtrip via prepareModel / __testBridgeModelDescribe', () => {
it('returns the cubes in the model with member counts', () => {
const source = makeSchemaSource(
[
makeCube({
name: 'Users',
measures: [
{ name: 'count', type: 'count', ownedByCube: true },
{ name: 'total', type: 'sum', ownedByCube: true },
],
dimensions: [
{ name: 'id', type: 'number', primaryKey: true, ownedByCube: true },
{ name: 'status', type: 'string', ownedByCube: true },
],
}),
makeCube({
name: 'Orders',
measures: [{ name: 'count', type: 'count', ownedByCube: true }],
dimensions: [{ name: 'id', type: 'number', primaryKey: true, ownedByCube: true }],
}),
],
{
Users: ['id'],
Orders: ['id'],
}
);

const handle = prepareModel(source);
const view = describeModel(handle);

// SchemaModelBuilder iterates by insertion order from JS, but the
// describe helper sorts alphabetically.
expect(view.cubes.map(c => c.name)).toEqual(['Orders', 'Users']);

const users = view.cubes.find(c => c.name === 'Users')!;
expect(users.measure_count).toBe(2);
expect(users.dimension_count).toBe(2);
expect(users.is_view).toBe(false);

const orders = view.cubes.find(c => c.name === 'Orders')!;
expect(orders.measure_count).toBe(1);
expect(orders.dimension_count).toBe(1);
});

it('the handle survives multiple describe calls', () => {
const source = makeSchemaSource(
[makeCube({ name: 'Users', measures: [{ name: 'count', type: 'count' }] })],
{ Users: [] }
);
const handle = prepareModel(source);
for (let i = 0; i < 50; i += 1) {
const view = describeModel(handle);
expect(view.cubes).toHaveLength(1);
expect(view.cubes[0].measure_count).toBe(1);
}
});

it('different handles refer to independent models', () => {
const a = prepareModel(
makeSchemaSource([makeCube({ name: 'A' })])
);
const b = prepareModel(
makeSchemaSource([makeCube({ name: 'B' }), makeCube({ name: 'C' })])
);

expect(describeModel(a).cubes.map(c => c.name)).toEqual(['A']);
expect(describeModel(b).cubes.map(c => c.name)).toEqual(['B', 'C']);
});

it('describeModel rejects non-RustBox arguments and wrong-type boxes', () => {
expect(() => describeModel({})).toThrow(/Object is not a Rust box/);
expect(() => describeModel(null)).toThrow(/Object is not a Rust box/);
});
});
Loading
Loading