diff --git a/Cargo.lock b/Cargo.lock index 416b6cb..b6e7837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1328,6 +1328,7 @@ dependencies = [ "prost", "sea-orm", "serde", + "serde_derive", "serde_json", ] @@ -2937,18 +2938,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.180" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea67f183f058fe88a4e3ec6e2788e003840893b91bac4559cabedd00863b3ed" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.180" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24e744d7782b686ab3b73267ef05697159cc0e5abbed3f47f9933165e5219036" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", diff --git a/app/Cargo.toml b/app/Cargo.toml index 5acdae9..5f2e404 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -27,7 +27,8 @@ async-graphql = { version = "5.0.4", features = [ "apollo_tracing", ] } async-graphql-poem = "5.0.3" -serde = { version = "1.0.152", features = ["derive"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_derive = { version = "1.0.188" } serde_json = { version = "1.0.91" } prost = "0.11.6" cube-client = { version = "0.1.2", git = "https://github.com/holaplex/cube-client", branch = "dev" } diff --git a/app/src/cube_client.rs b/app/src/cube_client.rs index 8a89e4d..4334abc 100644 --- a/app/src/cube_client.rs +++ b/app/src/cube_client.rs @@ -9,6 +9,7 @@ use hub_core::{ clap, thiserror, url::Url, }; +use serde::de::DeserializeOwned; /// Arguments for establishing a database connection #[derive(Clone, Debug, clap::Args)] @@ -49,13 +50,22 @@ impl Client { /// /// # Errors /// This function fails if query parameters are invalid or Cube is not responding - pub async fn query(&self, query: Query) -> Result { + pub async fn query( + &self, + query: Query, + ) -> Result, CubeClientError> { let request = V1LoadRequest { query: Some(query.build()), query_type: Some("multi".to_string()), }; let response = cube_api::load_v1(&self.0, Some(request)).await?; - Ok(serde_json::to_string(&response)?) + + response.results[0] + .data + .iter() + .map(|value| serde_json::from_value(value.clone())) + .collect::, serde_json::Error>>() + .map_err(Into::into) } } diff --git a/app/src/entities/customers.rs b/app/src/entities/customers.rs index 3a7978b..ec307af 100644 --- a/app/src/entities/customers.rs +++ b/app/src/entities/customers.rs @@ -1,7 +1,22 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.1 +use async_graphql::Enum; use sea_orm::entity::prelude::*; +#[derive(Clone, Copy, Enum, Debug, PartialEq, Eq)] +#[graphql(name = "CustomerDimension")] +pub enum Dimension { + ProjectId, +} + +impl ToString for Dimension { + fn to_string(&self) -> String { + match self { + Self::ProjectId => String::from("mints.project_id"), + } + } +} + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "customers")] pub struct Model { diff --git a/app/src/entities/mints.rs b/app/src/entities/mints.rs index 19c8d54..08c3508 100644 --- a/app/src/entities/mints.rs +++ b/app/src/entities/mints.rs @@ -1,7 +1,24 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.1 +use async_graphql::Enum; use sea_orm::entity::prelude::*; +#[derive(Clone, Copy, Enum, Debug, PartialEq, Eq)] +#[graphql(name = "MintDimension")] +pub enum Dimension { + ProjectId, + CollectionId, +} + +impl ToString for Dimension { + fn to_string(&self) -> String { + match self { + Self::ProjectId => String::from("mints.project_id"), + Self::CollectionId => String::from("mints.collection_id"), + } + } +} + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "mints")] pub struct Model { diff --git a/app/src/graphql/objects/collection.rs b/app/src/graphql/objects/collection.rs index 4745973..03ee720 100644 --- a/app/src/graphql/objects/collection.rs +++ b/app/src/graphql/objects/collection.rs @@ -1,10 +1,7 @@ use async_graphql::{ComplexObject, Context, Result, SimpleObject}; use hub_core::uuid::Uuid; -use crate::graphql::{ - objects::{DataPoint, Interval, Order}, - queries::analytics::Query, -}; +use crate::graphql::objects::{Interval, Order}; #[derive(Debug, Clone, SimpleObject)] #[graphql(complex)] @@ -15,24 +12,13 @@ pub struct Collection { #[ComplexObject] impl Collection { - #[allow(clippy::too_many_arguments)] async fn analytics( &self, - ctx: &Context<'_>, - interval: Option, - order: Option, - limit: Option, - ) -> Result> { - Query::analytics( - &Query, - ctx, - None, - None, - Some(self.id), - interval, - order, - limit, - ) - .await + _ctx: &Context<'_>, + _interval: Option, + _order: Option, + _limit: Option, + ) -> Result { + Ok(String::new()) } } diff --git a/app/src/graphql/objects/data_point.rs b/app/src/graphql/objects/data_point.rs new file mode 100644 index 0000000..6778ebc --- /dev/null +++ b/app/src/graphql/objects/data_point.rs @@ -0,0 +1,320 @@ +use std::{fmt, str::FromStr}; + +use async_graphql::{Enum, InputObject, SimpleObject}; +pub use cube_client::models::{v1_time::TimeGranularity, V1LoadResponse}; +use either::Either; +use hub_core::{ + anyhow::Result, + chrono::{NaiveDate, NaiveDateTime}, + uuid::Uuid, +}; +use serde::{de, Deserialize, Deserializer, Serialize}; + +#[derive(Debug, Clone, SimpleObject, Deserialize)] +pub struct MintDataPoint { + #[serde( + deserialize_with = "parse_count", + skip_serializing_if = "Option::is_none", + rename(deserialize = "mints.count"), + default + )] + pub count: Option, + #[serde( + deserialize_with = "parse_timestamp", + skip_serializing_if = "Option::is_none", + rename(deserialize = "mints.timestamp"), + default + )] + pub timestamp: Option, + #[serde( + skip_serializing_if = "Option::is_none", + rename(deserialize = "mints.collection_id"), + deserialize_with = "parse_uuid", + default + )] + pub collection_id: Option, + #[serde( + skip_serializing_if = "Option::is_none", + rename(deserialize = "mints.project_id"), + deserialize_with = "parse_uuid", + default + )] + pub project_id: Option, +} + +#[derive(Debug, Clone, SimpleObject, Deserialize)] +pub struct CustomerDataPoint { + #[serde( + skip_serializing_if = "Option::is_none", + rename(deserialize = "customers.count"), + deserialize_with = "parse_count", + default + )] + pub count: Option, + #[serde( + skip_serializing_if = "Option::is_none", + rename(deserialize = "customers.timestamp"), + deserialize_with = "parse_timestamp", + default + )] + pub timestamp: Option, + #[serde( + skip_serializing_if = "Option::is_none", + rename(deserialize = "customers.project_id"), + deserialize_with = "parse_uuid", + default + )] + pub project_id: Option, +} + +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum Granularity { + Hour, + Day, + Week, + Month, + Year, +} + +impl fmt::Display for Granularity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Granularity::Hour => "hour", + Granularity::Day => "day", + _ => "week", + }; + write!(f, "{s}") + } +} + +#[derive(InputObject)] +pub struct Measure { + pub resource: Resource, + pub operation: Operation, +} + +impl Measure { + #[must_use] + pub fn new(resource: Resource, operation: Operation) -> Self { + Self { + resource, + operation, + } + } + #[must_use] + pub fn as_string(&self) -> String { + format!("{}.{}", self.resource, self.operation) + } +} + +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum Operation { + Count, + Change, +} + +impl fmt::Display for Operation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Operation::Count => "count", + Operation::Change => "change", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)] +pub enum Resource { + Mints, + Customers, + Wallets, + Collections, + Projects, + Transfers, + Webhooks, + Credits, +} + +impl fmt::Display for Resource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Resource::Mints => "mints", + Resource::Customers => "customers", + Resource::Wallets => "wallets", + Resource::Collections => "collections", + Resource::Projects => "projects", + Resource::Transfers => "transfers", + Resource::Webhooks => "webhooks", + Resource::Credits => "credits", + }; + write!(f, "{s}") + } +} + +impl FromStr for Resource { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "mints" => Ok(Resource::Mints), + "customers" => Ok(Resource::Customers), + "wallets" => Ok(Resource::Wallets), + "collections" => Ok(Resource::Collections), + "projects" => Ok(Resource::Projects), + "transfers" => Ok(Resource::Transfers), + "webhooks" => Ok(Resource::Webhooks), + "credits" => Ok(Resource::Credits), + _ => Err(()), + } + } +} + +#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)] +pub enum Order { + Asc, + Desc, +} + +impl fmt::Display for Order { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Order::Asc => "asc", + Order::Desc => "desc", + }; + write!(f, "{s}") + } +} + +#[derive(InputObject)] +pub struct DateRange { + pub start: Option, + pub end: Option, + pub interval: Option, +} + +#[derive(Debug, Default, Enum, Copy, Clone, Eq, PartialEq)] +pub enum Interval { + All, + #[default] + Today, + Yesterday, + ThisWeek, + ThisMonth, + ThisYear, + Last7Days, + Last30Days, + LastWeek, + LastMonth, + LastQuarter, + LastYear, +} + +impl fmt::Display for Interval { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Interval::All => "all", + Interval::Today => "today", + Interval::Yesterday => "yesterday", + Interval::ThisWeek => "this week", + Interval::ThisMonth => "this month", + Interval::ThisYear => "this year", + Interval::Last7Days => "last 7 days", + Interval::Last30Days => "last 30 days", + Interval::LastWeek => "last week", + Interval::LastMonth => "last month", + Interval::LastQuarter => "last quarter", + Interval::LastYear => "last year", + }; + write!(f, "{s}") + } +} +impl Interval { + #[must_use] + pub fn to_granularity(&self) -> Granularity { + match self { + Interval::Today | Interval::Yesterday => Granularity::Hour, + Interval::ThisWeek + | Interval::All + | Interval::Last7Days + | Interval::LastWeek + | Interval::ThisMonth + | Interval::Last30Days + | Interval::LastMonth => Granularity::Day, + Interval::LastQuarter => Granularity::Week, + Interval::ThisYear | Interval::LastYear => Granularity::Month, + } + } + + #[must_use] + pub fn to_date_range(&self) -> Either> { + match self { + Self::All => Either::Right(vec![]), + Self::ThisWeek + | Self::Today + | Self::Yesterday + | Self::Last7Days + | Self::LastWeek + | Self::ThisMonth + | Self::Last30Days + | Self::LastMonth + | Self::LastQuarter + | Self::ThisYear + | Self::LastYear => Either::Left(self.to_string()), + } + } +} + +impl From for Vec { + fn from(date_range: DateRange) -> Self { + vec![ + date_range.start.unwrap().format("%Y-%m-%d").to_string(), + date_range.end.unwrap().format("%Y-%m-%d").to_string(), + ] + } +} + +impl From for TimeGranularity { + fn from(input: Granularity) -> Self { + match input { + Granularity::Hour => TimeGranularity::Minute, + Granularity::Day => TimeGranularity::Hour, + Granularity::Week | Granularity::Month => TimeGranularity::Day, + Granularity::Year => TimeGranularity::Month, + } + } +} + +fn parse_count<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(val) => val.parse::().map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} + +fn parse_timestamp<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(val) => NaiveDateTime::parse_from_str(&val, "%Y-%m-%dT%H:%M:%S%.f") + .map(Some) + .map_err(de::Error::custom), + None => Ok(None), + } +} + +fn parse_uuid<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + Some(val) => Uuid::parse_str(&val).map(Some).map_err(de::Error::custom), + None => Ok(None), + } +} diff --git a/app/src/graphql/objects/datapoint.rs b/app/src/graphql/objects/datapoint.rs deleted file mode 100644 index ed621e9..0000000 --- a/app/src/graphql/objects/datapoint.rs +++ /dev/null @@ -1,422 +0,0 @@ -use std::{fmt, str::FromStr}; - -use async_graphql::{Enum, Error, InputObject, SimpleObject}; -pub use cube_client::models::{v1_time::TimeGranularity, V1LoadResponse}; -use hub_core::{ - anyhow::Result, - chrono::{NaiveDate, NaiveDateTime}, - uuid::Uuid, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// A `DataPoint` object containing analytics information. -#[derive(Debug, Default, Clone, Serialize, Deserialize, SimpleObject)] -pub struct DataPoint { - /// Analytics data for mints. - #[serde(skip_serializing_if = "Option::is_none")] - pub mints: Option>, - /// Analytics data for customers. - #[serde(skip_serializing_if = "Option::is_none")] - pub customers: Option>, - /// Analytics data for collections. - #[serde(skip_serializing_if = "Option::is_none")] - pub collections: Option>, - /// Analytics data for wallets. - #[serde(skip_serializing_if = "Option::is_none")] - pub wallets: Option>, - /// Analytics data for projects. - #[serde(skip_serializing_if = "Option::is_none")] - pub projects: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub webhooks: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub credits: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub transfers: Option>, - #[graphql(visible = false)] - pub timestamp: Option, -} - -macro_rules! merge_fields { - ($self:expr, $other:expr, $($field:ident),+) => { - $( - if let Some(ref mut dest) = $self.$field { - if let Some(src) = &$other.$field { - dest.extend_from_slice(src); - } - } else { - $self.$field = $other.$field.clone(); - } - )+ - }; -} - -macro_rules! set_field { - ($self:expr, $resource:expr, $data:expr, $(($enum_variant:ident, $field:ident)),+ ) => { - match $resource { - $( - Resource::$enum_variant => { - $self.$field.get_or_insert_with(Vec::new).push($data.clone()); - } - ),+ - } - }; -} - -impl DataPoint { - #[must_use] - pub fn new() -> Self { - Self { - mints: None, - customers: None, - collections: None, - wallets: None, - projects: None, - transfers: None, - webhooks: None, - credits: None, - timestamp: None, - } - } - - pub fn set(&mut self, resource: Resource, data: &Data, timestamp: Option) { - self.timestamp = timestamp; - set_field!( - self, - resource, - data, - (Mints, mints), - (Customers, customers), - (Wallets, wallets), - (Collections, collections), - (Projects, projects), - (Transfers, transfers), - (Webhooks, webhooks), - (Credits, credits) - ); - } - pub fn merge(&mut self, other: &DataPoint) { - merge_fields!( - self, - other, - mints, - customers, - wallets, - collections, - projects, - transfers, - webhooks, - credits - ); - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize, SimpleObject)] -pub struct Data { - /// Count for the metric. - #[serde(skip_serializing_if = "Option::is_none")] - pub count: Option, - /// The ID of the organization the data belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub organization_id: Option, - /// The ID of the collection the data belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub collection_id: Option, - /// The ID of the project the data belongs to. - #[serde(skip_serializing_if = "Option::is_none")] - pub project_id: Option, - /// the timestamp associated with the data point. - #[serde(skip_serializing_if = "Option::is_none")] - pub timestamp: Option, -} - -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum Granularity { - Hour, - Day, - Week, - Month, - Year, -} - -impl fmt::Display for Granularity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Granularity::Hour => "hour", - Granularity::Day => "day", - _ => "week", - }; - write!(f, "{s}") - } -} - -#[derive(InputObject)] -pub struct Measure { - pub resource: Resource, - pub operation: Operation, -} - -impl Measure { - #[must_use] - pub fn new(resource: Resource, operation: Operation) -> Self { - Self { - resource, - operation, - } - } - #[must_use] - pub fn as_string(&self) -> String { - format!("{}.{}", self.resource, self.operation) - } -} - -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum Operation { - Count, - Change, -} - -impl fmt::Display for Operation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Operation::Count => "count", - Operation::Change => "change", - }; - write!(f, "{s}") - } -} - -#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)] -pub enum Resource { - Mints, - Customers, - Wallets, - Collections, - Projects, - Transfers, - Webhooks, - Credits, -} - -impl fmt::Display for Resource { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Resource::Mints => "mints", - Resource::Customers => "customers", - Resource::Wallets => "wallets", - Resource::Collections => "collections", - Resource::Projects => "projects", - Resource::Transfers => "transfers", - Resource::Webhooks => "webhooks", - Resource::Credits => "credits", - }; - write!(f, "{s}") - } -} - -impl FromStr for Resource { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "mints" => Ok(Resource::Mints), - "customers" => Ok(Resource::Customers), - "wallets" => Ok(Resource::Wallets), - "collections" => Ok(Resource::Collections), - "projects" => Ok(Resource::Projects), - "transfers" => Ok(Resource::Transfers), - "webhooks" => Ok(Resource::Webhooks), - "credits" => Ok(Resource::Credits), - _ => Err(()), - } - } -} - -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum Order { - Asc, - Desc, -} - -impl fmt::Display for Order { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Order::Asc => "asc", - Order::Desc => "desc", - }; - write!(f, "{s}") - } -} - -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum Dimension { - Collections, - Projects, - Organizations, -} - -impl fmt::Display for Dimension { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Dimension::Collections => "collections", - Dimension::Projects => "projects", - Dimension::Organizations => "organizations", - }; - write!(f, "{s}") - } -} - -#[derive(InputObject)] -pub struct DateRange { - pub start: Option, - pub end: Option, - pub interval: Option, -} - -#[derive(Default, Enum, Copy, Clone, Eq, PartialEq)] -pub enum Interval { - All, - #[default] - Today, - Yesterday, - ThisWeek, - ThisMonth, - ThisYear, - Last7Days, - Last30Days, - LastWeek, - LastMonth, - LastQuarter, - LastYear, -} - -impl fmt::Display for Interval { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Interval::All => "all", - Interval::Today => "today", - Interval::Yesterday => "yesterday", - Interval::ThisWeek => "this week", - Interval::ThisMonth => "this month", - Interval::ThisYear => "this year", - Interval::Last7Days => "last 7 days", - Interval::Last30Days => "last 30 days", - Interval::LastWeek => "last week", - Interval::LastMonth => "last month", - Interval::LastQuarter => "last quarter", - Interval::LastYear => "last year", - }; - write!(f, "{s}") - } -} -impl Interval { - #[must_use] - pub fn to_granularity(&self) -> Granularity { - match self { - Interval::Today | Interval::Yesterday => Granularity::Hour, - Interval::ThisWeek - | Interval::All - | Interval::Last7Days - | Interval::LastWeek - | Interval::ThisMonth - | Interval::Last30Days - | Interval::LastMonth => Granularity::Day, - Interval::LastQuarter => Granularity::Week, - Interval::ThisYear | Interval::LastYear => Granularity::Month, - } - } -} -impl From for Vec { - fn from(date_range: DateRange) -> Self { - vec![ - date_range.start.unwrap().format("%Y-%m-%d").to_string(), - date_range.end.unwrap().format("%Y-%m-%d").to_string(), - ] - } -} - -impl From for TimeGranularity { - fn from(input: Granularity) -> Self { - match input { - Granularity::Hour => TimeGranularity::Minute, - Granularity::Day => TimeGranularity::Hour, - Granularity::Week | Granularity::Month => TimeGranularity::Day, - Granularity::Year => TimeGranularity::Month, - } - } -} - -pub struct DataPoints(Vec); -impl DataPoints { - #[must_use] - pub fn into_vec(self) -> Vec { - self.0 - } -} -impl DataPoints { - /// Helper function to get a field and parse it as u64. - fn parse_count(value: &Value, resource: &str) -> Option { - value - .get(&format!("{resource}.count")) - .and_then(Value::as_str) - .and_then(|s| s.parse().ok()) - } - - /// Helper function to get a field and parse it as Uuid. - fn parse_uuid(value: &Value, field: &str) -> Option { - value - .get(field) - .and_then(Value::as_str) - .and_then(|s| Uuid::parse_str(s).ok()) - } - - /// Helper function to get a field and parse it as `NaiveDateTime`. - fn parse_timestamp(value: &Value, field: &str) -> Option { - value - .get(field) - .and_then(Value::as_str) - .and_then(|s| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f").ok()) - } - - /// # Returns - /// a vector of datapoints parsed from the response coming from Cube API - /// - /// # Errors - /// This function returns an error if there was a problem with retrieving the data points. - pub fn from_response(response: &str, resource: Resource) -> Result { - let response: V1LoadResponse = - serde_json::from_str(response).map_err(|e| Error::new(e.to_string()))?; - - hub_core::tracing::info!("Res: {:#?}", response); - let data = response - .results - .first() - .ok_or_else(|| Error::new("No results found"))? - .data - .iter() - .map(|v| { - let mut data_point = DataPoint::new(); - let data = Self::parse_data(v, &resource.to_string()); - data_point.set( - resource, - &data, - Self::parse_timestamp(v, &format!("{resource}.timestamp")), - ); - data_point - }) - .collect(); - - Ok(DataPoints(data)) - } - - fn parse_data(value: &Value, resource: &str) -> Data { - Data { - count: Self::parse_count(value, resource), - organization_id: Self::parse_uuid(value, "projects.organization_id"), - project_id: Self::parse_uuid(value, &format!("{resource}.project_id")), - collection_id: Self::parse_uuid(value, "mints.collection_id"), - timestamp: Self::parse_timestamp(value, &format!("{resource}.timestamp")), - } - } -} diff --git a/app/src/graphql/objects/mod.rs b/app/src/graphql/objects/mod.rs index 1c2c6f0..efe2c24 100644 --- a/app/src/graphql/objects/mod.rs +++ b/app/src/graphql/objects/mod.rs @@ -1,5 +1,5 @@ mod collection; -mod datapoint; +mod data_point; mod organization; mod project; @@ -7,8 +7,8 @@ pub use collection::Collection; pub use cube_client::models::{ V1LoadRequestQueryFilterItem, V1LoadRequestQueryTimeDimension, V1LoadResponse, }; -pub use datapoint::{ - DataPoint, DataPoints, DateRange, Dimension, Granularity, Interval, Measure, Operation, Order, +pub use data_point::{ + CustomerDataPoint, DateRange, Granularity, Interval, Measure, MintDataPoint, Operation, Order, Resource, TimeGranularity, }; pub use organization::Organization; diff --git a/app/src/graphql/objects/organization.rs b/app/src/graphql/objects/organization.rs index cb4c51f..f8d0991 100644 --- a/app/src/graphql/objects/organization.rs +++ b/app/src/graphql/objects/organization.rs @@ -1,10 +1,7 @@ use async_graphql::{ComplexObject, Context, Result, SimpleObject}; use hub_core::uuid::Uuid; -use crate::graphql::{ - objects::{DataPoint, Interval, Order}, - queries::analytics::Query, -}; +use crate::graphql::objects::{Interval, Order}; #[derive(Debug, Clone, SimpleObject)] #[graphql(complex)] @@ -17,21 +14,11 @@ pub struct Organization { impl Organization { async fn analytics( &self, - ctx: &Context<'_>, - interval: Option, - order: Option, - limit: Option, - ) -> Result> { - Query::analytics( - &Query, - ctx, - Some(self.id), - None, - None, - interval, - order, - limit, - ) - .await + _ctx: &Context<'_>, + _interval: Option, + _order: Option, + _limit: Option, + ) -> Result { + Ok(String::new()) } } diff --git a/app/src/graphql/objects/project.rs b/app/src/graphql/objects/project.rs index b857dab..199c50e 100644 --- a/app/src/graphql/objects/project.rs +++ b/app/src/graphql/objects/project.rs @@ -1,11 +1,126 @@ use async_graphql::{ComplexObject, Context, Result, SimpleObject}; use hub_core::uuid::Uuid; -use crate::graphql::{ - objects::{DataPoint, Interval, Order}, - queries::analytics::Query, +use crate::{ + cube_client::{Client, Query}, + entities::{customers, mints}, + graphql::objects::{ + CustomerDataPoint, Interval, MintDataPoint, Order, V1LoadRequestQueryFilterItem as Filter, + V1LoadRequestQueryTimeDimension as TimeDimension, + }, }; +#[derive(Debug, Clone, SimpleObject)] +#[graphql(complex)] +pub struct ProjectAnalytics { + id: Uuid, + interval: Option, + order: Option, + limit: Option, +} + +impl ProjectAnalytics { + pub fn new( + id: Uuid, + interval: Option, + order: Option, + limit: Option, + ) -> Self { + Self { + id, + interval, + order, + limit, + } + } +} + +#[ComplexObject] +impl ProjectAnalytics { + #[allow(clippy::too_many_arguments)] + async fn mints( + &self, + ctx: &Context<'_>, + dimensions: Option>, + ) -> Result> { + let time_dimension = ctx.look_ahead().field("timestamp").exists().then(|| { + let interval = self.interval.unwrap_or_default(); + + TimeDimension::new("mints.timestamp".to_string()) + .date_range(interval.to_date_range()) + .granularity(&interval.to_granularity().to_string()) + .clone() + }); + + let cube = ctx.data::()?; + + let filter = Filter::new() + .member("mints.project_id") + .operator("equals") + .values(vec![self.id.to_string()]); + + let mut query = Query::new() + .limit(self.limit.unwrap_or(100)) + .measures(vec!["mints.count".to_string()]) + .dimensions(dimensions.map_or(vec![], |dimensions| { + dimensions + .into_iter() + .map(|dimension| dimension.to_string()) + .collect() + })) + .filter_member(filter); + + if time_dimension.is_some() { + query = query.time_dimensions(time_dimension); + } + + let results = cube.query::(query).await?; + + Ok(results) + } + + async fn customers( + &self, + ctx: &Context<'_>, + dimensions: Option>, + ) -> Result> { + let time_dimension = ctx.look_ahead().field("timestamp").exists().then(|| { + let interval = self.interval.unwrap_or_default(); + + TimeDimension::new("customers.timestamp".to_string()) + .date_range(interval.to_date_range()) + .granularity(&interval.to_granularity().to_string()) + .clone() + }); + + let cube = ctx.data::()?; + + let filter = Filter::new() + .member("customers.project_id") + .operator("equals") + .values(vec![self.id.to_string()]); + + let mut query = Query::new() + .limit(self.limit.unwrap_or(100)) + .dimensions(dimensions.map_or(vec![], |dimensions| { + dimensions + .into_iter() + .map(|dimension| dimension.to_string()) + .collect() + })) + .measures(vec!["customers.count".to_string()]) + .filter_member(filter); + + if time_dimension.is_some() { + query = query.time_dimensions(time_dimension); + } + + let results = cube.query::(query).await?; + + Ok(results) + } +} + #[derive(Debug, Clone, SimpleObject)] #[graphql(complex)] pub struct Project { @@ -17,21 +132,11 @@ pub struct Project { impl Project { async fn analytics( &self, - ctx: &Context<'_>, + _ctx: &Context<'_>, interval: Option, order: Option, limit: Option, - ) -> Result> { - Query::analytics( - &Query, - ctx, - None, - Some(self.id), - None, - interval, - order, - limit, - ) - .await + ) -> Result { + Ok(ProjectAnalytics::new(self.id, interval, order, limit)) } } diff --git a/app/src/graphql/queries/analytics.rs b/app/src/graphql/queries/analytics.rs deleted file mode 100644 index 2ef3949..0000000 --- a/app/src/graphql/queries/analytics.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::collections::BTreeMap; - -use async_graphql::{Context, Object, Result}; -use either::Either; -use hub_core::{ - chrono::{NaiveDate, NaiveDateTime}, - uuid::Uuid, -}; - -use crate::{ - cube_client::{Client, Query as CubeQuery}, - graphql::objects::{ - DataPoint, DataPoints, Interval, Measure, Operation, Order, Resource, TimeGranularity, - V1LoadRequestQueryFilterItem as Filter, V1LoadRequestQueryTimeDimension as TimeDimension, - }, -}; - -#[derive(Debug, Clone, Default)] -pub struct Query; - -#[Object(name = "AnalyticsQuery")] -impl Query { - /// Returns a list of data points for a specific collection and timeframe. - /// - /// # Arguments - /// * `organizationId` - The ID of the organization - /// * `projectId` - The ID of the project. - /// * `collectionId` - The ID of the collection. - /// * `measures` - An map array of resources to query (resource, operation). - /// * `interval` - The timeframe interval. `TODAY` | `YESTERDAY` | `THIS_MONTH` | `LAST_MONTH` - /// * `order` - order the results by ASC or DESC. - /// * `limit` - Optional limit on the number of data points to retrieve. - /// - /// # Returns - /// A vector of Analytics objects representing the analytics data. - /// - /// # Errors - /// This function returns an error if there was a problem with retrieving the data points. - #[allow(clippy::too_many_arguments)] - pub async fn analytics( - &self, - ctx: &Context<'_>, - organization_id: Option, - project_id: Option, - collection_id: Option, - interval: Option, - order: Option, - limit: Option, - ) -> Result> { - let cube = ctx.data::()?; - let mut datapoints = Vec::new(); - - let selections = Selection::from_context(ctx); - - let (id, root) = parse_id_and_root(organization_id, project_id, collection_id)?; - - let order = order.unwrap_or(Order::Desc); - let mut use_ts = false; - for selection in &selections { - let resource = selection.resource.to_string(); - let ts_dimension = format!("{resource}.timestamp"); - let mut td = TimeDimension::new(ts_dimension.clone()); - td.date_range(Either::Left(interval.unwrap_or_default().to_string())); - - if selection.has_ts { - use_ts = true; - td.granularity = Some(interval.unwrap_or_default().to_granularity()) - .map(|g| TimeGranularity::from(g).to_string()); - } - - let filter = Filter::new() - .member(&format!("{resource}.{root}")) - .operator("equals") - .values(vec![id.clone()]); - - let query = CubeQuery::new() - .limit(limit.unwrap_or(100)) - .order(&ts_dimension, &order.to_string()) - .measures(selection.measures.iter().map(Measure::as_string).collect()) - .dimensions(selection.dimensions.clone()) - .time_dimensions(Some(td.clone())) - .filter_member(filter); - - hub_core::tracing::info!("Query: {query:#?}"); - - datapoints.extend( - DataPoints::from_response(&cube.query(query).await?, selection.resource)? - .into_vec(), - ); - } - - let dummy_ts: NaiveDateTime = NaiveDate::from_ymd_opt(1900, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap(); - - let response = if use_ts { - let mut merged: BTreeMap = BTreeMap::new(); - - for dp in &datapoints { - let timestamp = dp.timestamp.unwrap_or(dummy_ts); - merged - .entry(timestamp) - .and_modify(|existing_dp| existing_dp.merge(dp)) - .or_insert_with(|| dp.clone()); - } - - let mut datapoints: Vec = merged.into_values().collect(); - - for dp in &mut datapoints { - if dp.timestamp == Some(dummy_ts) { - dp.timestamp = None; - } - } - - if matches!(order, Order::Desc) { - datapoints.reverse(); - } - - datapoints - } else { - let mut merged = DataPoint::new(); - datapoints.iter().for_each(|dp| merged.merge(dp)); - vec![merged] - }; - Ok(response) - } -} - -pub struct Selection { - pub resource: Resource, - pub measures: Vec, - pub dimensions: Vec, - pub has_ts: bool, -} - -impl Selection { - #[must_use] - pub fn from_context(ctx: &Context<'_>) -> Vec { - let mut selections: Vec = Vec::new(); - - for field in ctx.field().selection_set() { - if let Ok(resource) = field.name().parse::() { - let mut dimensions = Vec::new(); - let mut measures = Vec::new(); - let mut has_ts = false; - for nested_field in field.selection_set() { - match nested_field.name() { - "count" => measures.push(Measure::new(resource, Operation::Count)), - "organizationId" => dimensions.push("projects.organization_id".to_string()), - "projectId" => dimensions.push(format!("{resource}.project_id")), - "collectionId" => dimensions.push(format!("{resource}.collection_id")), - "timestamp" => has_ts = true, - _ => {}, - } - } - - let selection = Selection { - resource, - measures, - dimensions, - has_ts, - }; - - selections.push(selection); - } - } - - selections - } -} - -fn parse_id_and_root( - organization_id: Option, - project_id: Option, - collection_id: Option, -) -> Result<(String, &'static str), async_graphql::Error> { - match (organization_id, project_id, collection_id) { - (Some(organization_id), None, None) => Ok((organization_id.to_string(), "organization_id")), - (None, Some(project_id), None) => Ok((project_id.to_string(), "project_id")), - (None, None, Some(collection_id)) => Ok((collection_id.to_string(), "collection_id")), - _ => Err(async_graphql::Error::new( - "No valid [project,organization,collection] ID or multiple IDs provided", - )), - } -} diff --git a/app/src/graphql/queries/mod.rs b/app/src/graphql/queries/mod.rs index 2ce1de1..ba96974 100644 --- a/app/src/graphql/queries/mod.rs +++ b/app/src/graphql/queries/mod.rs @@ -1,15 +1,9 @@ #![allow(clippy::unused_async)] -pub mod analytics; mod collection; mod organization; mod project; // // Add your other ones here to create a unified Query object #[derive(async_graphql::MergedObject, Default)] -pub struct Query( - analytics::Query, - organization::Query, - project::Query, - collection::Query, -); +pub struct Query(organization::Query, project::Query, collection::Query); diff --git a/docker-compose.yaml b/docker-compose.yaml index d82c2e9..d4e36ed 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,6 +41,7 @@ services: - CUBEJS_DB_HOST=db - CUBEJS_DB_USER=postgres - CUBEJS_DB_NAME=analytics + - CUBEJS_DB_PORT=5432 - CUBEJS_DB_PASS=holaplex volumes: