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