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