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