Skip to main content

activecube_rs/cube/
definition.rs

1use crate::compiler::ir::{JoinType, QueryBuilderFn};
2
3/// Which top-level chain wrapper(s) a cube appears under.
4#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5pub enum ChainGroup {
6    /// EVM chains (eth, bsc, ...) — wrapper carries a `network` argument.
7    Evm,
8    /// Solana — implicit `sol` chain, no network argument needed.
9    Solana,
10    /// Cross-chain aggregated cubes (OHLC, TokenStats, ...).
11    Trading,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum DimType {
16    String,
17    Int,
18    Float,
19    DateTime,
20    Bool,
21}
22
23#[derive(Debug, Clone)]
24pub struct Dimension {
25    pub graphql_name: String,
26    pub column: String,
27    pub dim_type: DimType,
28    pub description: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32pub enum DimensionNode {
33    Leaf(Dimension),
34    Group {
35        graphql_name: String,
36        description: Option<String>,
37        children: Vec<DimensionNode>,
38    },
39    /// Array dimension: maps to parallel ClickHouse Array columns.
40    /// Each element is a structured object with fields from aligned arrays.
41    Array {
42        graphql_name: String,
43        description: Option<String>,
44        children: Vec<ArrayFieldDef>,
45    },
46}
47
48/// One field inside an Array dimension, backed by a ClickHouse Array(String) column.
49#[derive(Debug, Clone)]
50pub struct ArrayFieldDef {
51    pub graphql_name: String,
52    pub column: String,
53    pub field_type: ArrayFieldType,
54    pub description: Option<String>,
55}
56
57/// Type of an array field: scalar or polymorphic Union.
58#[derive(Debug, Clone)]
59pub enum ArrayFieldType {
60    Scalar(DimType),
61    Union(Vec<UnionVariant>),
62}
63
64/// One variant of a GraphQL Union type.
65#[derive(Debug, Clone)]
66pub struct UnionVariant {
67    /// GraphQL type name, e.g. "Solana_ABI_Integer_Value_Arg"
68    pub type_name: String,
69    /// GraphQL field name inside the variant, e.g. "integer"
70    pub field_name: String,
71    /// Scalar type of the value, e.g. DimType::Int
72    pub source_type: DimType,
73}
74
75/// A named selector defines a filterable field on a Cube.
76/// Each selector maps a GraphQL argument name to a column + type,
77/// enabling `eq`, `gt`, `in`, `any` etc.
78#[derive(Debug, Clone)]
79pub struct SelectorDef {
80    pub graphql_name: String,
81    pub column: String,
82    pub dim_type: DimType,
83}
84
85pub fn selector(graphql_name: &str, column: &str, dim_type: DimType) -> SelectorDef {
86    SelectorDef {
87        graphql_name: graphql_name.to_string(),
88        column: column.to_string(),
89        dim_type,
90    }
91}
92
93/// Metric definition — standard SQL aggregate or custom expression.
94#[derive(Debug, Clone)]
95pub struct MetricDef {
96    pub name: String,
97    /// If None, uses the standard SQL function (COUNT/SUM/AVG/...).
98    /// If Some, uses this SQL template with `{column}` as placeholder.
99    /// Example: `"sumIf({column}, direction='in') - sumIf({column}, direction='out')"`
100    pub expression_template: Option<String>,
101    pub return_type: DimType,
102    pub description: Option<String>,
103    /// Whether this metric supports conditional aggregation (countIf/sumIf).
104    pub supports_where: bool,
105}
106
107impl MetricDef {
108    pub fn standard(name: &str) -> Self {
109        Self {
110            name: name.to_string(),
111            expression_template: None,
112            return_type: DimType::Float,
113            description: None,
114            supports_where: true,
115        }
116    }
117
118    pub fn custom(name: &str, expression: &str) -> Self {
119        Self {
120            name: name.to_string(),
121            expression_template: Some(expression.to_string()),
122            return_type: DimType::Float,
123            description: None,
124            supports_where: false,
125        }
126    }
127
128    pub fn with_description(mut self, desc: &str) -> Self {
129        self.description = Some(desc.to_string());
130        self
131    }
132
133    pub fn with_return_type(mut self, rt: DimType) -> Self {
134        self.return_type = rt;
135        self
136    }
137}
138
139/// Helper to create a list of standard metrics from names.
140pub fn standard_metrics(names: &[&str]) -> Vec<MetricDef> {
141    names.iter().map(|n| MetricDef::standard(n)).collect()
142}
143
144/// Multi-table routing: a single Cube can map to different physical tables
145/// depending on which columns the query requests.
146#[derive(Debug, Clone)]
147pub struct TableRoute {
148    pub schema: String,
149    pub table_pattern: String,
150    /// Columns available in this table. If empty, this route serves all queries.
151    pub available_columns: Vec<String>,
152    /// Lower priority = preferred. The primary table (schema/table_pattern) has implicit priority 0.
153    pub priority: u32,
154}
155
156/// Declares a JOIN relationship from this cube to another cube.
157#[derive(Debug, Clone)]
158pub struct JoinDef {
159    /// GraphQL field name on the source record, e.g. "joinTransfers"
160    pub field_name: String,
161    /// Target cube name as registered in the CubeRegistry, e.g. "Transfers"
162    pub target_cube: String,
163    /// (local_column, remote_column) pairs for the ON clause.
164    pub conditions: Vec<(String, String)>,
165    pub description: Option<String>,
166    /// JOIN type — defaults to Left.
167    pub join_type: JoinType,
168}
169
170pub fn join_def(field_name: &str, target_cube: &str, conditions: &[(&str, &str)]) -> JoinDef {
171    JoinDef {
172        field_name: field_name.to_string(),
173        target_cube: target_cube.to_string(),
174        conditions: conditions.iter().map(|(l, r)| (l.to_string(), r.to_string())).collect(),
175        description: None,
176        join_type: JoinType::Left,
177    }
178}
179
180pub fn join_def_desc(field_name: &str, target_cube: &str, conditions: &[(&str, &str)], desc: &str) -> JoinDef {
181    JoinDef {
182        field_name: field_name.to_string(),
183        target_cube: target_cube.to_string(),
184        conditions: conditions.iter().map(|(l, r)| (l.to_string(), r.to_string())).collect(),
185        description: Some(desc.to_string()),
186        join_type: JoinType::Left,
187    }
188}
189
190pub fn join_def_typed(
191    field_name: &str, target_cube: &str,
192    conditions: &[(&str, &str)],
193    join_type: JoinType,
194) -> JoinDef {
195    JoinDef {
196        field_name: field_name.to_string(),
197        target_cube: target_cube.to_string(),
198        conditions: conditions.iter().map(|(l, r)| (l.to_string(), r.to_string())).collect(),
199        description: None,
200        join_type,
201    }
202}
203
204#[derive(Debug, Clone)]
205pub struct CubeDefinition {
206    pub name: String,
207    pub schema: String,
208    /// Table name pattern. Use `{chain}` as placeholder for chain-prefixed tables
209    /// (e.g. `{chain}_trades` → `sol_trades`). For tables without chain prefix
210    /// (e.g. `dex_pool_liquidities`), use the literal table name and set
211    /// `chain_column` instead.
212    pub table_pattern: String,
213    /// If set, the table doesn't use a `{chain}` prefix in its name. Instead,
214    /// the chain is filtered via `WHERE <chain_column> = ?`. Example:
215    /// `dex_pool_liquidities` has a `chain` column rather than `sol_dex_pool_liquidities`.
216    pub chain_column: Option<String>,
217    pub dimensions: Vec<DimensionNode>,
218    pub metrics: Vec<MetricDef>,
219    pub selectors: Vec<SelectorDef>,
220    pub default_filters: Vec<(String, String)>,
221    pub default_limit: u32,
222    pub max_limit: u32,
223    /// Append FINAL to FROM clause for ReplacingMergeTree tables in ClickHouse.
224    pub use_final: bool,
225    /// Human-readable description of the cube's purpose, exposed via _cubeMetadata.
226    pub description: String,
227    /// Declarative JOIN relationships to other cubes.
228    pub joins: Vec<JoinDef>,
229    /// Alternative tables that can serve subsets of this cube's columns.
230    /// When non-empty, `resolve_table` picks the best match by requested columns.
231    pub table_routes: Vec<TableRoute>,
232    /// Custom query builder that bypasses the standard IR → SQL compilation.
233    /// Used for cubes requiring window functions, CTEs, or multi-step subqueries.
234    pub custom_query_builder: Option<QueryBuilderFn>,
235    /// SQL subquery used as the FROM source instead of `schema.table`.
236    /// Supports `{schema}` and `{chain}` placeholders expanded at query time.
237    /// When set, the compiler generates `FROM ({expanded}) AS _t`.
238    pub from_subquery: Option<String>,
239    /// Which chain wrapper(s) this cube appears under. Empty = legacy flat mode.
240    pub chain_groups: Vec<ChainGroup>,
241}
242
243impl CubeDefinition {
244    pub fn table_for_chain(&self, chain: &str) -> String {
245        self.table_pattern.replace("{chain}", chain)
246    }
247
248    pub fn qualified_table(&self, chain: &str) -> String {
249        format!("{}.{}", self.schema, self.table_for_chain(chain))
250    }
251
252    /// Pick the optimal (schema, table) for a given chain and set of requested columns.
253    /// Falls back to the primary schema/table_pattern when no route matches.
254    pub fn resolve_table(&self, chain: &str, requested_columns: &[String]) -> (String, String) {
255        if self.table_routes.is_empty() {
256            return (self.schema.clone(), self.table_for_chain(chain));
257        }
258
259        let mut candidates: Vec<&TableRoute> = self.table_routes.iter()
260            .filter(|r| {
261                r.available_columns.is_empty()
262                    || (!requested_columns.is_empty()
263                        && requested_columns.iter().all(|c| r.available_columns.contains(c)))
264            })
265            .collect();
266
267        candidates.sort_by_key(|r| r.priority);
268
269        if let Some(best) = candidates.first() {
270            (best.schema.clone(), best.table_pattern.replace("{chain}", chain))
271        } else {
272            (self.schema.clone(), self.table_for_chain(chain))
273        }
274    }
275
276    pub fn flat_dimensions(&self) -> Vec<(String, Dimension)> {
277        let mut out = Vec::new();
278        for node in &self.dimensions {
279            collect_leaves(node, "", &mut out);
280        }
281        out
282    }
283
284    /// Check if a metric name exists in this cube's metrics.
285    pub fn has_metric(&self, name: &str) -> bool {
286        self.metrics.iter().any(|m| m.name == name)
287    }
288
289    /// Find a metric definition by name.
290    pub fn find_metric(&self, name: &str) -> Option<&MetricDef> {
291        self.metrics.iter().find(|m| m.name == name)
292    }
293}
294
295fn collect_leaves(node: &DimensionNode, prefix: &str, out: &mut Vec<(String, Dimension)>) {
296    match node {
297        DimensionNode::Leaf(dim) => {
298            let path = if prefix.is_empty() {
299                dim.graphql_name.clone()
300            } else {
301                format!("{}_{}", prefix, dim.graphql_name)
302            };
303            out.push((path, dim.clone()));
304        }
305        DimensionNode::Group { graphql_name, children, .. } => {
306            let new_prefix = if prefix.is_empty() {
307                graphql_name.clone()
308            } else {
309                format!("{prefix}_{graphql_name}")
310            };
311            for child in children {
312                collect_leaves(child, &new_prefix, out);
313            }
314        }
315        DimensionNode::Array { .. } => {
316            // Array dimensions are not flat leaves; they are resolved
317            // via parallel array columns and handled separately in schema generation.
318        }
319    }
320}
321
322// ---------------------------------------------------------------------------
323// CubeBuilder — ergonomic builder pattern for CubeDefinition
324// ---------------------------------------------------------------------------
325
326pub struct CubeBuilder {
327    def: CubeDefinition,
328}
329
330impl CubeBuilder {
331    pub fn new(name: &str) -> Self {
332        Self {
333            def: CubeDefinition {
334                name: name.to_string(),
335                schema: String::new(),
336                table_pattern: String::new(),
337                chain_column: None,
338                dimensions: Vec::new(),
339                metrics: Vec::new(),
340                selectors: Vec::new(),
341                default_filters: Vec::new(),
342                default_limit: 25,
343                max_limit: 10000,
344                use_final: false,
345                description: String::new(),
346                joins: Vec::new(),
347                table_routes: Vec::new(),
348                custom_query_builder: None,
349                from_subquery: None,
350                chain_groups: Vec::new(),
351            },
352        }
353    }
354
355    pub fn schema(mut self, schema: &str) -> Self {
356        self.def.schema = schema.to_string();
357        self
358    }
359
360    pub fn table(mut self, pattern: &str) -> Self {
361        self.def.table_pattern = pattern.to_string();
362        self
363    }
364
365    pub fn chain_column(mut self, column: &str) -> Self {
366        self.def.chain_column = Some(column.to_string());
367        self
368    }
369
370    pub fn dimension(mut self, node: DimensionNode) -> Self {
371        self.def.dimensions.push(node);
372        self
373    }
374
375    /// Add a standard metric (count, sum, avg, min, max, uniq).
376    pub fn metric(mut self, name: &str) -> Self {
377        self.def.metrics.push(MetricDef::standard(name));
378        self
379    }
380
381    /// Add multiple standard metrics by name.
382    pub fn metrics(mut self, names: &[&str]) -> Self {
383        self.def.metrics.extend(names.iter().map(|s| MetricDef::standard(s)));
384        self
385    }
386
387    /// Add a custom metric with an SQL expression template.
388    pub fn custom_metric(mut self, def: MetricDef) -> Self {
389        self.def.metrics.push(def);
390        self
391    }
392
393    pub fn selector(mut self, sel: SelectorDef) -> Self {
394        self.def.selectors.push(sel);
395        self
396    }
397
398    pub fn default_filter(mut self, column: &str, value: &str) -> Self {
399        self.def.default_filters.push((column.to_string(), value.to_string()));
400        self
401    }
402
403    pub fn default_limit(mut self, limit: u32) -> Self {
404        self.def.default_limit = limit;
405        self
406    }
407
408    pub fn max_limit(mut self, limit: u32) -> Self {
409        self.def.max_limit = limit;
410        self
411    }
412
413    pub fn use_final(mut self, val: bool) -> Self {
414        self.def.use_final = val;
415        self
416    }
417
418    pub fn description(mut self, desc: &str) -> Self {
419        self.def.description = desc.to_string();
420        self
421    }
422
423    pub fn join(mut self, j: JoinDef) -> Self {
424        self.def.joins.push(j);
425        self
426    }
427
428    pub fn joins(mut self, js: Vec<JoinDef>) -> Self {
429        self.def.joins.extend(js);
430        self
431    }
432
433    pub fn table_route(mut self, route: TableRoute) -> Self {
434        self.def.table_routes.push(route);
435        self
436    }
437
438    pub fn custom_query_builder(mut self, builder: QueryBuilderFn) -> Self {
439        self.def.custom_query_builder = Some(builder);
440        self
441    }
442
443    pub fn from_subquery(mut self, subquery_sql: &str) -> Self {
444        self.def.from_subquery = Some(subquery_sql.to_string());
445        self
446    }
447
448    pub fn chain_groups(mut self, groups: Vec<ChainGroup>) -> Self {
449        self.def.chain_groups = groups;
450        self
451    }
452
453    pub fn build(self) -> CubeDefinition {
454        self.def
455    }
456}
457
458// ---------------------------------------------------------------------------
459// Helper functions for concise dimension/selector construction
460// ---------------------------------------------------------------------------
461
462pub fn dim(graphql_name: &str, column: &str, dim_type: DimType) -> DimensionNode {
463    DimensionNode::Leaf(Dimension {
464        graphql_name: graphql_name.to_string(),
465        column: column.to_string(),
466        dim_type,
467        description: None,
468    })
469}
470
471pub fn dim_desc(graphql_name: &str, column: &str, dim_type: DimType, desc: &str) -> DimensionNode {
472    DimensionNode::Leaf(Dimension {
473        graphql_name: graphql_name.to_string(),
474        column: column.to_string(),
475        dim_type,
476        description: Some(desc.to_string()),
477    })
478}
479
480pub fn dim_group(graphql_name: &str, children: Vec<DimensionNode>) -> DimensionNode {
481    DimensionNode::Group {
482        graphql_name: graphql_name.to_string(),
483        description: None,
484        children,
485    }
486}
487
488pub fn dim_group_desc(graphql_name: &str, desc: &str, children: Vec<DimensionNode>) -> DimensionNode {
489    DimensionNode::Group {
490        graphql_name: graphql_name.to_string(),
491        description: Some(desc.to_string()),
492        children,
493    }
494}
495
496pub fn dim_array(graphql_name: &str, children: Vec<ArrayFieldDef>) -> DimensionNode {
497    DimensionNode::Array {
498        graphql_name: graphql_name.to_string(),
499        description: None,
500        children,
501    }
502}
503
504pub fn dim_array_desc(graphql_name: &str, desc: &str, children: Vec<ArrayFieldDef>) -> DimensionNode {
505    DimensionNode::Array {
506        graphql_name: graphql_name.to_string(),
507        description: Some(desc.to_string()),
508        children,
509    }
510}
511
512pub fn array_field(graphql_name: &str, column: &str, field_type: ArrayFieldType) -> ArrayFieldDef {
513    ArrayFieldDef {
514        graphql_name: graphql_name.to_string(),
515        column: column.to_string(),
516        field_type,
517        description: None,
518    }
519}
520
521pub fn array_field_desc(graphql_name: &str, column: &str, field_type: ArrayFieldType, desc: &str) -> ArrayFieldDef {
522    ArrayFieldDef {
523        graphql_name: graphql_name.to_string(),
524        column: column.to_string(),
525        field_type,
526        description: Some(desc.to_string()),
527    }
528}
529
530pub fn variant(type_name: &str, field_name: &str, source_type: DimType) -> UnionVariant {
531    UnionVariant {
532        type_name: type_name.to_string(),
533        field_name: field_name.to_string(),
534        source_type,
535    }
536}