Skip to main content

elasticctl_api/search/
esql.rs

1//! ES|QL responses and the sync/async `/_query` runners.
2
3use elasticctl_core::{Error, ErrorKind, Result, Transport};
4use serde_json::{Map, Value};
5use std::time::Duration;
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct EsqlColumn {
9    pub name: String,
10    pub r#type: String,
11}
12
13/// Row-major `values`: one array of cells per row. `columnar` responses are
14/// transposed into this shape by `decode_columnar` so callers always see rows.
15#[derive(Debug, Clone, PartialEq)]
16pub struct EsqlResponse {
17    pub columns: Vec<EsqlColumn>,
18    pub values: Vec<Vec<Value>>,
19    pub is_partial: bool,
20}
21
22fn parse_columns(obj: &Map<String, Value>) -> Result<Vec<EsqlColumn>> {
23    let columns = obj
24        .get("columns")
25        .and_then(Value::as_array)
26        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding esql response field `columns`"))?;
27    let mut out = Vec::with_capacity(columns.len());
28    for (i, col) in columns.iter().enumerate() {
29        let name = col.get("name").and_then(Value::as_str).ok_or_else(|| {
30            Error::new(
31                ErrorKind::Http,
32                format!("decoding esql response column {i} field `name`"),
33            )
34        })?;
35        let ty = col.get("type").and_then(Value::as_str).ok_or_else(|| {
36            Error::new(
37                ErrorKind::Http,
38                format!("decoding esql response column {i} field `type`"),
39            )
40        })?;
41        out.push(EsqlColumn {
42            name: name.to_string(),
43            r#type: ty.to_string(),
44        });
45    }
46    Ok(out)
47}
48
49fn is_partial(obj: &Map<String, Value>) -> bool {
50    obj.get("is_partial")
51        .and_then(Value::as_bool)
52        .unwrap_or(false)
53}
54
55/// Strict decode of a row-major `POST /_query` response. Unknown fields are
56/// accepted; a missing or mistyped `columns`/`values` is an error, never an
57/// empty result.
58pub fn decode(value: &Value) -> Result<EsqlResponse> {
59    let obj = value.as_object().ok_or_else(|| {
60        Error::new(
61            ErrorKind::Http,
62            "decoding esql response: expected an object",
63        )
64    })?;
65    let columns = parse_columns(obj)?;
66
67    let values = obj
68        .get("values")
69        .and_then(Value::as_array)
70        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding esql response field `values`"))?
71        .iter()
72        .map(|row| {
73            let cells = row.as_array().ok_or_else(|| {
74                Error::new(
75                    ErrorKind::Http,
76                    "decoding esql response: `values` rows must be arrays",
77                )
78            })?;
79            if cells.len() != columns.len() {
80                return Err(Error::new(
81                    ErrorKind::Http,
82                    "decoding esql response: `values` row width does not match `columns`",
83                ));
84            }
85            Ok(cells.clone())
86        })
87        .collect::<Result<Vec<_>>>()?;
88
89    Ok(EsqlResponse {
90        columns,
91        values,
92        is_partial: is_partial(obj),
93    })
94}
95
96/// Strict decode of a column-major (`columnar: true`) response. `values` holds
97/// one array per column, each the length of the row count; the result is
98/// transposed into the row-major `EsqlResponse` shape.
99pub fn decode_columnar(value: &Value) -> Result<EsqlResponse> {
100    let obj = value.as_object().ok_or_else(|| {
101        Error::new(
102            ErrorKind::Http,
103            "decoding esql response: expected an object",
104        )
105    })?;
106    let columns = parse_columns(obj)?;
107
108    let cols = obj
109        .get("values")
110        .and_then(Value::as_array)
111        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding esql response field `values`"))?;
112    // A zero-row columnar result is `values: []`, not one empty array per
113    // column (measured against Serverless 9.6.0). Treat it as an empty result
114    // rather than a column-count mismatch.
115    if cols.is_empty() {
116        return Ok(EsqlResponse {
117            columns,
118            values: Vec::new(),
119            is_partial: is_partial(obj),
120        });
121    }
122    if cols.len() != columns.len() {
123        return Err(Error::new(
124            ErrorKind::Http,
125            "decoding esql response: `values` column count does not match `columns`",
126        ));
127    }
128    let arrays = cols
129        .iter()
130        .enumerate()
131        .map(|(i, col)| {
132            col.as_array().ok_or_else(|| {
133                Error::new(
134                    ErrorKind::Http,
135                    format!("decoding esql response: `values` column {i} must be an array"),
136                )
137            })
138        })
139        .collect::<Result<Vec<_>>>()?;
140    let row_count = arrays.first().map(|col| col.len()).unwrap_or(0);
141    if arrays.iter().any(|col| col.len() != row_count) {
142        return Err(Error::new(
143            ErrorKind::Http,
144            "decoding esql response: `values` columns have unequal lengths",
145        ));
146    }
147
148    let mut values = Vec::with_capacity(row_count);
149    for r in 0..row_count {
150        let mut row = Vec::with_capacity(columns.len());
151        for col in &arrays {
152            row.push(col[r].clone());
153        }
154        values.push(row);
155    }
156
157    Ok(EsqlResponse {
158        columns,
159        values,
160        is_partial: is_partial(obj),
161    })
162}
163
164/// Run a synchronous ES|QL query. `query` carries its own `FROM` and `LIMIT`.
165pub async fn run_sync(t: &Transport, query: &str) -> Result<EsqlResponse> {
166    let body = serde_json::json!({ "query": query });
167    let response = t.post_absolute_es("/_query", &body).await?;
168    decode(&response)
169}
170
171/// Run a query through the async API and poll until complete. ES|QL has no
172/// page-by-page cursor; the full result returns in one response. `columnar:
173/// true` keeps the payload and memory footprint low.
174pub async fn run_async(t: &Transport, query: &str) -> Result<EsqlResponse> {
175    let start = t
176        .post_absolute_es(
177            "/_query/async",
178            &serde_json::json!({ "query": query, "wait_for_completion_timeout": "1ms", "columnar": true }),
179        )
180        .await?;
181    // A query finishing within wait_for_completion_timeout returns the inline
182    // result with is_running: false and no `id`; decode it directly.
183    let id = match start.get("id").and_then(Value::as_str) {
184        Some(id) => id.to_string(),
185        None => return decode_columnar(&start),
186    };
187    // The start response can also carry both an `id` and an inline result.
188    // Clean the id up before decoding either way.
189    if start.get("is_running").and_then(Value::as_bool) == Some(false) {
190        let _ = t.delete_absolute_es(&format!("/_query/async/{id}")).await;
191        return decode_columnar(&start);
192    }
193
194    // Poll once a second for up to five minutes: a bulk export may legitimately
195    // run that long, but a query that never finishes must still fail closed.
196    poll_until_complete(t, &id, 300, Duration::from_secs(1)).await
197}
198
199/// Poll `GET /_query/async/{id}` until it reports completion, cleaning up on
200/// every exit path. `max_polls * interval` bounds the wait; `run_async` passes
201/// a bulk-export-sized budget, while tests pass a tiny one.
202pub async fn poll_until_complete(
203    t: &Transport,
204    id: &str,
205    max_polls: usize,
206    interval: Duration,
207) -> Result<EsqlResponse> {
208    for _ in 0..max_polls {
209        let resp = match t.get_absolute_es(&format!("/_query/async/{id}")).await {
210            Ok(resp) => resp,
211            Err(err) => {
212                let _ = t.delete_absolute_es(&format!("/_query/async/{id}")).await;
213                return Err(err);
214            }
215        };
216        if resp.get("is_running").and_then(Value::as_bool) == Some(false) {
217            let _ = t.delete_absolute_es(&format!("/_query/async/{id}")).await;
218            return decode_columnar(&resp);
219        }
220        tokio::time::sleep(interval).await;
221    }
222    let _ = t.delete_absolute_es(&format!("/_query/async/{id}")).await;
223    Err(Error::new(
224        ErrorKind::Timeout,
225        format!("async query {id} still running after {max_polls} polls"),
226    ))
227}