posthog_cli/commands/
query.rs

1use anyhow::Error;
2use clap::ValueEnum;
3use crossterm::event::{self, Event};
4use ratatui::{
5    layout::Constraint,
6    style::{Color, Style, Stylize},
7    widgets::{Row, Table, TableState},
8    Frame,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13// TODO - we could formalise a lot of this and move it into posthog-rs, tbh
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct QueryRequest {
17    query: Query,
18    refresh: QueryRefresh,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "kind")]
23pub enum Query {
24    HogQLQuery { query: String },
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum QueryRefresh {
30    Blocking,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct HogQLQueryResponse {
35    pub cache_key: Option<String>,
36    pub cache_target_age: Option<String>,
37    pub clickhouse: Option<String>, // Clickhouse query text
38    #[serde(default, deserialize_with = "null_is_empty")]
39    pub columns: Vec<String>, // Columns returned from the query
40    pub error: Option<String>,
41    #[serde(default, deserialize_with = "null_is_empty")]
42    pub explain: Vec<String>,
43    #[serde(default, rename = "hasMore", deserialize_with = "null_is_false")]
44    pub has_more: bool,
45    pub hogql: Option<String>, // HogQL query text
46    #[serde(default, deserialize_with = "null_is_false")]
47    pub is_cached: bool,
48    pub last_refresh: Option<String>, // Last time the query was refreshed
49    pub next_allowed_client_refresh_time: Option<String>, // Next time the client can refresh the query
50    pub offset: Option<i64>,                              // Offset of the query
51    pub query: Option<String>,                            // Query text
52    #[serde(default, deserialize_with = "null_is_empty")]
53    pub types: Vec<(String, String)>,
54    #[serde(default, deserialize_with = "null_is_empty")]
55    pub results: Vec<Vec<Value>>,
56    #[serde(default, deserialize_with = "null_is_empty")]
57    pub timings: Vec<Timing>,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct HogQLQueryErrorResponse {
62    pub code: String,
63    pub detail: String,
64    #[serde(rename = "type")]
65    pub error_type: String,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct Timing {
70    k: String,
71    t: f64,
72}
73
74#[derive(Debug, Clone, Copy, ValueEnum)]
75pub enum OutputMode {
76    Print,
77    Tui,
78}
79
80pub fn run_query(host: &str, to_run: &str, output: OutputMode) -> Result<(), Error> {
81    let client = reqwest::blocking::Client::new();
82    let creds = crate::utils::auth::load_token()?;
83    let query_endpoint = format!("{}/api/environments/{}/query", host, creds.env_id);
84
85    let request = QueryRequest {
86        query: Query::HogQLQuery {
87            query: to_run.to_string(),
88        },
89        refresh: QueryRefresh::Blocking,
90    };
91
92    let response = client
93        .post(&query_endpoint)
94        .json(&request)
95        .bearer_auth(creds.token)
96        .send()?;
97
98    let code = response.status();
99    let body = response.text()?;
100
101    let value: Value = serde_json::from_str(&body)?;
102
103    if code.is_client_error() {
104        let error: HogQLQueryErrorResponse = serde_json::from_value(value)?;
105        println!("{}", serde_json::to_string_pretty(&error)?);
106        return Ok(());
107    }
108
109    let response: HogQLQueryResponse = serde_json::from_value(value)?;
110
111    match output {
112        OutputMode::Print => {
113            for res in response.results {
114                println!("{}", serde_json::to_string(&res)?);
115            }
116        }
117        OutputMode::Tui => {
118            draw_output(&response)?;
119        }
120    }
121
122    Ok(())
123}
124
125fn draw_output(response: &HogQLQueryResponse) -> Result<(), Error> {
126    let mut terminal = ratatui::init();
127    let mut table_state = TableState::default();
128
129    loop {
130        let draw_frame = |f: &mut Frame| draw_table(f, response, &mut table_state);
131        terminal.draw(draw_frame).expect("failed to draw frame");
132        if matches!(event::read().expect("failed to read event"), Event::Key(_)) {
133            break;
134        }
135    }
136    ratatui::restore();
137    Ok(())
138}
139
140fn draw_table(f: &mut Frame, response: &HogQLQueryResponse, table_state: &mut TableState) {
141    let cols = &response.columns;
142    let widths = cols.iter().map(|_| Constraint::Fill(1)).collect::<Vec<_>>();
143    let mut rows: Vec<Row> = Vec::with_capacity(response.results.len());
144    for row in &response.results {
145        let mut row_data = Vec::with_capacity(cols.len());
146        for _ in cols {
147            let value = row[row_data.len()].to_string();
148            row_data.push(value.to_string());
149        }
150        rows.push(Row::new(row_data));
151    }
152    let table = Table::new(rows, widths)
153        .column_spacing(1)
154        .header(Row::new(cols.clone()).style(Style::new().bold().bg(Color::LightBlue)))
155        .block(
156            ratatui::widgets::Block::default()
157                .title("Query Results (press any key to exit)")
158                .title_style(Style::new().bold().fg(Color::White).bg(Color::DarkGray))
159                .borders(ratatui::widgets::Borders::ALL)
160                .border_style(Style::new().fg(Color::White).bg(Color::DarkGray)),
161        )
162        .row_highlight_style(Style::new().bold().bg(Color::Blue))
163        .highlight_symbol(">>");
164
165    f.render_stateful_widget(table, f.area(), table_state);
166}
167
168fn null_is_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
169where
170    D: serde::Deserializer<'de>,
171    T: serde::Deserialize<'de>,
172{
173    let opt = Option::deserialize(deserializer)?;
174    match opt {
175        Some(v) => Ok(v),
176        None => Ok(Vec::new()),
177    }
178}
179
180fn null_is_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
181where
182    D: serde::Deserializer<'de>,
183{
184    let opt = Option::deserialize(deserializer)?;
185    match opt {
186        Some(v) => Ok(v),
187        None => Ok(false),
188    }
189}