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