diff --git a/packages/cubejs-backend-native/src/bridge_test_exports.rs b/packages/cubejs-backend-native/src/bridge_test_exports.rs index bbc9cb329a796..6bc9cd8389407 100644 --- a/packages/cubejs-backend-native/src/bridge_test_exports.rs +++ b/packages/cubejs-backend-native/src/bridge_test_exports.rs @@ -9,29 +9,154 @@ //! 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::bridge_meta::{BridgeFieldKind, BridgeFieldMeta}; 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::{ + base_query_options::{ + base_query_options_bridge_fields_meta, BaseQueryOptions, NativeBaseQueryOptions, + }, + base_tools::{base_tools_bridge_fields_meta, BaseTools, NativeBaseTools}, + case_definition::{case_definition_bridge_fields_meta, CaseDefinition, NativeCaseDefinition}, + case_else_item::{case_else_item_bridge_fields_meta, CaseElseItem, NativeCaseElseItem}, + case_item::{case_item_bridge_fields_meta, CaseItem, NativeCaseItem}, + case_switch_definition::{ + case_switch_definition_bridge_fields_meta, CaseSwitchDefinition, NativeCaseSwitchDefinition, + }, + case_switch_else_item::{ + case_switch_else_item_bridge_fields_meta, CaseSwitchElseItem, NativeCaseSwitchElseItem, + }, + case_switch_item::{case_switch_item_bridge_fields_meta, CaseSwitchItem, NativeCaseSwitchItem}, + cube_definition::{cube_definition_bridge_fields_meta, CubeDefinition, NativeCubeDefinition}, + dimension_definition::{ + dimension_definition_bridge_fields_meta, DimensionDefinition, NativeDimensionDefinition, + }, + driver_tools::{driver_tools_bridge_fields_meta, DriverTools, NativeDriverTools}, + evaluator::{cube_evaluator_bridge_fields_meta, CubeEvaluator, NativeCubeEvaluator}, + filter_group::{filter_group_bridge_fields_meta, NativeFilterGroup}, + filter_params::{filter_params_bridge_fields_meta, NativeFilterParams}, + filter_params_callback::{FilterParamsCallback, NativeFilterParamsCallback}, + geo_item::{geo_item_bridge_fields_meta, GeoItem, NativeGeoItem}, + granularity_definition::{ + granularity_definition_bridge_fields_meta, GranularityDefinition, + NativeGranularityDefinition, + }, + join_definition::{join_definition_bridge_fields_meta, JoinDefinition, NativeJoinDefinition}, + join_graph::{join_graph_bridge_fields_meta, JoinGraph, NativeJoinGraph}, + join_hints::JoinHintItem, + join_item::{join_item_bridge_fields_meta, JoinItem, NativeJoinItem}, + join_item_definition::{ + join_item_definition_bridge_fields_meta, JoinItemDefinition, NativeJoinItemDefinition, + }, + measure_definition::{ + measure_definition_bridge_fields_meta, MeasureDefinition, NativeMeasureDefinition, + }, + member_definition::{ + member_definition_bridge_fields_meta, MemberDefinition, NativeMemberDefinition, + }, + member_expression::{ + expression_struct_bridge_fields_meta, member_expression_definition_bridge_fields_meta, + ExpressionStruct, MemberExpressionDefinition, NativeExpressionStruct, + NativeMemberExpressionDefinition, + }, + member_order_by::{member_order_by_bridge_fields_meta, MemberOrderBy, NativeMemberOrderBy}, + member_sql::{ + FilterGroupItem, FilterParamsItem, MemberSql, NativeMemberSql, SqlTemplate, SqlTemplateArgs, + }, + pre_aggregation_description::{ + pre_aggregation_description_bridge_fields_meta, NativePreAggregationDescription, + PreAggregationDescription, + }, + pre_aggregation_obj::{ + pre_aggregation_obj_bridge_fields_meta, NativePreAggregationObj, PreAggregationObj, + }, + pre_aggregation_time_dimension::{ + pre_aggregation_time_dimension_bridge_fields_meta, NativePreAggregationTimeDimension, + PreAggregationTimeDimension, + }, + security_context::{ + security_context_bridge_fields_meta, NativeSecurityContext, SecurityContext, + }, + segment_definition::{ + segment_definition_bridge_fields_meta, NativeSegmentDefinition, SegmentDefinition, + }, + sql_templates_render::SqlTemplatesRender, + sql_utils::{sql_utils_bridge_fields_meta, NativeSqlUtils, SqlUtils}, + struct_with_sql_member::{ + struct_with_sql_member_bridge_fields_meta, NativeStructWithSqlMember, StructWithSqlMember, + }, + timeshift_definition::{ + time_shift_definition_bridge_fields_meta, NativeTimeShiftDefinition, TimeShiftDefinition, + }, }; -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::collections::HashSet; use std::rc::Rc; +enum InvokeStatus { + Ok, + Err(String), + Skipped(String), +} + +struct InvokeResult { + entries: Vec<(&'static str, InvokeStatus)>, +} + +impl InvokeResult { + fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + fn record(&mut self, name: &'static str, result: Result) { + let status = match result { + Ok(_) => InvokeStatus::Ok, + Err(e) => InvokeStatus::Err(e.to_string()), + }; + self.entries.push((name, status)); + } + + fn skip(&mut self, name: &'static str, reason: &'static str) { + self.entries + .push((name, InvokeStatus::Skipped(reason.to_string()))); + } + + fn invoked_names(&self) -> HashSet<&'static str> { + self.entries.iter().map(|(n, _)| *n).collect() + } + + fn to_native( + &self, + ctx: NativeContextHolder, + ) -> Result, CubeError> { + let map = ctx.empty_struct()?; + for (name, status) in &self.entries { + let entry = ctx.empty_struct()?; + match status { + InvokeStatus::Ok => { + entry.set_field("status", "ok".to_string().to_native(ctx.clone())?)?; + } + InvokeStatus::Err(msg) => { + entry.set_field("status", "error".to_string().to_native(ctx.clone())?)?; + entry.set_field("message", msg.to_string().to_native(ctx.clone())?)?; + } + InvokeStatus::Skipped(reason) => { + entry.set_field("status", "skipped".to_string().to_native(ctx.clone())?)?; + entry.set_field("reason", reason.to_string().to_native(ctx.clone())?)?; + } + } + map.set_field(name, NativeObjectHandle::new(entry.into_object()))?; + } + Ok(NativeObjectHandle::new(map.into_object())) + } +} + struct StubBaseTools; fn stub_err(method: &str) -> CubeError { @@ -247,6 +372,501 @@ fn invoke_filter_params_callback(cx: FunctionContext) -> JsResult { ) } +fn unknown_bridge_err(name: &str) -> CubeError { + CubeError::user(format!( + "Unknown bridge type: {} (test harness dispatcher does not register it)", + name + )) +} + +macro_rules! bridge_registry { + ( $( $key:literal => $native:ident , $meta_fn:path , $invoke_fn:path );* $(;)? ) => { + fn fields_meta_for_bridge(name: &str) -> Result, CubeError> { + match name { + $( $key => Ok($meta_fn()), )* + other => Err(unknown_bridge_err(other)), + } + } + + fn try_new_bridge( + name: &str, + obj: NativeObjectHandle, + ) -> Result<(), CubeError> { + match name { + $( $key => { $native::try_new(obj)?; Ok(()) } )* + other => Err(unknown_bridge_err(other)), + } + } + + fn invoke_bridge_dispatch( + name: &str, + obj: NativeObjectHandle, + ) -> Result { + match name { + $( $key => { + let bridge = $native::try_new(obj)?; + Ok($invoke_fn(&bridge)) + } )* + other => Err(unknown_bridge_err(other)), + } + } + + fn registered_bridge_names() -> &'static [&'static str] { + &[ $( $key ),* ] + } + }; +} + +bridge_registry! { + "baseQueryOptions" => NativeBaseQueryOptions, base_query_options_bridge_fields_meta, invoke_base_query_options; + "baseTools" => NativeBaseTools, base_tools_bridge_fields_meta, invoke_base_tools; + "caseDefinition" => NativeCaseDefinition, case_definition_bridge_fields_meta, invoke_case_definition; + "caseElseItem" => NativeCaseElseItem, case_else_item_bridge_fields_meta, invoke_case_else_item; + "caseItem" => NativeCaseItem, case_item_bridge_fields_meta, invoke_case_item; + "caseSwitchDefinition" => NativeCaseSwitchDefinition, case_switch_definition_bridge_fields_meta, invoke_case_switch_definition; + "caseSwitchElseItem" => NativeCaseSwitchElseItem, case_switch_else_item_bridge_fields_meta, invoke_case_switch_else_item; + "caseSwitchItem" => NativeCaseSwitchItem, case_switch_item_bridge_fields_meta, invoke_case_switch_item; + "cubeDefinition" => NativeCubeDefinition, cube_definition_bridge_fields_meta, invoke_cube_definition; + "cubeEvaluator" => NativeCubeEvaluator, cube_evaluator_bridge_fields_meta, invoke_cube_evaluator; + "dimensionDefinition" => NativeDimensionDefinition, dimension_definition_bridge_fields_meta, invoke_dimension_definition; + "driverTools" => NativeDriverTools, driver_tools_bridge_fields_meta, invoke_driver_tools; + "expressionStruct" => NativeExpressionStruct, expression_struct_bridge_fields_meta, invoke_expression_struct; + "filterGroup" => NativeFilterGroup, filter_group_bridge_fields_meta, invoke_filter_group; + "filterParams" => NativeFilterParams, filter_params_bridge_fields_meta, invoke_filter_params; + "geoItem" => NativeGeoItem, geo_item_bridge_fields_meta, invoke_geo_item; + "granularityDefinition" => NativeGranularityDefinition, granularity_definition_bridge_fields_meta, invoke_granularity_definition; + "joinDefinition" => NativeJoinDefinition, join_definition_bridge_fields_meta, invoke_join_definition; + "joinGraph" => NativeJoinGraph, join_graph_bridge_fields_meta, invoke_join_graph; + "joinItem" => NativeJoinItem, join_item_bridge_fields_meta, invoke_join_item; + "joinItemDefinition" => NativeJoinItemDefinition, join_item_definition_bridge_fields_meta, invoke_join_item_definition; + "measureDefinition" => NativeMeasureDefinition, measure_definition_bridge_fields_meta, invoke_measure_definition; + "memberDefinition" => NativeMemberDefinition, member_definition_bridge_fields_meta, invoke_member_definition; + "memberExpressionDefinition" => NativeMemberExpressionDefinition, member_expression_definition_bridge_fields_meta, invoke_member_expression_definition; + "memberOrderBy" => NativeMemberOrderBy, member_order_by_bridge_fields_meta, invoke_member_order_by; + "preAggregationDescription" => NativePreAggregationDescription, pre_aggregation_description_bridge_fields_meta, invoke_pre_aggregation_description; + "preAggregationObj" => NativePreAggregationObj, pre_aggregation_obj_bridge_fields_meta, invoke_pre_aggregation_obj; + "preAggregationTimeDimension" => NativePreAggregationTimeDimension, pre_aggregation_time_dimension_bridge_fields_meta, invoke_pre_aggregation_time_dimension; + "securityContext" => NativeSecurityContext, security_context_bridge_fields_meta, invoke_security_context; + "segmentDefinition" => NativeSegmentDefinition, segment_definition_bridge_fields_meta, invoke_segment_definition; + "sqlUtils" => NativeSqlUtils, sql_utils_bridge_fields_meta, invoke_sql_utils; + "structWithSqlMember" => NativeStructWithSqlMember, struct_with_sql_member_bridge_fields_meta, invoke_struct_with_sql_member; + "timeShiftDefinition" => NativeTimeShiftDefinition, time_shift_definition_bridge_fields_meta, invoke_time_shift_definition; +} + +fn list_bridge_fields_inner( + context_holder: NativeContextHolder, + name: String, +) -> Result, CubeError> { + let meta = fields_meta_for_bridge(&name)?; + let arr = context_holder.empty_array()?; + for (i, m) in meta.iter().enumerate() { + let entry = context_holder.empty_struct()?; + entry.set_field( + "name", + m.name.to_string().to_native(context_holder.clone())?, + )?; + entry.set_field( + "jsName", + m.js_name.to_string().to_native(context_holder.clone())?, + )?; + entry.set_field( + "kind", + m.kind + .as_str() + .to_string() + .to_native(context_holder.clone())?, + )?; + entry.set_field("optional", m.optional.to_native(context_holder.clone())?)?; + entry.set_field("vec", m.vec.to_native(context_holder.clone())?)?; + arr.set(i as u32, NativeObjectHandle::new(entry.into_object()))?; + } + Ok(NativeObjectHandle::new(arr.into_object())) +} + +fn list_bridge_fields(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, name: String| { + list_bridge_fields_inner(context_holder, name) + }, + ) +} + +fn parse_bridge_inner( + context_holder: NativeContextHolder, + name: String, + obj: NativeObjectHandle, +) -> Result, CubeError> { + try_new_bridge(&name, obj)?; + true.to_native(context_holder) +} + +fn parse_bridge(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, name: String, obj: NativeObjectHandle<_>| { + parse_bridge_inner(context_holder, name, obj) + }, + ) +} + +fn invoke_bridge_inner( + context_holder: NativeContextHolder, + name: String, + obj: NativeObjectHandle, +) -> Result, CubeError> { + let meta = fields_meta_for_bridge(&name)?; + let result = invoke_bridge_dispatch(&name, obj)?; + + // Guard: per-bridge `invoke_` must touch every field/call that the + // macro emits in meta. Drift here means a new trait method landed without + // a matching invoke entry — silent coverage loss otherwise. + let expected: HashSet<&'static str> = meta + .iter() + .filter(|m| matches!(m.kind, BridgeFieldKind::Field | BridgeFieldKind::Call)) + .map(|m| m.name) + .collect(); + let invoked = result.invoked_names(); + if invoked != expected { + let missing: Vec<_> = expected.difference(&invoked).copied().collect(); + let extra: Vec<_> = invoked.difference(&expected).copied().collect(); + return Err(CubeError::internal(format!( + "invoke dispatcher out of sync with bridge_fields_meta for '{}': missing={:?}, extra={:?}", + name, missing, extra, + ))); + } + + result.to_native(context_holder) +} + +fn invoke_bridge(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call( + cx, + |context_holder: NativeContextHolder<_>, name: String, obj: NativeObjectHandle<_>| { + invoke_bridge_inner(context_holder, name, obj) + }, + ) +} + +fn list_bridge_names_inner( + context_holder: NativeContextHolder, +) -> Result, CubeError> { + let names = registered_bridge_names(); + let arr = context_holder.empty_array()?; + for (i, n) in names.iter().enumerate() { + arr.set(i as u32, n.to_string().to_native(context_holder.clone())?)?; + } + Ok(NativeObjectHandle::new(arr.into_object())) +} + +fn list_bridge_names(cx: FunctionContext) -> JsResult { + neon_guarded_funcion_call(cx, |context_holder: NativeContextHolder<_>| { + list_bridge_names_inner(context_holder) + }) +} + +// --------------------------------------------------------------------------- +// Per-bridge invoke dispatchers. +// +// Every `#[nbridge(field)]` getter and every plain `#[nbridge]` call-method +// is invoked once. Field-getters get `r.record(name, bridge.method())` — +// success means the JS-side value was successfully read and deserialized. +// Call-methods get `r.record(name, bridge.method())` for +// methods whose argument types have an obvious default (String, bool, Vec, +// HashMap, JoinHintItem). Methods whose arguments include `Rc` or +// other custom types that cannot be synthesized in Rust without a real JS +// object are skipped via `r.skip(name, reason)`. +// +// The `invoke_bridge_inner` endpoint guards drift: if a method appears in +// `_bridge_fields_meta()` but is not recorded here, the whole +// invocation fails — silent coverage holes are not possible. +// --------------------------------------------------------------------------- + +fn invoke_filter_group(_b: &NativeFilterGroup) -> InvokeResult { + InvokeResult::new() +} +fn invoke_filter_params(_b: &NativeFilterParams) -> InvokeResult { + InvokeResult::new() +} +fn invoke_security_context(_b: &NativeSecurityContext) -> InvokeResult { + InvokeResult::new() +} +fn invoke_sql_utils(_b: &NativeSqlUtils) -> InvokeResult { + InvokeResult::new() +} +fn invoke_pre_aggregation_obj(_b: &NativePreAggregationObj) -> InvokeResult { + InvokeResult::new() +} + +fn invoke_geo_item(b: &NativeGeoItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_struct_with_sql_member( + b: &NativeStructWithSqlMember, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_case_else_item(b: &NativeCaseElseItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("label", b.label()); + r +} +fn invoke_case_item(b: &NativeCaseItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r.record("label", b.label()); + r +} +fn invoke_case_definition(b: &NativeCaseDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("when", b.when()); + r.record("else_label", b.else_label()); + r +} +fn invoke_case_switch_else_item(b: &NativeCaseSwitchElseItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_case_switch_item(b: &NativeCaseSwitchItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_case_switch_definition( + b: &NativeCaseSwitchDefinition, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("switch", b.switch()); + r.record("when", b.when()); + r.record("else_sql", b.else_sql()); + r +} + +fn invoke_member_order_by(b: &NativeMemberOrderBy) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r.record("dir", b.dir()); + r +} + +fn invoke_member_definition(b: &NativeMemberDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} + +fn invoke_segment_definition(b: &NativeSegmentDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} + +fn invoke_join_item_definition(b: &NativeJoinItemDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_join_item(b: &NativeJoinItem) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("join", b.join()); + r +} +fn invoke_join_definition(b: &NativeJoinDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("joins", b.joins()); + r +} +fn invoke_join_graph(b: &NativeJoinGraph) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("build_join", b.build_join(vec![])); + r +} + +fn invoke_granularity_definition( + b: &NativeGranularityDefinition, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} +fn invoke_pre_aggregation_time_dimension( + b: &NativePreAggregationTimeDimension, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("dimension", b.dimension()); + r +} + +fn invoke_time_shift_definition(b: &NativeTimeShiftDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r +} + +fn invoke_cube_definition(b: &NativeCubeDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql_table", b.sql_table()); + r.record("sql", b.sql()); + r +} + +fn invoke_dimension_definition(b: &NativeDimensionDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r.record("case", b.case()); + r.record("latitude", b.latitude()); + r.record("longitude", b.longitude()); + r.record("time_shift", b.time_shift()); + r.record("mask_sql", b.mask_sql()); + r +} + +fn invoke_measure_definition(b: &NativeMeasureDefinition) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("sql", b.sql()); + r.record("case", b.case()); + r.record("filters", b.filters()); + r.record("drill_filters", b.drill_filters()); + r.record("order_by", b.order_by()); + r.record("mask_sql", b.mask_sql()); + r +} + +fn invoke_expression_struct(b: &NativeExpressionStruct) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("add_filters", b.add_filters()); + r +} + +fn invoke_member_expression_definition( + b: &NativeMemberExpressionDefinition, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("expression", b.expression()); + r +} + +fn invoke_pre_aggregation_description( + b: &NativePreAggregationDescription, +) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("measure_references", b.measure_references()); + r.record("dimension_references", b.dimension_references()); + r.record("time_dimension_reference", b.time_dimension_reference()); + r.record("segment_references", b.segment_references()); + r.record("rollup_references", b.rollup_references()); + r.record("time_dimension_references", b.time_dimension_references()); + r +} + +fn invoke_base_query_options(b: &NativeBaseQueryOptions) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("measures", b.measures()); + r.record("dimensions", b.dimensions()); + r.record("segments", b.segments()); + r.record("cube_evaluator", b.cube_evaluator()); + r.record("base_tools", b.base_tools()); + r.record("join_graph", b.join_graph()); + r.record("security_context", b.security_context()); + r.record("join_hints", b.join_hints()); + r +} + +fn invoke_base_tools(b: &NativeBaseTools) -> InvokeResult { + let mut r = InvokeResult::new(); + r.record("driver_tools", b.driver_tools(false)); + r.record("sql_templates", b.sql_templates()); + r.record("sql_utils_for_rust", b.sql_utils_for_rust()); + r.record( + "generate_time_series", + b.generate_time_series("day".to_string(), vec![]), + ); + r.record( + "generate_custom_time_series", + b.generate_custom_time_series("day".to_string(), vec![], "2024-01-01".to_string()), + ); + r.record("get_allocated_params", b.get_allocated_params()); + r.record("all_cube_members", b.all_cube_members("Orders".to_string())); + r.record( + "interval_and_minimal_time_unit", + b.interval_and_minimal_time_unit("1 day".to_string()), + ); + r.record( + "get_pre_aggregation_by_name", + b.get_pre_aggregation_by_name("Orders".to_string(), "main".to_string()), + ); + r.record( + "pre_aggregation_table_name", + b.pre_aggregation_table_name("Orders".to_string(), "main".to_string()), + ); + r.record("join_tree_for_hints", b.join_tree_for_hints(vec![])); + r +} + +fn invoke_driver_tools(b: &NativeDriverTools) -> InvokeResult { + let mut r = InvokeResult::new(); + let s = || "x".to_string(); + r.record("convert_tz", b.convert_tz(s())); + r.record("time_grouped_column", b.time_grouped_column(s(), s())); + r.record("sql_templates", b.sql_templates()); + r.record("timestamp_precision", b.timestamp_precision()); + r.record("time_stamp_cast", b.time_stamp_cast(s())); + r.record("date_time_cast", b.date_time_cast(s())); + r.record("in_db_time_zone", b.in_db_time_zone(s())); + r.record("get_allocated_params", b.get_allocated_params()); + r.record("subtract_interval", b.subtract_interval(s(), s())); + r.record("add_interval", b.add_interval(s(), s())); + r.record("interval_string", b.interval_string(s())); + r.record("add_timestamp_interval", b.add_timestamp_interval(s(), s())); + r.record( + "interval_and_minimal_time_unit", + b.interval_and_minimal_time_unit(s()), + ); + r.record("hll_init", b.hll_init(s())); + r.record("hll_merge", b.hll_merge(s())); + r.record("hll_cardinality_merge", b.hll_cardinality_merge(s())); + r.record("count_distinct_approx", b.count_distinct_approx(s())); + r.record( + "support_generated_series_for_custom_td", + b.support_generated_series_for_custom_td(), + ); + r.record("date_bin", b.date_bin(s(), s(), s())); + r +} + +fn invoke_cube_evaluator(b: &NativeCubeEvaluator) -> InvokeResult { + let mut r = InvokeResult::new(); + let s = || "x".to_string(); + r.record("parse_path", b.parse_path(s(), s())); + r.record("measure_by_path", b.measure_by_path(s())); + r.record("dimension_by_path", b.dimension_by_path(s())); + r.record("segment_by_path", b.segment_by_path(s())); + r.record("cube_from_path", b.cube_from_path(s())); + r.record("is_measure", b.is_measure(vec![s()])); + r.record("is_dimension", b.is_dimension(vec![s()])); + r.record("is_segment", b.is_segment(vec![s()])); + r.record("cube_exists", b.cube_exists(s())); + r.record("resolve_granularity", b.resolve_granularity(vec![s()])); + r.record( + "pre_aggregations_for_cube_as_array", + b.pre_aggregations_for_cube_as_array(s()), + ); + r.record( + "pre_aggregation_description_by_name", + b.pre_aggregation_description_by_name(s(), s()), + ); + r.skip( + "evaluate_rollup_references", + "Rc argument has no auto-default in Rust", + ); + r +} + pub fn register_module(cx: &mut ModuleContext) -> NeonResult<()> { cx.export_function("__testBridgeCompileMemberSql", compile_member_sql)?; cx.export_function("__testBridgeParseArgsNames", parse_args_names)?; @@ -254,5 +874,9 @@ pub fn register_module(cx: &mut ModuleContext) -> NeonResult<()> { "__testBridgeInvokeFilterParamsCallback", invoke_filter_params_callback, )?; + cx.export_function("__testBridgeListFields", list_bridge_fields)?; + cx.export_function("__testBridgeParse", parse_bridge)?; + cx.export_function("__testBridgeInvoke", invoke_bridge)?; + cx.export_function("__testBridgeListBridgeNames", list_bridge_names)?; Ok(()) } diff --git a/packages/cubejs-backend-native/test/bridge/args-names.test.ts b/packages/cubejs-backend-native/test/bridge/args-names.test.ts index c61b29c4c7eeb..cb541a4f89aea 100644 --- a/packages/cubejs-backend-native/test/bridge/args-names.test.ts +++ b/packages/cubejs-backend-native/test/bridge/args-names.test.ts @@ -29,51 +29,34 @@ describeBridge('bridge: args_names parser', () => { }); }); - // 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', () => { + describe('edge cases', () => { + it('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', () => { + it('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', () => { + it('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', () => { + it('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', () => { + it('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', () => { + it('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/bridge-fixtures.ts b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts new file mode 100644 index 0000000000000..327d635ace8ad --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts @@ -0,0 +1,273 @@ +// Reference JS-side shapes for every macro-generated bridge. +// +// Each factory returns a fresh value that must satisfy: +// 1. `NativeX::try_new` — required trait fields and required serde-static +// fields must be present with the right types. +// 2. Per-bridge invoke dispatcher in bridge_test_exports.rs — every +// `field` getter must deserialize successfully, and every `call` method +// stub must return a value that marshals back into the declared Rust +// return type. +// +// Treat these factories as the executable contract documenting what +// schema-compiler and friends are expected to hand to Tesseract for each +// bridge. If a Rust trait gains a new method, the bridge_registry guard +// will fire from the Rust side; if a JS shape stops matching, the invoke +// check fires here. +// +// Keys are JS-side identifiers (post-`#[serde(rename)]`, camelCase for trait +// methods that the macro auto-converts). + +/* eslint-disable @typescript-eslint/no-empty-function */ + +export const memberSqlFn = (): unknown => () => 'sql'; + +export const filterGroupFixture = (): unknown => ({}); +export const filterParamsFixture = (): unknown => ({}); +export const securityContextFixture = (): unknown => ({}); +export const sqlUtilsFixture = (): unknown => ({}); +export const preAggregationObjFixture = (): unknown => ({}); + +export const geoItemFixture = (): unknown => ({ + sql: memberSqlFn(), +}); + +export const structWithSqlMemberFixture = (): unknown => ({ + sql: memberSqlFn(), +}); + +// CaseElseItem.label -> StringOrSql; deserializer tries String first. +export const caseElseItemFixture = (): unknown => ({ + label: 'else', +}); + +export const caseItemFixture = (): unknown => ({ + sql: memberSqlFn(), + label: 'when_label', +}); + +export const caseDefinitionFixture = (): unknown => ({ + when: [caseItemFixture()], + // CaseDefinition.else_label is renamed to "else" via #[nbridge(rename = "else")]. + else: caseElseItemFixture(), +}); + +export const caseSwitchElseItemFixture = (): unknown => ({ + sql: memberSqlFn(), +}); + +export const caseSwitchItemFixture = (): unknown => ({ + value: 'v', + sql: memberSqlFn(), +}); + +export const caseSwitchDefinitionFixture = (): unknown => ({ + switch: memberSqlFn(), + when: [caseSwitchItemFixture()], + // CaseSwitchDefinition.else_sql is renamed to "else" via #[nbridge(rename = "else")]. + else: caseSwitchElseItemFixture(), +}); + +export const memberOrderByFixture = (): unknown => ({ + sql: memberSqlFn(), + dir: 'asc', +}); + +export const memberDefinitionFixture = (): unknown => ({ + type: 'dimension', + // sql is optional +}); + +export const segmentDefinitionFixture = (): unknown => ({ + sql: memberSqlFn(), + // segment_type, owned_by_cube optional +}); + +export const joinItemDefinitionFixture = (): unknown => ({ + relationship: 'many_to_one', + sql: memberSqlFn(), +}); + +export const joinItemFixture = (): unknown => ({ + from: 'orders', + to: 'users', + originalFrom: 'orders', + originalTo: 'users', + join: joinItemDefinitionFixture(), +}); + +export const joinDefinitionFixture = (): unknown => ({ + root: 'orders', + multiplicationFactor: {}, + joins: [joinItemFixture()], +}); + +export const joinGraphFixture = (): unknown => ({ + buildJoin: () => joinDefinitionFixture(), +}); + +export const granularityDefinitionFixture = (): unknown => ({ + interval: '1 day', + // origin, offset optional + sql: memberSqlFn(), +}); + +export const timeShiftDefinitionFixture = (): unknown => ({ + // all static optional, sql optional + sql: memberSqlFn(), + interval: '1 day', + type: 'prior', + name: 'last_day', +}); + +export const preAggregationTimeDimensionFixture = (): unknown => ({ + granularity: 'day', + dimension: memberSqlFn(), +}); + +export const preAggregationDescriptionFixture = (): unknown => ({ + name: 'main', + type: 'rollup', + // granularity, sqlAlias, external, allowNonStrictDateRangeMatch optional + // measure_references, dimension_references, etc — all optional getters +}); + +export const cubeDefinitionFixture = (): unknown => ({ + name: 'Orders', + // sqlAlias, isView, isCalendar, joinMap optional + // sql_table, sql optional getters +}); + +export const dimensionDefinitionFixture = (): unknown => ({ + type: 'string', + // owned_by_cube, multi_stage, etc. — all optional + // sql/case/latitude/longitude/time_shift/mask_sql — all optional getters +}); + +export const measureDefinitionFixture = (): unknown => ({ + type: 'count', + // owned_by_cube, multi_stage, reduce_by_references, etc. — all optional + // sql/case/filters/drill_filters/order_by/mask_sql — all optional getters +}); + +export const expressionStructFixture = (): unknown => ({ + type: 'PatchMeasure', + // sourceMeasure, replaceAggregationType, addFilters — all optional +}); + +export const memberExpressionDefinitionFixture = (): unknown => ({ + // expressionName, name, cubeName, definition — all optional + // expression — required, MemberExpressionExpressionDef tries MemberSql first + expression: memberSqlFn(), +}); + +// CubeEvaluator: every method is a `call`. Each stub must return a value +// that marshals into the declared Rust return type. The cascade is real — +// measureByPath has to hand back something that NativeMeasureDefinition +// can wrap, and so on. +export const cubeEvaluatorFixture = (): unknown => ({ + primaryKeys: {}, + parsePath: () => [], + measureByPath: () => measureDefinitionFixture(), + dimensionByPath: () => dimensionDefinitionFixture(), + segmentByPath: () => segmentDefinitionFixture(), + cubeFromPath: () => cubeDefinitionFixture(), + isMeasure: () => false, + isDimension: () => false, + isSegment: () => false, + cubeExists: () => false, + resolveGranularity: () => granularityDefinitionFixture(), + preAggregationsForCubeAsArray: () => [preAggregationDescriptionFixture()], + preAggregationDescriptionByName: () => preAggregationDescriptionFixture(), + // evaluate_rollup_references is invoke-skipped on the Rust side because + // its `Rc` argument has no auto-default, but the JS object + // still needs the key for try_new's has_field check. + evaluateRollupReferences: () => [], +}); + +export const driverToolsFixture = (): unknown => ({ + convertTz: () => 'tz', + timeGroupedColumn: () => 'col', + sqlTemplates: () => ({}), + timestampPrecision: () => 6, + timeStampCast: () => 'ts', + dateTimeCast: () => 'dt', + inDbTimeZone: () => 'tz', + getAllocatedParams: () => [], + subtractInterval: () => 'd', + addInterval: () => 'd', + intervalString: () => 's', + addTimestampInterval: () => 'd', + intervalAndMinimalTimeUnit: () => ['1', 'day'], + hllInit: () => 'h', + hllMerge: () => 'h', + hllCardinalityMerge: () => 'h', + countDistinctApprox: () => 'c', + supportGeneratedSeriesForCustomTd: () => false, + dateBin: () => 'b', +}); + +export const baseToolsFixture = (): unknown => ({ + driverTools: () => driverToolsFixture(), + sqlTemplates: () => ({}), + sqlUtilsForRust: () => sqlUtilsFixture(), + generateTimeSeries: () => [], + generateCustomTimeSeries: () => [], + getAllocatedParams: () => [], + allCubeMembers: () => [], + intervalAndMinimalTimeUnit: () => ['1', 'day'], + getPreAggregationByName: () => preAggregationObjFixture(), + preAggregationTableName: () => 'pre_aggr_table', + joinTreeForHints: () => joinDefinitionFixture(), +}); + +export const baseQueryOptionsFixture = (): unknown => ({ + // Static fields + exportAnnotatedSql: false, + disableExternalPreAggregations: false, + // Optional static — omitted intentionally; serde fills None. + // + // Trait fields (all `field, optional, vec` except the four required ones) + cubeEvaluator: cubeEvaluatorFixture(), + baseTools: baseToolsFixture(), + joinGraph: joinGraphFixture(), + securityContext: securityContextFixture(), + // Optional vec fields can be omitted +}); + +export type BridgeFixtureFactory = () => unknown; + +export const FIXTURES: Record = { + baseQueryOptions: baseQueryOptionsFixture, + baseTools: baseToolsFixture, + caseDefinition: caseDefinitionFixture, + caseElseItem: caseElseItemFixture, + caseItem: caseItemFixture, + caseSwitchDefinition: caseSwitchDefinitionFixture, + caseSwitchElseItem: caseSwitchElseItemFixture, + caseSwitchItem: caseSwitchItemFixture, + cubeDefinition: cubeDefinitionFixture, + cubeEvaluator: cubeEvaluatorFixture, + dimensionDefinition: dimensionDefinitionFixture, + driverTools: driverToolsFixture, + expressionStruct: expressionStructFixture, + filterGroup: filterGroupFixture, + filterParams: filterParamsFixture, + geoItem: geoItemFixture, + granularityDefinition: granularityDefinitionFixture, + joinDefinition: joinDefinitionFixture, + joinGraph: joinGraphFixture, + joinItem: joinItemFixture, + joinItemDefinition: joinItemDefinitionFixture, + measureDefinition: measureDefinitionFixture, + memberDefinition: memberDefinitionFixture, + memberExpressionDefinition: memberExpressionDefinitionFixture, + memberOrderBy: memberOrderByFixture, + preAggregationDescription: preAggregationDescriptionFixture, + preAggregationObj: preAggregationObjFixture, + preAggregationTimeDimension: preAggregationTimeDimensionFixture, + securityContext: securityContextFixture, + segmentDefinition: segmentDefinitionFixture, + sqlUtils: sqlUtilsFixture, + structWithSqlMember: structWithSqlMemberFixture, + timeShiftDefinition: timeShiftDefinitionFixture, +}; diff --git a/packages/cubejs-backend-native/test/bridge/helpers.ts b/packages/cubejs-backend-native/test/bridge/helpers.ts index 8c372ecf5b2de..9cb427a1ddf4b 100644 --- a/packages/cubejs-backend-native/test/bridge/helpers.ts +++ b/packages/cubejs-backend-native/test/bridge/helpers.ts @@ -56,3 +56,81 @@ export function invokeFilterParamsCallback( ): string { return native.__testBridgeInvokeFilterParamsCallback(fn, args); } + +export type BridgeFieldKind = 'field' | 'call' | 'static'; + +export interface BridgeFieldMeta { + name: string; + jsName: string; + kind: BridgeFieldKind; + optional: boolean; + vec: boolean; +} + +export function listBridgeFields(name: string): BridgeFieldMeta[] { + if (!bridgeHarnessAvailable) { + throw new Error( + 'Bridge test harness is not built. Rebuild with `yarn native:build-debug-bridge-tests`.' + ); + } + return native.__testBridgeListFields(name); +} + +export function listBridgeNames(): string[] { + if (!bridgeHarnessAvailable) { + throw new Error( + 'Bridge test harness is not built. Rebuild with `yarn native:build-debug-bridge-tests`.' + ); + } + return native.__testBridgeListBridgeNames(); +} + +export function parseBridge(name: string, obj: unknown): void { + if (!bridgeHarnessAvailable) { + throw new Error( + 'Bridge test harness is not built. Rebuild with `yarn native:build-debug-bridge-tests`.' + ); + } + native.__testBridgeParse(name, obj); +} + +export function fieldNames(meta: BridgeFieldMeta[]): string[] { + return meta.map((m) => m.name).sort(); +} + +export type InvokeStatus = + | { status: 'ok' } + | { status: 'error'; message: string } + | { status: 'skipped'; reason: string }; + +export type InvokeResult = Record; + +export function invokeBridge(name: string, fixture: unknown): InvokeResult { + if (!bridgeHarnessAvailable) { + throw new Error( + 'Bridge test harness is not built. Rebuild with `yarn native:build-debug-bridge-tests`.' + ); + } + return native.__testBridgeInvoke(name, fixture); +} + +/** + * Asserts every recorded invocation is `ok` or `skipped`. Errors surface + * the offending field, the Rust-side message, and the kind of failure so + * they read naturally in CI logs. Skipped entries are allowed because some + * call-methods take Rust-only argument types (e.g. `Rc`) + * that have no auto-default. + */ +export function expectAllInvocationsOk(result: InvokeResult): void { + const failures: string[] = []; + for (const [field, entry] of Object.entries(result)) { + if (entry.status === 'error') { + failures.push(`${field}: error: ${entry.message}`); + } + } + if (failures.length > 0) { + throw new Error( + `Bridge invocation failed for ${failures.length} field(s):\n ${failures.join('\n ')}` + ); + } +} diff --git a/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts b/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts index 3e985061c346d..22e63e4fbfc39 100644 --- a/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts +++ b/packages/cubejs-backend-native/test/bridge/multi-arg.test.ts @@ -12,13 +12,13 @@ describeBridge('bridge: multi-arg dispatch', () => { ); expect(result.template).toBe( - 'SUM({arg:0}) WHERE {fp:0} AND t = {sv:1}' + 'SUM({arg:0}) WHERE {fp:0} AND t = {sv:0}' ); 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']); + expect(result.args.security_context.values).toEqual(['acme']); }); it('throws an internal error from the StubBaseTools when SQL_UTILS is referenced', () => { diff --git a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts new file mode 100644 index 0000000000000..9207a1ed1c30a --- /dev/null +++ b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts @@ -0,0 +1,280 @@ +import { + bridgeHarnessAvailable, + expectAllInvocationsOk, + fieldNames, + invokeBridge, + listBridgeFields, + listBridgeNames, + parseBridge, +} from './helpers'; +import { FIXTURES } from './bridge-fixtures'; + +// Table-driven coverage for every bridge that uses #[nativebridge::native_bridge]. +// +// Each row pins the field set the macro should emit for the bridge, +// keyed by Rust ident (snake_case) — this is the same shape `fieldNames` +// returns. Adding a method or static field on either side without +// updating this list fails the meta assertion. +// +// The fully-populated fixture for the bridge lives in `bridge-fixtures.ts`. +// That file is the executable JS-side contract: the explicit shape we +// expect schema-compiler / cubejs-server-core to hand to Tesseract for +// each bridge. The invoke check below fires every `field` getter and +// every `call` method on the bridge against that fixture, so a mismatch +// between the Rust trait and the JS contract surfaces immediately. +// +// Coverage scope: every trait annotated with #[nativebridge::native_bridge] +// is registered in `bridge_registry!` and pinned here. Hand-rolled +// bridges (MemberSql, FilterParamsCallback, SqlTemplatesRender) live +// outside the macro and are not in scope here. +type BridgeSpec = { + name: string; + expected: string[]; +}; + +const BRIDGES: BridgeSpec[] = [ + { + name: 'baseQueryOptions', + expected: [ + 'base_tools', + 'convert_tz_for_raw_time_dimension', + 'cube_evaluator', + 'cubestore_support_multistage', + 'dimensions', + 'disable_external_pre_aggregations', + 'export_annotated_sql', + 'filters', + 'join_graph', + 'join_hints', + 'limit', + 'masked_members', + 'measures', + 'member_to_alias', + 'offset', + 'order', + 'pre_aggregation_id', + 'pre_aggregation_query', + 'row_limit', + 'security_context', + 'segments', + 'time_dimensions', + 'timezone', + 'total_query', + 'ungrouped', + ], + }, + { + name: 'baseTools', + expected: [ + 'all_cube_members', + 'driver_tools', + 'generate_custom_time_series', + 'generate_time_series', + 'get_allocated_params', + 'get_pre_aggregation_by_name', + 'interval_and_minimal_time_unit', + 'join_tree_for_hints', + 'pre_aggregation_table_name', + 'sql_templates', + 'sql_utils_for_rust', + ], + }, + { name: 'caseDefinition', expected: ['else_label', 'when'] }, + { name: 'caseElseItem', expected: ['label'] }, + { name: 'caseItem', expected: ['label', 'sql'] }, + { name: 'caseSwitchDefinition', expected: ['else_sql', 'switch', 'when'] }, + { name: 'caseSwitchElseItem', expected: ['sql'] }, + { name: 'caseSwitchItem', expected: ['sql', 'value'] }, + { + name: 'cubeDefinition', + expected: ['is_calendar', 'is_view', 'join_map', 'name', 'sql', 'sql_alias', 'sql_table'], + }, + { + name: 'cubeEvaluator', + expected: [ + 'cube_exists', + 'cube_from_path', + 'dimension_by_path', + 'evaluate_rollup_references', + 'is_dimension', + 'is_measure', + 'is_segment', + 'measure_by_path', + 'parse_path', + 'pre_aggregation_description_by_name', + 'pre_aggregations_for_cube_as_array', + 'primary_keys', + 'resolve_granularity', + 'segment_by_path', + ], + }, + { + name: 'dimensionDefinition', + expected: [ + 'add_group_by_references', + 'case', + 'dimension_type', + 'latitude', + 'longitude', + 'mask_sql', + 'multi_stage', + 'owned_by_cube', + 'primary_key', + 'propagate_filters_to_sub_query', + 'sql', + 'sub_query', + 'time_shift', + 'values', + ], + }, + { + name: 'driverTools', + expected: [ + 'add_interval', + 'add_timestamp_interval', + 'convert_tz', + 'count_distinct_approx', + 'date_bin', + 'date_time_cast', + 'get_allocated_params', + 'hll_cardinality_merge', + 'hll_init', + 'hll_merge', + 'in_db_time_zone', + 'interval_and_minimal_time_unit', + 'interval_string', + 'sql_templates', + 'subtract_interval', + 'support_generated_series_for_custom_td', + 'time_grouped_column', + 'time_stamp_cast', + 'timestamp_precision', + ], + }, + { + name: 'expressionStruct', + expected: ['add_filters', 'expression_type', 'replace_aggregation_type', 'source_measure'], + }, + { name: 'filterGroup', expected: [] }, + { name: 'filterParams', expected: [] }, + { name: 'geoItem', expected: ['sql'] }, + { name: 'granularityDefinition', expected: ['interval', 'offset', 'origin', 'sql'] }, + { name: 'joinDefinition', expected: ['joins', 'multiplication_factor', 'root'] }, + { name: 'joinGraph', expected: ['build_join'] }, + { name: 'joinItem', expected: ['from', 'join', 'original_from', 'original_to', 'to'] }, + { name: 'joinItemDefinition', expected: ['relationship', 'sql'] }, + { + name: 'measureDefinition', + expected: [ + 'add_group_by_references', + 'case', + 'drill_filters', + 'filters', + 'group_by_references', + 'mask_sql', + 'measure_type', + 'multi_stage', + 'order_by', + 'owned_by_cube', + 'reduce_by_references', + 'rolling_window', + 'sql', + 'time_shift_references', + ], + }, + { name: 'memberDefinition', expected: ['member_type', 'sql'] }, + { + name: 'memberExpressionDefinition', + expected: ['cube_name', 'definition', 'expression', 'expression_name', 'name'], + }, + { name: 'memberOrderBy', expected: ['dir', 'sql'] }, + { + name: 'preAggregationDescription', + expected: [ + 'allow_non_strict_date_range_match', + 'dimension_references', + 'external', + 'granularity', + 'measure_references', + 'name', + 'pre_aggregation_type', + 'rollup_references', + 'segment_references', + 'sql_alias', + 'time_dimension_reference', + 'time_dimension_references', + ], + }, + { + name: 'preAggregationObj', + expected: ['cube', 'pre_aggregation_id', 'pre_aggregation_name', 'table_name'], + }, + { name: 'preAggregationTimeDimension', expected: ['dimension', 'granularity'] }, + { name: 'securityContext', expected: [] }, + { name: 'segmentDefinition', expected: ['owned_by_cube', 'segment_type', 'sql'] }, + { name: 'sqlUtils', expected: [] }, + { name: 'structWithSqlMember', expected: ['sql'] }, + { name: 'timeShiftDefinition', expected: ['interval', 'name', 'sql', 'timeshift_type'] }, +]; + +const describeBridge = bridgeHarnessAvailable ? describe : describe.skip; + +// Cross-side completeness guard. The bridge_registry! macro on the Rust +// side is the source of truth; both `BRIDGES` and `FIXTURES` must enumerate +// the exact same set of bridges. Adding a bridge in Rust without wiring +// the JS contract here (or vice versa) would otherwise be a silent gap. +describeBridge('bridge object: registry coverage', () => { + it('every registered bridge has a row in BRIDGES and an entry in FIXTURES', () => { + const registered = [...listBridgeNames()].sort(); + const inTests = BRIDGES.map((b) => b.name).sort(); + const inFixtures = Object.keys(FIXTURES).sort(); + expect(inTests).toEqual(registered); + expect(inFixtures).toEqual(registered); + }); +}); + +describeBridge.each(BRIDGES)('bridge object: $name', ({ name, expected }) => { + it('exposes the expected field set via the bridge meta', () => { + expect(fieldNames(listBridgeFields(name))).toEqual(expected); + }); + + it('parses a fully-populated fixture without error', () => { + const fixture = FIXTURES[name](); + expect(() => parseBridge(name, fixture)).not.toThrow(); + }); + + it('every field-getter and call-method round-trips successfully', () => { + const fixture = FIXTURES[name](); + expectAllInvocationsOk(invokeBridge(name, fixture)); + }); +}); + +// Negative-required cases. Picked one representative bridge to keep the +// matrix above clean while still exercising the macro-generated +// `Field is required` rejection path. +describeBridge('bridge object: try_new negative paths', () => { + it('rejects a memberOrderBy fixture missing the required sql field', () => { + expect(() => parseBridge('memberOrderBy', { dir: 'asc' })).toThrow( + /Field sql is required/ + ); + }); + + it('rejects a memberOrderBy fixture missing the required dir field', () => { + expect(() => parseBridge('memberOrderBy', { sql: () => 'x' })).toThrow( + /Field dir is required/ + ); + }); +}); + +// Meta-shape assertion. Picked timeShiftDefinition because it has the most +// interesting mix: a trait `field` (sql) plus three serde-static fields, +// one of which is renamed via `#[serde(rename = "type")]`. +describeBridge('bridge object: meta shape', () => { + it('reports js_name + kind for the serde-renamed static field on timeShiftDefinition', () => { + const meta = listBridgeFields('timeShiftDefinition'); + const tsType = meta.find((m) => m.name === 'timeshift_type'); + expect(tsType?.jsName).toBe('type'); + expect(tsType?.kind).toBe('static'); + expect(tsType?.optional).toBe(true); + }); +}); diff --git a/packages/cubejs-backend-native/test/bridge/result-shape.test.ts b/packages/cubejs-backend-native/test/bridge/result-shape.test.ts index 3b58b0357030d..38bdcab7b1418 100644 --- a/packages/cubejs-backend-native/test/bridge/result-shape.test.ts +++ b/packages/cubejs-backend-native/test/bridge/result-shape.test.ts @@ -26,21 +26,20 @@ describeBridge('bridge: result shape', () => { 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('coerces a numeric return to its string form', () => { + const integerResult = compileMemberSql(() => 42 as any); + const decimalResult = compileMemberSql(() => 1.5 as any); + + expect(integerResult.template).toBe('42'); + expect(decimalResult.template).toBe('1.5'); }); - it('errors when the user function returns a primitive boolean', () => { - expect(() => compileMemberSql(() => true as any)).toThrow( - /Object is not the Struct object/ - ); + it('coerces a boolean return to its string form', () => { + const trueResult = compileMemberSql(() => true as any); + const falseResult = compileMemberSql(() => false as any); + + expect(trueResult.template).toBe('true'); + expect(falseResult.template).toBe('false'); }); it('returns an empty string template when the user function returns null', () => { @@ -49,17 +48,4 @@ describeBridge('bridge: result shape', () => { 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 index 885999d0f6442..480852c4ba68d 100644 --- a/packages/cubejs-backend-native/test/bridge/security-context.test.ts +++ b/packages/cubejs-backend-native/test/bridge/security-context.test.ts @@ -3,35 +3,34 @@ 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'. + it('handles a string filter value as col = {sv:0}', () => { 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']); + expect(result.template).toBe('col = {sv:0}'); + expect(result.args.security_context.values).toEqual(['acme']); }); - it('handles a string array as col IN (sv0, sv1, ...)', () => { + it('handles a string array as col IN (sv0, sv1, sv2)', () => { 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', - ]); + expect(result.template).toBe('col IN ({sv:0}, {sv:1}, {sv:2})'); + expect(result.args.security_context.values).toEqual(['a', 'b', 'c']); + }); + + it('handles a numeric array by stringifying each element', () => { + const result = compileMemberSql( + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.ids.filter('id')}`, + { ids: [1, 2, 3] } + ); + + expect(result.template).toBe('id IN ({sv:0}, {sv:1}, {sv:2})'); + expect(result.args.security_context.values).toEqual(['1', '2', '3']); }); it('renders an empty string array as 1 = 0 with no values registered for the filter', () => { @@ -41,8 +40,6 @@ describeBridge('bridge: SECURITY_CONTEXT — filter input shapes', () => { ); 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([]); }); @@ -63,8 +60,8 @@ describeBridge('bridge: SECURITY_CONTEXT — filter input shapes', () => { { user_id: 42 } ); - expect(result.template).toBe('uid = {sv:1}'); - expect(result.args.security_context.values).toEqual(['42', '42']); + expect(result.template).toBe('uid = {sv:0}'); + expect(result.args.security_context.values).toEqual(['42']); }); it('formats a non-integer number as a decimal string', () => { @@ -73,34 +70,49 @@ describeBridge('bridge: SECURITY_CONTEXT — filter input shapes', () => { { factor: 1.5 } ); - expect(result.template).toBe('f = {sv:1}'); - expect(result.args.security_context.values).toEqual(['1.5', '1.5']); + expect(result.template).toBe('f = {sv:0}'); + expect(result.args.security_context.values).toEqual(['1.5']); }); - it('formats a boolean as the string true/false', () => { + it('formats a truthy boolean as the string "true"', () => { 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']); + expect(result.template).toBe('f = {sv:0}'); + expect(result.args.security_context.values).toEqual(['true']); }); - it('returns 1 = 1 when an optional filter field is missing', () => { + it.each([ + ['missing', undefined], + ['null', null], + ['empty string', ''], + ['zero', 0], + ['false', false], + ])('returns 1 = 1 when filter value is %s', (_, value) => { + const ctx = value === undefined ? {} : { tenant: value }; const result = compileMemberSql( (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, - {} + ctx ); expect(result.template).toBe('1 = 1'); expect(result.args.security_context.values).toEqual([]); }); - it('throws a user error when requiredFilter field is missing', () => { + it.each([ + ['missing', undefined], + ['null', null], + ['empty string', ''], + ['zero', 0], + ['false', false], + ])('throws when requiredFilter value is %s', (_, value) => { + const ctx = value === undefined ? {} : { tenant: value }; + expect(() => compileMemberSql( (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.requiredFilter('col')}`, - {} + ctx )).toThrow(/Filter for col is required/); }); @@ -119,8 +131,8 @@ describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { { tenant: { id: '123' } } ); - expect(result.template).toBe('col = {sv:1}'); - expect(result.args.security_context.values).toEqual(['123', '123']); + expect(result.template).toBe('col = {sv:0}'); + expect(result.args.security_context.values).toEqual(['123']); }); it('does not crash on a deep leaf-proxy path that does not exist in the context', () => { @@ -133,6 +145,7 @@ describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { // 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'); + expect(result.args.security_context.values).toEqual([]); }); it('exposes unsafeValue() that returns the raw value without registering a placeholder', () => { @@ -142,15 +155,14 @@ describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { ); 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']); + expect(result.args.security_context.values).toEqual([]); }); 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. + // and the resulting template is just the picked literal — no + // placeholders registered. const adminResult = compileMemberSql( (SECURITY_CONTEXT: any) => `SELECT * FROM ${ SECURITY_CONTEXT.cubeCloud.groups.unsafeValue() === 'admin' @@ -170,18 +182,13 @@ describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { 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']); + expect(adminResult.args.security_context.values).toEqual([]); + expect(viewerResult.args.security_context.values).toEqual([]); }); - 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. + it('renders a scalar leaf used directly in a template as a single placeholder', () => { + // `tenant_id = ${SECURITY_CONTEXT.cubeCloud.tenantId}` — common in prod. + // Coerce-time toString fires once and registers a single placeholder. const result = compileMemberSql( (SECURITY_CONTEXT: any) => `tenant_id = ${SECURITY_CONTEXT.cubeCloud.tenantId}`, { cubeCloud: { tenantId: '123' } } @@ -191,142 +198,65 @@ describeBridge('bridge: SECURITY_CONTEXT — proxy structure', () => { 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. + it('renders an array leaf directly in a template as comma-joined placeholders', () => { 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' } + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.groups}`, + { groups: ['a', 'b'] } ); - expect(camel.template).toBe('col = {sv:1}'); - expect(snake.template).toBe('col = {sv:1}'); + expect(result.template).toBe('{sv:0},{sv:1}'); + expect(result.args.security_context.values).toEqual(['a', 'b']); }); -}); -// 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', () => { + it('renders an empty array leaf directly in a template as an empty string', () => { const result = compileMemberSql( - (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.flag.filter('f')}`, - { flag: false } + (SECURITY_CONTEXT: any) => `[${SECURITY_CONTEXT.groups}]`, + { groups: [] } ); - 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.template).toBe('[]'); expect(result.args.security_context.values).toEqual([]); }); - it.skip('JS-ref: .filter on \'\' returns 1 = 1', () => { + it('allocates a fresh placeholder on every coercion of the same leaf proxy', () => { const result = compileMemberSql( - (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.tenant.filter('col')}`, - { tenant: '' } + (SECURITY_CONTEXT: any) => { + const t = SECURITY_CONTEXT.tenant; + return `${t} | ${t}`; + }, + { tenant: 'acme' } ); - 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+\}\)$/ - ); + expect(result.template).toBe('{sv:0} | {sv:1}'); + expect(result.args.security_context.values).toEqual(['acme', 'acme']); }); - // 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}', () => { + 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.tenant.filter('col')}`, - { tenant: 'acme' } + (SECURITY_CONTEXT: any) => `${SECURITY_CONTEXT.cubeCloud.groups.filter( + (groups: string[]) => `source IN (${groups.join(',')})` + )}`, + { cubeCloud: { groups: ['a', 'b'] } } ); - 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.template).toBe('source 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`, + it('accepts both camelCase securityContext and snake_case security_context arg names', () => { + const camel = compileMemberSql( + (securityContext: any) => `${securityContext.tenant.filter('col')}`, { 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'] } + const snake = compileMemberSql( + // eslint-disable-next-line camelcase + (security_context: any) => `${security_context.tenant.filter('col')}`, + { tenant: 'acme' } ); - expect(result.template).toBe('{sv:0},{sv:1}'); + + expect(camel.template).toBe('col = {sv:0}'); + expect(snake.template).toBe('col = {sv:0}'); }); }); diff --git a/rust/cube/cubenativeutils/src/wrappers/bridge_meta.rs b/rust/cube/cubenativeutils/src/wrappers/bridge_meta.rs new file mode 100644 index 0000000000000..407a30ebc4365 --- /dev/null +++ b/rust/cube/cubenativeutils/src/wrappers/bridge_meta.rs @@ -0,0 +1,25 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeFieldKind { + Field, + Call, + Static, +} + +impl BridgeFieldKind { + pub fn as_str(&self) -> &'static str { + match self { + BridgeFieldKind::Field => "field", + BridgeFieldKind::Call => "call", + BridgeFieldKind::Static => "static", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BridgeFieldMeta { + pub name: &'static str, + pub js_name: &'static str, + pub kind: BridgeFieldKind, + pub optional: bool, + pub vec: bool, +} diff --git a/rust/cube/cubenativeutils/src/wrappers/mod.rs b/rust/cube/cubenativeutils/src/wrappers/mod.rs index 10394d7037ab5..09c0f6918f430 100644 --- a/rust/cube/cubenativeutils/src/wrappers/mod.rs +++ b/rust/cube/cubenativeutils/src/wrappers/mod.rs @@ -1,3 +1,4 @@ +pub mod bridge_meta; pub mod context; mod functions_args_def; pub mod inner_types; diff --git a/rust/cube/cubenativeutils/src/wrappers/neon/object/neon_function.rs b/rust/cube/cubenativeutils/src/wrappers/neon/object/neon_function.rs index 96bf382250f8f..8095acb844171 100644 --- a/rust/cube/cubenativeutils/src/wrappers/neon/object/neon_function.rs +++ b/rust/cube/cubenativeutils/src/wrappers/neon/object/neon_function.rs @@ -75,33 +75,159 @@ impl + 'static> NativeFunction> for NeonFu } fn args_names(&self) -> Result, CubeError> { - lazy_static! { - static ref FUNCTION_RE: Regex = Regex::new( - r"function\s+\w+\(([A-Za-z0-9_,]*)|\(([\s\S]*?)\)\s*=>|\(?(\w+)\)?\s*=>" - ) - .unwrap(); + Ok(parse_args_names(&self.definition()?)) + } +} + +fn parse_args_names(definition: &str) -> Vec { + lazy_static! { + // Strips an optional `async` and `function [*] [name]` prefix — + // anything left is either `(args) ...` or `name => ...`. + static ref PREFIX_RE: Regex = + Regex::new(r"^\s*(?:async\s+)?(?:function\s*\*?\s*\w*\s*)?").unwrap(); + static ref IDENT_RE: Regex = Regex::new(r"[A-Za-z_$][A-Za-z0-9_$]*").unwrap(); + } + + let prefix_end = PREFIX_RE.find(definition).map(|m| m.end()).unwrap_or(0); + let rest = definition[prefix_end..].trim_start(); + + if !rest.starts_with('(') { + return IDENT_RE + .find(rest) + .filter(|m| rest[m.end()..].trim_start().starts_with("=>")) + .map(|m| vec![m.as_str().to_string()]) + .unwrap_or_default(); + } + + let Some(end) = matching_paren(rest) else { + return vec![]; + }; + let inner = &rest[1..end]; + + let mut out = Vec::new(); + for tok in split_top_level(inner, ',') { + let tok = strip_default(tok).trim().trim_start_matches('.').trim(); + if tok.is_empty() { + continue; } - let definition = self.definition()?; - if let Some(captures) = FUNCTION_RE.captures(&definition) { - let args_string = captures.get(1).or(captures.get(2)).or(captures.get(3)); - if let Some(args_string) = args_string { - Ok(args_string - .as_str() - .split(',') - .filter_map(|s| { - let arg = s.trim().to_string(); - if arg.is_empty() { - None - } else { - Some(arg) - } - }) - .collect()) - } else { - Ok(vec![]) - } + if tok.starts_with('{') || tok.starts_with('[') { + out.extend(IDENT_RE.find_iter(tok).map(|m| m.as_str().to_string())); } else { - Ok(vec![]) + out.push(tok.to_string()); + } + } + out +} + +fn matching_paren(s: &str) -> Option { + let mut depth = 0i32; + for (i, b) in s.bytes().enumerate() { + match b { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + depth -= 1; + if depth == 0 && b == b')' { + return Some(i); + } + } + _ => {} + } + } + None +} + +fn split_top_level(s: &str, sep: char) -> Vec<&str> { + let mut depth = 0i32; + let mut start = 0usize; + let mut out = Vec::new(); + for (i, c) in s.char_indices() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + c if c == sep && depth == 0 => { + out.push(&s[start..i]); + start = i + c.len_utf8(); + } + _ => {} } } + out.push(&s[start..]); + out +} + +fn strip_default(tok: &str) -> &str { + let mut depth = 0i32; + for (i, c) in tok.char_indices() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + '=' if depth == 0 => return &tok[..i], + _ => {} + } + } + tok +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(def: &str) -> Vec { + parse_args_names(def) + } + + #[test] + fn matching_paren_balanced_with_nested_default() { + // `(x = f())` — naive lazy regex would stop at the inner `)`. + assert_eq!(matching_paren("(x = f())"), Some(8)); + assert_eq!(matching_paren("(x = (1, 2))"), Some(11)); + assert_eq!(matching_paren("(a, {b: [c]})"), Some(12)); + assert_eq!(matching_paren("(unbalanced"), None); + } + + #[test] + fn split_top_level_respects_nested_brackets() { + assert_eq!(split_top_level("a, b, c", ','), vec!["a", " b", " c"]); + assert_eq!(split_top_level("{a, b}, c", ','), vec!["{a, b}", " c"]); + assert_eq!(split_top_level("(a, b), c", ','), vec!["(a, b)", " c"]); + assert_eq!(split_top_level("", ','), vec![""]); + } + + #[test] + fn strip_default_cuts_at_top_level_equals_only() { + assert_eq!(strip_default("x"), "x"); + assert_eq!(strip_default("x = 1"), "x "); + assert_eq!(strip_default("x = (1, 2)"), "x "); + // Arrow body in the default value: only the first top-level `=` counts. + assert_eq!(strip_default("x = (y) => y"), "x "); + // Nested `=` inside a destructuring default stays untouched. + assert_eq!(strip_default("{a = 1}"), "{a = 1}"); + } + + #[test] + fn parses_arrow_forms() { + assert_eq!(names("(x) => x"), vec!["x"]); + assert_eq!(names("(x, y) => x"), vec!["x", "y"]); + assert_eq!(names("() => 42"), Vec::::new()); + assert_eq!(names("async (x) => x"), vec!["x"]); + assert_eq!(names("x => x"), vec!["x"]); + } + + #[test] + fn parses_function_forms() { + assert_eq!(names("function named(x, y) { return x; }"), vec!["x", "y"]); + assert_eq!(names("async function n(a) { return a; }"), vec!["a"]); + assert_eq!(names("function (x) { return x; }"), vec!["x"]); + assert_eq!(names("function* gen(a, b) {}"), vec!["a", "b"]); + } + + #[test] + fn handles_defaults_rest_and_destructuring() { + assert_eq!(names("(x = 1) => x"), vec!["x"]); + assert_eq!(names("(...args) => args"), vec!["args"]); + assert_eq!(names("({ a, b }) => a"), vec!["a", "b"]); + assert_eq!(names("([a, b]) => a"), vec!["a", "b"]); + // Default value with nested parens — no foot-gun on lazy regex. + assert_eq!(names("(x = f(1, 2)) => x"), vec!["x"]); + } } diff --git a/rust/cube/cubenativeutils/src/wrappers/object_handle.rs b/rust/cube/cubenativeutils/src/wrappers/object_handle.rs index 4068750655102..e367c789260ba 100644 --- a/rust/cube/cubenativeutils/src/wrappers/object_handle.rs +++ b/rust/cube/cubenativeutils/src/wrappers/object_handle.rs @@ -1,5 +1,8 @@ use super::{inner_types::InnerTypes, object::NativeObject}; -use super::{NativeContextHolder, NativeContextHolderRef, NativeString, NativeStruct}; +use super::{ + NativeBoolean, NativeContextHolder, NativeContextHolderRef, NativeNumber, NativeString, + NativeStruct, +}; use crate::CubeError; #[derive(Clone)] @@ -68,15 +71,23 @@ impl NativeObjectHandle { pub fn convert_to_string(&self) -> Result { if let Ok(str) = self.to_string() { - str.value() - } else if self.is_null()? { - Ok("".to_string()) - } else { - self.to_struct()? - .call_method("toString", vec![])? - .into_string()? - .value() + return str.value(); + } + if self.is_null()? { + return Ok("".to_string()); + } + // Primitives don't expose a struct-side `toString`, so coerce them + // inline before the struct fallback below — otherwise it errors. + if let Ok(n) = self.to_number() { + return Ok(n.value()?.to_string()); + } + if let Ok(b) = self.to_boolean() { + return Ok(b.value()?.to_string()); } + self.to_struct()? + .call_method("toString", vec![])? + .into_string()? + .value() } pub fn try_clone_to_context_ref( diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs index c164ec45cf5b6..ac95eaa39baec 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_query_options.rs @@ -56,7 +56,7 @@ impl FilterItem { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct BaseQueryOptionsStatic { #[serde(rename = "timeDimensions")] pub time_dimensions: Option>, @@ -88,7 +88,7 @@ pub struct BaseQueryOptionsStatic { pub member_to_alias: Option>, } -#[nativebridge::native_bridge(BaseQueryOptionsStatic)] +#[nativebridge::native_bridge(BaseQueryOptionsStatic, with_static_meta)] pub trait BaseQueryOptions { #[nbridge(field, optional, vec)] fn measures(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/case_switch_item.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/case_switch_item.rs index 95406dad4444f..6a4883ecab36c 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/case_switch_item.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/case_switch_item.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct CaseSwitchItemStatic { pub value: String, } -#[nativebridge::native_bridge(CaseSwitchItemStatic)] +#[nativebridge::native_bridge(CaseSwitchItemStatic, with_static_meta)] pub trait CaseSwitchItem { #[nbridge(field)] fn sql(&self) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs index 1316ee8dc6a65..d21841b95d5d9 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/cube_definition.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct CubeDefinitionStatic { pub name: String, #[serde(rename = "sqlAlias")] @@ -32,7 +32,7 @@ impl CubeDefinitionStatic { } } -#[nativebridge::native_bridge(CubeDefinitionStatic)] +#[nativebridge::native_bridge(CubeDefinitionStatic, with_static_meta)] pub trait CubeDefinition { #[nbridge(field, optional)] fn sql_table(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs index e82870529fb84..b51f222439dec 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct DimensionDefinitionStatic { #[serde(rename = "type")] pub dimension_type: String, @@ -32,7 +32,7 @@ pub struct DimensionDefinitionStatic { pub primary_key: Option, } -#[nativebridge::native_bridge(DimensionDefinitionStatic)] +#[nativebridge::native_bridge(DimensionDefinitionStatic, with_static_meta)] pub trait DimensionDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs index bbcb25af716e3..4948359b27cb3 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs @@ -21,7 +21,7 @@ use std::any::Any; use std::collections::HashMap; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct CubeEvaluatorStatic { #[serde(rename = "primaryKeys")] pub primary_keys: HashMap>, @@ -33,7 +33,7 @@ pub struct CallDep { pub parent: Option, } -#[nativebridge::native_bridge(CubeEvaluatorStatic)] +#[nativebridge::native_bridge(CubeEvaluatorStatic, with_static_meta)] pub trait CubeEvaluator { fn parse_path(&self, path_type: String, path: String) -> Result, CubeError>; fn measure_by_path(&self, measure_path: String) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs index 5f08ad1e20e30..afde8d097ad06 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs @@ -9,14 +9,16 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] +#[derive( + Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash, nativebridge::NativeBridgeStatic, +)] pub struct GranularityDefinitionStatic { pub interval: String, pub origin: Option, pub offset: Option, } -#[nativebridge::native_bridge(GranularityDefinitionStatic)] +#[nativebridge::native_bridge(GranularityDefinitionStatic, with_static_meta)] pub trait GranularityDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_definition.rs index 2ff82aa8c0eae..9fd80b42d274a 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_definition.rs @@ -11,14 +11,14 @@ use std::any::Any; use std::collections::HashMap; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct JoinDefinitionStatic { pub root: String, #[serde(rename = "multiplicationFactor")] pub multiplication_factor: HashMap, } -#[nativebridge::native_bridge(JoinDefinitionStatic)] +#[nativebridge::native_bridge(JoinDefinitionStatic, with_static_meta)] pub trait JoinDefinition { #[nbridge(field, vec)] fn joins(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item.rs index 922de7cbde963..0c925cbf786df 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item.rs @@ -9,7 +9,9 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash)] +#[derive( + Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, nativebridge::NativeBridgeStatic, +)] pub struct JoinItemStatic { pub from: String, pub to: String, @@ -19,7 +21,7 @@ pub struct JoinItemStatic { pub original_to: String, } -#[nativebridge::native_bridge(JoinItemStatic)] +#[nativebridge::native_bridge(JoinItemStatic, with_static_meta)] pub trait JoinItem { #[nbridge(field)] fn join(&self) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item_definition.rs index 7d14e64fe3756..ac12b5312abac 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/join_item_definition.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct JoinItemDefinitionStatic { pub relationship: String, } -#[nativebridge::native_bridge(JoinItemDefinitionStatic)] +#[nativebridge::native_bridge(JoinItemDefinitionStatic, with_static_meta)] pub trait JoinItemDefinition { #[nbridge(field)] fn sql(&self) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/measure_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/measure_definition.rs index 0e59b1810669f..58f438464aa52 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/measure_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/measure_definition.rs @@ -33,7 +33,7 @@ pub struct RollingWindow { pub granularity: Option, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct MeasureDefinitionStatic { #[serde(rename = "type")] pub measure_type: String, @@ -53,7 +53,7 @@ pub struct MeasureDefinitionStatic { pub rolling_window: Option, } -#[nativebridge::native_bridge(MeasureDefinitionStatic)] +#[nativebridge::native_bridge(MeasureDefinitionStatic, with_static_meta)] pub trait MeasureDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_definition.rs index 275b6ee4e8178..43f67e3865990 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_definition.rs @@ -9,13 +9,13 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct MemberDefinitionStatic { #[serde(rename = "type")] pub member_type: String, } -#[nativebridge::native_bridge(MemberDefinitionStatic)] +#[nativebridge::native_bridge(MemberDefinitionStatic, with_static_meta)] pub trait MemberDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_expression.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_expression.rs index ce340f828b9ee..1cb40a81a24ad 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_expression.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_expression.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, nativebridge::NativeBridgeStatic)] pub struct ExpressionStructStatic { #[serde(rename = "type")] pub expression_type: String, @@ -20,7 +20,7 @@ pub struct ExpressionStructStatic { pub replace_aggregation_type: Option, } -#[nativebridge::native_bridge(ExpressionStructStatic)] +#[nativebridge::native_bridge(ExpressionStructStatic, with_static_meta)] pub trait ExpressionStruct { #[nbridge(field, optional, vec)] fn add_filters(&self) -> Result>>, CubeError>; @@ -45,7 +45,7 @@ impl NativeDeserialize for MemberExpressionExpressionDef { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, nativebridge::NativeBridgeStatic)] pub struct MemberExpressionDefinitionStatic { #[serde(rename = "expressionName")] pub expression_name: Option, @@ -55,7 +55,7 @@ pub struct MemberExpressionDefinitionStatic { pub definition: Option, } -#[nativebridge::native_bridge(MemberExpressionDefinitionStatic, without_imports)] +#[nativebridge::native_bridge(MemberExpressionDefinitionStatic, without_imports, with_static_meta)] pub trait MemberExpressionDefinition { #[nbridge(field)] fn expression(&self) -> Result; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs index 1e9b95505f759..f913103fff43a 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/member_sql.rs @@ -383,6 +383,23 @@ impl NativeMemberSql { } } + fn coerce_scalar_to_string( + handle: NativeObjectHandle, + ) -> Result { + if let Ok(s) = String::from_native(handle.clone()) { + return Ok(s); + } + if let Ok(n) = f64::from_native(handle.clone()) { + return Ok(n.to_string()); + } + if let Ok(b) = bool::from_native(handle.clone()) { + return Ok(b.to_string()); + } + Err(CubeError::user( + "Invalid param for security context".to_string(), + )) + } + fn security_context_filter_fn( context_holder: NativeContextHolder, property_value: NativeObjectHandle, @@ -394,20 +411,37 @@ impl NativeMemberSql { StringVec(Vec), None, } - let param_value = if let Ok(prop_vec) = Vec::::from_native(property_value.clone()) { - ParamValue::StringVec(prop_vec) + // Falsy scalars (undefined / null / "" / 0 / NaN / false) collapse to + // `ParamValue::None` and emit `1 = 1`. Empty arrays stay as an empty + // `StringVec` and emit `1 = 0` separately below — `IN ()` is invalid + // SQL in most dialects. + let param_value = if property_value.is_undefined()? || property_value.is_null()? { + ParamValue::None + } else if let Ok(arr) = property_value.to_array() { + let values = arr + .to_vec()? + .into_iter() + .map(Self::coerce_scalar_to_string) + .collect::, _>>()?; + ParamValue::StringVec(values) } else if let Ok(prop) = String::from_native(property_value.clone()) { - ParamValue::String(prop) + if prop.is_empty() { + ParamValue::None + } else { + ParamValue::String(prop) + } } else if let Ok(prop) = f64::from_native(property_value.clone()) { - if prop.fract() == 0.0 && prop.is_finite() { - ParamValue::String(format!("{}", prop as i64)) + if prop == 0.0 || prop.is_nan() { + ParamValue::None } else { ParamValue::String(prop.to_string()) } } else if let Ok(prop) = bool::from_native(property_value.clone()) { - ParamValue::String(prop.to_string()) - } else if property_value.is_undefined()? || property_value.is_null()? { - ParamValue::None + if prop { + ParamValue::String("true".to_string()) + } else { + ParamValue::None + } } else { return Err(CubeError::user( "Invalid param for security context".to_string(), @@ -500,30 +534,40 @@ impl NativeMemberSql { property_value: NativeObjectHandle, proxy_state: ProxyStateWeak, ) -> Result, CubeError> { - let str_value = if let Ok(prop_vec) = Vec::::from_native(property_value.clone()) { - Some(prop_vec) - } else if let Ok(prop) = String::from_native(property_value.clone()) { - Some(vec![prop]) - } else if let Ok(prop) = f64::from_native(property_value.clone()) { - if prop.fract() == 0.0 && prop.is_finite() { - Some(vec![format!("{}", prop as i64)]) - } else { + // Type extraction is read-only and runs eagerly. Placeholder + // allocation happens lazily inside the returned function so it only + // fires when the proxy is actually coerced via `${...}` — otherwise + // every property access would register a placeholder. + let str_value: Option> = + if property_value.is_undefined()? || property_value.is_null()? { + None + } else if let Ok(arr) = property_value.to_array() { + let elements = arr.to_vec()?; + let mut values = Vec::with_capacity(elements.len()); + for el in elements { + values.push(Self::coerce_scalar_to_string(el)?); + } + Some(values) + } else if let Ok(prop) = String::from_native(property_value.clone()) { + Some(vec![prop]) + } else if let Ok(prop) = f64::from_native(property_value.clone()) { Some(vec![prop.to_string()]) - } - } else if let Ok(prop) = bool::from_native(property_value.clone()) { - Some(vec![prop.to_string()]) - } else { - None - }; - let allocated = match str_value { - Some(values) => values - .iter() - .map(|v| Self::process_secutity_context_value(&proxy_state, v)) - .collect::, _>>()? - .join(", "), - None => String::new(), - }; - let result = context_holder.to_string_fn(allocated)?; + } else if let Ok(prop) = bool::from_native(property_value.clone()) { + Some(vec![prop.to_string()]) + } else { + None + }; + let result = + context_holder.make_vararg_function(move |_, _| -> Result { + match &str_value { + Some(values) => Ok(values + .iter() + .map(|v| Self::process_secutity_context_value(&proxy_state, v)) + .collect::, _>>()? + .join(",")), + None => Ok(String::new()), + } + })?; Ok(NativeObjectHandle::new(result.into_object())) } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_description.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_description.rs index c1b77141d8a42..f0e42e962f8d4 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_description.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_description.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct PreAggregationDescriptionStatic { pub name: String, #[serde(rename = "type")] @@ -26,7 +26,7 @@ pub struct PreAggregationDescriptionStatic { pub allow_non_strict_date_range_match: Option, } -#[nativebridge::native_bridge(PreAggregationDescriptionStatic)] +#[nativebridge::native_bridge(PreAggregationDescriptionStatic, with_static_meta)] pub trait PreAggregationDescription { #[nbridge(field, optional)] fn measure_references(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_obj.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_obj.rs index 39ae16b04ab1c..62b9084a8ab3b 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_obj.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_obj.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct PreAggregationObjStatic { #[serde(rename = "tableName")] pub table_name: Option, @@ -17,5 +17,5 @@ pub struct PreAggregationObjStatic { pub pre_aggregation_id: Option, } -#[nativebridge::native_bridge(PreAggregationObjStatic)] +#[nativebridge::native_bridge(PreAggregationObjStatic, with_static_meta)] pub trait PreAggregationObj {} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_time_dimension.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_time_dimension.rs index 6aa9ebf79bc5c..83c0bb9c7bb9e 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_time_dimension.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/pre_aggregation_time_dimension.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Deserialize, Serialize, Clone, Debug)] +#[derive(Deserialize, Serialize, Clone, Debug, nativebridge::NativeBridgeStatic)] pub struct PreAggregationTimeDimensionStatic { pub granularity: String, } -#[nativebridge::native_bridge(PreAggregationTimeDimensionStatic)] +#[nativebridge::native_bridge(PreAggregationTimeDimensionStatic, with_static_meta)] pub trait PreAggregationTimeDimension { #[nbridge(field)] fn dimension(&self) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/segment_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/segment_definition.rs index f9e930fa4a1b1..41ba60c8ca2b0 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/segment_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/segment_definition.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, nativebridge::NativeBridgeStatic)] pub struct SegmentDefinitionStatic { #[serde(rename = "type")] pub segment_type: Option, @@ -17,7 +17,7 @@ pub struct SegmentDefinitionStatic { pub owned_by_cube: Option, } -#[nativebridge::native_bridge(SegmentDefinitionStatic)] +#[nativebridge::native_bridge(SegmentDefinitionStatic, with_static_meta)] pub trait SegmentDefinition { #[nbridge(field)] fn sql(&self) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs index f51081ac7ee15..da5ad2e0c7621 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/timeshift_definition.rs @@ -9,7 +9,9 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] +#[derive( + Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash, nativebridge::NativeBridgeStatic, +)] pub struct TimeShiftDefinitionStatic { pub interval: Option, #[serde(rename = "type")] @@ -17,7 +19,7 @@ pub struct TimeShiftDefinitionStatic { pub name: Option, } -#[nativebridge::native_bridge(TimeShiftDefinitionStatic)] +#[nativebridge::native_bridge(TimeShiftDefinitionStatic, with_static_meta)] pub trait TimeShiftDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/aggregate_multiplied_subquery.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/aggregate_multiplied_subquery.rs index cc7017ac49b13..a471a8261c780 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/aggregate_multiplied_subquery.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/aggregate_multiplied_subquery.rs @@ -1,6 +1,7 @@ use super::super::{LogicalNodeProcessor, ProcessableNode, PushDownBuilderContext}; use crate::logical_plan::{AggregateMultipliedSubquery, AggregateMultipliedSubquerySource}; use crate::physical_plan::ReferencesBuilder; +use crate::physical_plan::VisitorContext; use crate::physical_plan::{ Expr, From, JoinBuilder, JoinCondition, MemberExpression, QualifiedColumnName, Select, SelectBuilder, @@ -59,6 +60,24 @@ impl<'a> LogicalNodeProcessor<'a, AggregateMultipliedSubquery> match &aggregate_multiplied_subquery.source { AggregateMultipliedSubquerySource::Cube(cube) => { + // Bind a dedicated VisitorContext to the join's right-hand side + // so that primary-key dimensions render against `pk_cube_alias` + // (the source cube join). Without it, the outer factory's + // render_references — populated later for the SELECT — map + // these dimensions to the inner `keys` subquery alias, and + // both sides of the ON clause collapse to `keys. = keys.`. + // Clone the parent factory rather than rebuilding from context so + // that any state already added above (currently none, but this + // makes the lineage explicit for future maintenance) is preserved. + let mut join_context_factory = context_factory.clone(); + join_context_factory + .add_cube_name_reference(cube.cube().name().clone(), pk_cube_alias.clone()); + let join_visitor_context = Rc::new(VisitorContext::new( + query_tools.clone(), + &join_context_factory, + None, + )); + let conditions = primary_keys_dimensions .iter() .map(|dim| -> Result<_, CubeError> { @@ -67,7 +86,10 @@ impl<'a> LogicalNodeProcessor<'a, AggregateMultipliedSubquery> Some(keys_query_alias.clone()), alias_in_keys_query, )); - let pk_cube_expr = Expr::Member(MemberExpression::new(dim.clone())); + let pk_cube_expr = Expr::new_member_with_context( + dim.clone(), + join_visitor_context.clone(), + ); Ok(vec![(keys_query_ref, pk_cube_expr)]) }) .collect::, _>>()?; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_fact.yaml b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_fact.yaml index 5c100fb1d9f9d..fd4a7b57ac1ea 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_fact.yaml +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_multi_fact.yaml @@ -50,6 +50,11 @@ cubes: sql: lifetime_value filters: - sql: "{regions.name} = 'East'" + - name: active_count + type: count + sql: "{id}" + filters: + - sql: "{name} LIKE 'A%'" - name: orders sql: "SELECT * FROM orders" diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs index 809c63c0815d0..f7381b4e99a6e 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/multi_fact.rs @@ -487,6 +487,71 @@ async fn test_multiplied_aggregate_hub_sum_measure() { } } +#[tokio::test(flavor = "multi_thread")] +async fn test_multiplied_aggregate_filtered_count_on_pk() { + let ctx = create_context(); + + // customers.active_count (type: count, sql: "{CUBE}.id", filters: [...]) + // grouped by orders.status. + // customers→orders is one_to_many, so customers is multiplied → AggregateMultipliedSubquery + // The measure references only customers → source = Cube branch. + // Regression: the Cube branch used to render the right side of the FK-aggregate + // rejoin via Expr::Member, which the outer factory's render_references mapped + // back to the inner `keys` subquery alias — yielding a tautological + // `ON keys. = keys.` that cross-joined the source table and over-counted. + let query = indoc! {" + measures: + - customers.active_count + dimensions: + - orders.status + order: + - id: orders.status + "}; + + let sql = ctx.build_sql(query).unwrap(); + + // The buggy output contained `keys.customers__id = keys.customers__id`. Flatten + // the SQL so the assertion survives any future line-wrapping in the planner's + // formatter, then walk every ON predicate and assert no tautological equality + // survives. The snapshot below is the airtight guard; this is a targeted + // structural sanity check pinned to the bug shape. + let flat = sql.replace('\n', " "); + let flat_upper = flat.to_ascii_uppercase(); + let mut cursor = 0; + while let Some(rel) = flat_upper[cursor..].find(" ON ") { + let on_end = cursor + rel + 4; + let after = &flat[on_end..]; + let after_upper = &flat_upper[on_end..]; + let bound = [ + " GROUP BY ", + " ORDER BY ", + " LEFT JOIN ", + " INNER JOIN ", + " RIGHT JOIN ", + " UNION ", + ] + .iter() + .filter_map(|kw| after_upper.find(kw)) + .min() + .unwrap_or(after.len()); + for chunk in after[..bound].split(" AND ") { + if let Some((lhs, rhs)) = chunk.split_once('=') { + let lhs = lhs.trim().trim_matches('(').trim(); + let rhs = rhs.trim().trim_matches(')').trim(); + assert!( + lhs != rhs, + "tautological join condition `{lhs} = {rhs}` in:\n{sql}" + ); + } + } + cursor = on_end; + } + + if let Some(result) = ctx.try_execute_pg(query, SEED).await { + insta::assert_snapshot!(result); + } +} + #[tokio::test(flavor = "multi_thread")] async fn test_multiplied_aggregate_with_measure_subquery() { let ctx = create_context(); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_filtered_count_on_pk.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_filtered_count_on_pk.snap new file mode 100644 index 0000000000000..2d694e28d8cf6 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/snapshots/cubesqlplanner__tests__integration__multi_fact__multiplied_aggregate_filtered_count_on_pk.snap @@ -0,0 +1,9 @@ +--- +source: cubesqlplanner/src/tests/integration/multi_fact.rs +expression: result +--- +orders__status | customers__active_count +---------------+------------------------ +completed | 1 +pending | 1 +NULL | 0 diff --git a/rust/cube/cubesqlplanner/nativebridge/src/lib.rs b/rust/cube/cubesqlplanner/nativebridge/src/lib.rs index e668a706a8517..b678c0b7f2b33 100644 --- a/rust/cube/cubesqlplanner/nativebridge/src/lib.rs +++ b/rust/cube/cubesqlplanner/nativebridge/src/lib.rs @@ -7,8 +7,8 @@ use syn::spanned::Spanned; use syn::token::PathSep; use syn::LitStr; use syn::{ - parse_macro_input, punctuated::Punctuated, FnArg, Item, Meta, Pat, Path, PathArguments, - PathSegment, ReturnType, TraitItem, TraitItemFn, Type, + parse_macro_input, punctuated::Punctuated, Data, DeriveInput, Fields, FnArg, Item, Meta, Pat, + Path, PathArguments, PathSegment, ReturnType, TraitItem, TraitItemFn, Type, }; #[proc_macro_attribute] pub fn native_bridge(args: TokenStream, input: TokenStream) -> proc_macro::TokenStream { @@ -19,6 +19,15 @@ pub fn native_bridge(args: TokenStream, input: TokenStream) -> proc_macro::Token Meta::Path(p) => { if p.is_ident("without_imports") { svc.without_imports = true; + } else if p.is_ident("with_static_meta") { + svc.with_static_meta = true; + } else if looks_like_flag(p) { + return syn::Error::new( + p.span(), + "unknown native_bridge flag (expected `without_imports` or `with_static_meta`)", + ) + .to_compile_error() + .into(); } else { svc.static_data_type = Some(p.clone()) } @@ -26,22 +35,29 @@ pub fn native_bridge(args: TokenStream, input: TokenStream) -> proc_macro::Token _ => {} } } - if args.len() > 0 { - let arg = args.first().unwrap(); - match arg { - Meta::Path(p) => svc.static_data_type = Some(p.clone()), - _ => {} - } - } proc_macro::TokenStream::from(svc.into_token_stream()) } +fn looks_like_flag(path: &Path) -> bool { + if path.segments.len() != 1 { + return false; + } + let ident = &path.segments.first().unwrap().ident; + ident + .to_string() + .chars() + .next() + .map(|c| c.is_ascii_lowercase()) + .unwrap_or(false) +} + struct NativeService { ident: Ident, methods: Vec, pub static_data_type: Option, pub without_imports: bool, + pub with_static_meta: bool, } enum NativeMethodType { @@ -133,6 +149,7 @@ impl Parse for NativeService { methods, static_data_type: None, without_imports: false, + with_static_meta: false, } } x => { @@ -161,7 +178,7 @@ impl NativeService { Err(e) => Some(Err(e)), } } - x => panic!("Unexpected pattern: {:?}", x), + _ => panic!("Unexpected pattern in native_bridge trait method argument"), }, FnArg::Receiver(_) => None, }) @@ -538,6 +555,39 @@ impl NativeService { } } } + + fn meta_fn_ident(&self) -> Ident { + let snake = pascal_to_snake_case(&self.ident.to_string()); + format_ident!("{}_bridge_fields_meta", snake) + } + + fn meta_impl(&self) -> proc_macro2::TokenStream { + let meta_fn = self.meta_fn_ident(); + let trait_entries = self + .methods + .iter() + .map(|m| m.field_meta_entry()) + .collect::>(); + let static_extension = match (&self.static_data_type, self.with_static_meta) { + (Some(static_data_type), true) => quote! { + v.extend( + <#static_data_type>::static_fields_meta() + .iter() + .copied(), + ); + }, + _ => quote! {}, + }; + quote! { + pub fn #meta_fn() -> Vec { + let mut v: Vec = vec![ + #( #trait_entries ),* + ]; + #static_extension + v + } + } + } } impl NativeMethod { @@ -763,6 +813,182 @@ impl ToTokens for NativeService { self.struct_impl(), self.struct_bridge_impl(), self.serialization_impl(), + self.meta_impl(), ]); } } + +// Naive PascalCase -> snake_case for trait identifiers used to derive the +// generated `_bridge_fields_meta` free function. Treats every uppercase +// character as a word boundary, so `HTTPSomething` becomes `h_t_t_p_something`. +// Bridge trait names in this codebase do not use consecutive uppercase +// acronyms; revisit if that ever changes. +fn pascal_to_snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (i, ch) in name.chars().enumerate() { + if ch.is_uppercase() { + if i != 0 { + out.push('_'); + } + for low in ch.to_lowercase() { + out.push(low); + } + } else { + out.push(ch); + } + } + out +} + +impl NativeMethod { + fn field_meta_entry(&self) -> proc_macro2::TokenStream { + let name_str = self.ident.to_string(); + let js_name = self + .method_params + .custom_name + .clone() + .unwrap_or_else(|| self.camel_case_name()); + let kind_token = match self.method_params.method_type { + NativeMethodType::Call => quote! { + cubenativeutils::wrappers::bridge_meta::BridgeFieldKind::Call + }, + NativeMethodType::Getter => quote! { + cubenativeutils::wrappers::bridge_meta::BridgeFieldKind::Field + }, + }; + let optional = self.method_params.is_optional; + let vec = self.method_params.is_vec; + quote! { + cubenativeutils::wrappers::bridge_meta::BridgeFieldMeta { + name: #name_str, + js_name: #js_name, + kind: #kind_token, + optional: #optional, + vec: #vec, + } + } + } +} + +#[proc_macro_derive(NativeBridgeStatic, attributes(nbridge_static))] +pub fn derive_native_bridge_static(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let struct_ident = input.ident.clone(); + let entries = match collect_static_field_meta_entries(&input) { + Ok(entries) => entries, + Err(e) => return e.to_compile_error().into(), + }; + let expanded = quote! { + impl #struct_ident { + pub fn static_fields_meta() -> &'static [cubenativeutils::wrappers::bridge_meta::BridgeFieldMeta] { + static FIELDS: &[cubenativeutils::wrappers::bridge_meta::BridgeFieldMeta] = &[ + #( #entries ),* + ]; + FIELDS + } + } + }; + TokenStream::from(expanded) +} + +fn collect_static_field_meta_entries( + input: &DeriveInput, +) -> syn::Result> { + let data = match &input.data { + Data::Struct(d) => d, + _ => { + return Err(syn::Error::new( + input.span(), + "NativeBridgeStatic can only be derived for structs", + )) + } + }; + let fields = match &data.fields { + Fields::Named(named) => &named.named, + _ => { + return Err(syn::Error::new( + input.span(), + "NativeBridgeStatic requires a struct with named fields", + )) + } + }; + + let mut entries = Vec::new(); + for field in fields { + let ident = field.ident.as_ref().unwrap(); + let name_str = ident.to_string(); + let mut js_name: Option = None; + let mut skip = false; + for attr in &field.attrs { + if attr.path().is_ident("serde") { + // Best-effort: serde supports many options with `= value` shapes + // (e.g. `default = "fn"`) that parse_nested_meta cannot pass + // through to our callback unchanged, so we swallow errors and + // pick up only the `rename = "..."` shape we care about. + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + js_name = Some(meta.value()?.parse::()?.value()); + } + Ok(()) + }); + } + if attr.path().is_ident("nbridge_static") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + skip = true; + Ok(()) + } else { + Err(meta.error("unknown nbridge_static option (expected `skip`)")) + } + })?; + } + } + if skip { + continue; + } + let js_name = js_name.unwrap_or_else(|| name_str.clone()); + let optional = field_is_optional(&field.ty); + let vec = field_is_vec_or_optional_vec(&field.ty); + entries.push(quote! { + cubenativeutils::wrappers::bridge_meta::BridgeFieldMeta { + name: #name_str, + js_name: #js_name, + kind: cubenativeutils::wrappers::bridge_meta::BridgeFieldKind::Static, + optional: #optional, + vec: #vec, + } + }); + } + Ok(entries) +} + +fn field_is_optional(ty: &Type) -> bool { + if let Type::Path(tp) = ty { + if let Some(seg) = tp.path.segments.last() { + return seg.ident == "Option"; + } + } + false +} + +fn field_is_vec_or_optional_vec(ty: &Type) -> bool { + if let Type::Path(tp) = ty { + if let Some(seg) = tp.path.segments.last() { + if seg.ident == "Vec" { + return true; + } + if seg.ident == "Option" { + if let PathArguments::AngleBracketed(inner) = &seg.arguments { + if let Some(syn::GenericArgument::Type(inner_ty)) = inner.args.first() { + if let Type::Path(itp) = inner_ty { + if let Some(iseg) = itp.path.segments.last() { + return iseg.ident == "Vec"; + } + } + } + } + } + } + } + false +}