Skip to main content

pond/
sql.rs

1//! `pond_sql_query`: read-only DataFusion SQL over the three Lance tables
2//! (`sessions` / `messages` / `parts`), registered as `LanceTableProvider`s
3//! (behind plan-time views that rename `id` to `message_id` / `session_id`)
4//! on a fresh per-call `SessionContext`. Read-only is enforced in two layers - a
5//! single-`SELECT` pre-parse and `sql_with_options` with DDL/DML/statements all
6//! disabled - so no statement that mutates the corpus or touches the filesystem
7//! (INSERT/UPDATE/DELETE/CREATE/DROP/COPY/CREATE EXTERNAL TABLE/SET) can run.
8//! Results render inline (row-capped) or export to a parquet/ndjson file the
9//! caller fetches via the `pond-sql-export://` resource (`src/transport.rs`).
10
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use anyhow::anyhow;
16use arrow_json::LineDelimitedWriter;
17use lance::Dataset;
18use lance::datafusion::LanceTableProvider;
19use lance::deps::arrow_array::builder::{
20    BooleanBuilder, Float64Builder, Int64Builder, StringBuilder,
21};
22use lance::deps::arrow_array::{
23    Array, ArrayRef, GenericStringArray, LargeBinaryArray, OffsetSizeTrait, RecordBatch,
24    StringArray, StringViewArray,
25};
26use lance::deps::arrow_schema::{ArrowError, DataType, Field, Schema, SchemaRef};
27use lance::deps::datafusion::arrow::util::pretty::pretty_format_batches;
28use lance::deps::datafusion::catalog::{Session, TableFunctionImpl, TableProvider};
29use lance::deps::datafusion::common::ScalarValue;
30use lance::deps::datafusion::datasource::{ViewTable, provider_as_source};
31use lance::deps::datafusion::error::DataFusionError;
32use lance::deps::datafusion::execution::SessionStateBuilder;
33use lance::deps::datafusion::execution::runtime_env::RuntimeEnvBuilder;
34use lance::deps::datafusion::logical_expr::{
35    ColumnarValue, LogicalPlanBuilder, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature,
36    TypeSignature, Volatility,
37};
38use lance::deps::datafusion::logical_expr::{Expr, TableType};
39use lance::deps::datafusion::physical_plan::ExecutionPlan;
40use lance::deps::datafusion::prelude::{SQLOptions, SessionConfig, SessionContext, col};
41use lance::deps::datafusion::sql::parser::{DFParser, Statement as DfStatement};
42use lance::deps::datafusion::sql::sqlparser::ast::{SetExpr, Statement as SqlStatement};
43use lance_arrow::SchemaExt;
44use lance_datafusion::udf::register_functions;
45use lance_index::scalar::FullTextSearchQuery;
46use lance_index::scalar::inverted::parser::from_json;
47use parquet::arrow::ArrowWriter;
48
49/// Per-query memory ceiling for the DataFusion runtime. Not enforced on every
50/// operator (datafusion caveat), so the timeout below is the hard backstop.
51const MEM_LIMIT_BYTES: usize = 512 * 1024 * 1024;
52/// Wall-clock cap on `collect()`. DataFusion 53 has no built-in query timeout,
53/// so this `tokio::time::timeout` is the only guard against a runaway plan.
54const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
55/// Byte budget for the inline (rendered table) result; rows are dropped to fit.
56const INLINE_BUDGET_BYTES: usize = 80_000;
57/// Hard ceiling on an export artifact: base64'd over `resources/read` it costs
58/// ~1.33x this in the response, so keep it well under any process envelope.
59const MAX_EXPORT_BYTES: usize = 100 * 1024 * 1024;
60/// Default inline row cap when the caller passes no `limit`.
61pub const DEFAULT_INLINE_ROWS: usize = 100;
62/// Upper bound on the caller-supplied inline `limit`.
63pub const MAX_INLINE_ROWS: usize = 1_000;
64
65/// Export serialization format. Vector columns are excluded and JSON columns
66/// are decoded to text before encoding (see [`displayable`]).
67#[derive(Debug, Clone, Copy)]
68pub enum Format {
69    Parquet,
70    Ndjson,
71}
72
73impl Format {
74    pub fn ext(self) -> &'static str {
75        match self {
76            Self::Parquet => "parquet",
77            Self::Ndjson => "ndjson",
78        }
79    }
80
81    pub fn mime(self) -> &'static str {
82        match self {
83            Self::Parquet => "application/vnd.apache.parquet",
84            Self::Ndjson => "application/x-ndjson",
85        }
86    }
87}
88
89/// How `pond_sql_query` returns results.
90#[derive(Debug, Clone, Copy)]
91pub enum Mode {
92    /// Render a row-capped table into the tool result.
93    Inline,
94    /// Write the full result to a file and return a `pond-sql-export://` link.
95    Export(Format),
96}
97
98/// The Lance datasets a query references, fetched fresh per call so each query
99/// sees a current snapshot (the handle freshness gate runs on each
100/// `Store::dataset`). A field is `None` when the query never names that table -
101/// the caller skips opening it, avoiding the slow `parts.lance` open on the
102/// common messages-only query (spec.md#search). See [`mentions_table`].
103pub struct Tables {
104    pub sessions: Option<Arc<Dataset>>,
105    pub messages: Option<Arc<Dataset>>,
106    pub parts: Option<Arc<Dataset>>,
107}
108
109/// Whether `sql` references the table named `table`. A DataFusion query can
110/// only reach a registered table by writing its name literally - no alias hides
111/// the base name - so this lowercase word-boundary scan never yields a false
112/// negative. At worst it matches the name inside a string or column literal and
113/// opens a table the query won't touch: a cheap, safe false positive. Lets the
114/// caller open only the datasets a query needs.
115pub fn mentions_table(sql: &str, table: &str) -> bool {
116    sql.to_ascii_lowercase()
117        .split(|c: char| !c.is_alphanumeric() && c != '_')
118        .any(|token| token == table)
119}
120
121/// Result of a successful `run`.
122pub enum Outcome {
123    /// A rendered, row-capped table (already includes the metrics footer).
124    Inline(String),
125    /// Encoded export bytes plus metadata for the caller's summary/resource.
126    Export {
127        bytes: Vec<u8>,
128        format: Format,
129        rows: usize,
130        columns: Vec<String>,
131    },
132}
133
134/// Two error channels: `Query` is caller-fixable (parse/plan/exec/limits) and
135/// the tool surfaces it as an `isError` result so the model self-corrects;
136/// `Infra` is an internal failure surfaced as a protocol error.
137#[derive(Debug)]
138pub enum SqlError {
139    Query(String),
140    Infra(anyhow::Error),
141}
142
143fn infra(error: ArrowError) -> SqlError {
144    SqlError::Infra(anyhow::Error::new(error))
145}
146
147/// Execute one read-only SQL query and return either a rendered table, a JSON
148/// payload, or encoded export bytes.
149pub async fn run(
150    tables: &Tables,
151    sql: &str,
152    mode: Mode,
153    inline_rows: usize,
154) -> Result<Outcome, SqlError> {
155    let parsed = parse_and_gate(sql)?;
156    if matches!(parsed.kind, StatementKind::Explain) && matches!(mode, Mode::Export(_)) {
157        return Err(SqlError::Query(
158            "EXPLAIN returns a plan, not a result set; use format=text (or json) to read it"
159                .to_owned(),
160        ));
161    }
162    if projection_mentions_vector(parsed.projection_query()) {
163        return Err(SqlError::Query(
164            "the `vector` column is not selectable from pond_sql_query (it is a \
165             FixedSizeList<f32> embedding, ~600 bytes per row and not useful in a result). \
166             For semantic search use pond_search. Filtering on it is allowed in WHERE \
167             (e.g. `vector IS NOT NULL`)."
168                .to_owned(),
169        ));
170    }
171    if jsonb_cast_misuse(sql) {
172        return Err(SqlError::Query(
173            "CAST / `::` does not work on the binary JSONB columns (variant_data, options) - \
174             when the bytes happen to be valid text it can even silently return garbage. \
175             Stringify the whole value with json_extract(col, '$') or read one field with \
176             json_extract(col, '$.field')."
177                .to_owned(),
178        ));
179    }
180    if jsonb_fulldoc_like_scan(sql) {
181        return Err(SqlError::Query(
182            "a leading-wildcard LIKE over the whole JSONB document - \
183             json_extract(variant_data, '$') LIKE '%...%' - stringifies and scans every row, \
184             so over parts it will not finish within the time limit. There is no substring \
185             index on tool bodies yet (TODO #47: lance v8 FM-Index). Instead match a single \
186             field with json_extract(variant_data, '$.field') LIKE '...', scope to one session \
187             with session_id = '<id>' and read it with pond_get, or search conversational text \
188             with contains_tokens(search_text, '...')."
189                .to_owned(),
190        ));
191    }
192    let ctx = build_context()?;
193    register(&ctx, tables)?;
194
195    // Defense in depth on top of the pre-parse gate: SQLOptions blocks DDL/DML
196    // at planning time. `allow_statements` stays false for a plain SELECT (the
197    // parse-time gate already rejects SET/SHOW etc.) but must be true for
198    // EXPLAIN, which DataFusion classifies as a Statement node. The inner
199    // query of an EXPLAIN was vetted by the gate above.
200    let options = SQLOptions::new()
201        .with_allow_ddl(false)
202        .with_allow_dml(false)
203        .with_allow_statements(matches!(parsed.kind, StatementKind::Explain));
204    let df = ctx
205        .sql_with_options(sql, options)
206        .await
207        .map_err(|error| SqlError::Query(enrich(&format!("SQL error: {error}"))))?;
208
209    // Captured before `collect()` consumes `df`, so an empty result still
210    // renders its column headers.
211    let result_schema = Arc::new(df.schema().as_arrow().clone());
212    let started = Instant::now();
213    // TODO(#47): substring hunts inside parts.variant_data (json_extract +
214    // LIKE full scans) are the dominant real-world cause of this timeout. The
215    // planned fix is lance v8's FM-Index on variant_data (raw-byte substring
216    // search via `contains(variant_data, 'needle')`); until it lands, the
217    // message steers agents to predicates the current indexes can serve.
218    let collected = tokio::time::timeout(QUERY_TIMEOUT, df.collect())
219        .await
220        .map_err(|_| {
221            SqlError::Query(format!(
222                "query exceeded the {}s limit; add a narrower WHERE or a LIMIT. If you were \
223                 substring-scanning variant_data (json_extract + LIKE), there is no \
224                 substring index on tool bodies yet: filter parts by type and \
225                 json_get_string(variant_data, 'name') first, or search conversational \
226                 text with contains_tokens(search_text, '...') instead.",
227                QUERY_TIMEOUT.as_secs()
228            ))
229        })?
230        .map_err(|error| SqlError::Query(enrich(&format!("SQL error: {error}"))))?;
231    let elapsed = started.elapsed();
232
233    let display: Vec<RecordBatch> = if collected.is_empty() {
234        vec![displayable(&RecordBatch::new_empty(result_schema)).map_err(infra)?]
235    } else {
236        collected
237            .into_iter()
238            .map(|batch| displayable(&batch))
239            .collect::<Result<_, _>>()
240            .map_err(infra)?
241    };
242
243    match mode {
244        Mode::Inline => Ok(Outcome::Inline(
245            render_inline(&display, inline_rows, elapsed).map_err(infra)?,
246        )),
247        Mode::Export(format) => {
248            let rows = display.iter().map(RecordBatch::num_rows).sum();
249            let columns = display
250                .first()
251                .map(|batch| {
252                    batch
253                        .schema()
254                        .fields()
255                        .iter()
256                        .map(|field| field.name().clone())
257                        .collect::<Vec<_>>()
258                })
259                .unwrap_or_default();
260            let bytes = match format {
261                Format::Parquet => encode_parquet(&display)?,
262                Format::Ndjson => encode_ndjson(&display)?,
263            };
264            if bytes.len() > MAX_EXPORT_BYTES {
265                return Err(SqlError::Query(format!(
266                    "export is {} bytes, over the {MAX_EXPORT_BYTES} byte limit; \
267                     narrow the query or aggregate",
268                    bytes.len()
269                )));
270            }
271            Ok(Outcome::Export {
272                bytes,
273                format,
274                rows,
275                columns,
276            })
277        }
278    }
279}
280
281/// Top-level statement shape allowed past the read-only gate.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum StatementKind {
284    /// A plain `Query` (SELECT/WITH/VALUES/UNION).
285    Query,
286    /// `EXPLAIN [ANALYZE] <query>` - planning info only, no mutation.
287    Explain,
288}
289
290/// Parsed top-level statement, normalized so downstream checks always see a
291/// projection-bearing `Query` regardless of whether the user wrote `SELECT`
292/// or `EXPLAIN SELECT`. DataFusion's parser wraps EXPLAIN in its own
293/// `DfStatement::Explain` variant (separate from sqlparser's
294/// `SqlStatement::Explain`), so the gate has to peel both layers.
295struct ParsedStatement {
296    kind: StatementKind,
297    query: lance::deps::datafusion::sql::sqlparser::ast::Query,
298}
299
300impl ParsedStatement {
301    fn projection_query(&self) -> &lance::deps::datafusion::sql::sqlparser::ast::Query {
302        &self.query
303    }
304}
305
306/// Read-only gate: parse the SQL and require exactly one top-level `Query` or
307/// `EXPLAIN <Query>`. Rejects DDL/DML/COPY/SET/SHOW and multi-statement input,
308/// which `SQLOptions` alone does not catch at planning time. EXPLAIN of a
309/// non-Query (e.g. `EXPLAIN INSERT ...`) is also rejected: EXPLAIN itself is
310/// read-only, but letting the inner shape be DDL/DML widens the surface area
311/// the gate has to reason about for no real agent gain.
312fn parse_and_gate(sql: &str) -> Result<ParsedStatement, SqlError> {
313    let statements = DFParser::parse_sql(sql)
314        .map_err(|error| SqlError::Query(format!("SQL parse error: {error}")))?;
315    if statements.len() != 1 {
316        return Err(SqlError::Query(
317            "pond_sql_query runs exactly one statement; submit a single SELECT".to_owned(),
318        ));
319    }
320    let Some(front) = statements.front() else {
321        return Err(read_only_rejection());
322    };
323    match front {
324        DfStatement::Statement(boxed) => match boxed.as_ref() {
325            SqlStatement::Query(query) => Ok(ParsedStatement {
326                kind: StatementKind::Query,
327                query: query.as_ref().clone(),
328            }),
329            _ => Err(read_only_rejection()),
330        },
331        DfStatement::Explain(explain) => match explain.statement.as_ref() {
332            DfStatement::Statement(inner) => match inner.as_ref() {
333                SqlStatement::Query(query) => Ok(ParsedStatement {
334                    kind: StatementKind::Explain,
335                    query: query.as_ref().clone(),
336                }),
337                _ => Err(read_only_rejection()),
338            },
339            _ => Err(read_only_rejection()),
340        },
341        _ => Err(read_only_rejection()),
342    }
343}
344
345fn read_only_rejection() -> SqlError {
346    // Surface-neutral wording: this message reaches both the pond_sql_query
347    // MCP tool and the `pond sql` CLI, so it names neither.
348    SqlError::Query(
349        "pond's SQL surface is read-only: only a single SELECT/WITH (or EXPLAIN of one) is \
350         allowed (no INSERT/UPDATE/DELETE/CREATE/DROP/COPY/SET)"
351            .to_owned(),
352    )
353}
354
355/// Reject any top-level projection that explicitly references the embedding
356/// `vector` column. Today such queries silently return an empty column (the
357/// FixedSizeList<f32> is stripped by `displayable`), which wastes agent tokens
358/// diagnosing. WHERE/HAVING references stay legal - the doc lets agents filter
359/// on it (e.g. `WHERE vector IS NOT NULL`); only projecting the column out is
360/// blocked. Heuristic: tokenize each top-level SELECT item and look for a bare
361/// `vector` identifier. Covers `SELECT vector`, `SELECT id, vector`,
362/// `SELECT m.vector`, and `SELECT array_length(vector)`. Wildcards (`*` /
363/// `messages.*`) keep the existing silent-strip behavior since they don't name
364/// the column explicitly.
365fn projection_mentions_vector(query: &lance::deps::datafusion::sql::sqlparser::ast::Query) -> bool {
366    walk_set_expr_for_vector(query.body.as_ref())
367}
368
369fn walk_set_expr_for_vector(expr: &SetExpr) -> bool {
370    match expr {
371        SetExpr::Select(select) => select
372            .projection
373            .iter()
374            .any(|item| mentions_vector_token(&item.to_string())),
375        SetExpr::Query(inner) => walk_set_expr_for_vector(inner.body.as_ref()),
376        SetExpr::SetOperation { left, right, .. } => {
377            walk_set_expr_for_vector(left) || walk_set_expr_for_vector(right)
378        }
379        _ => false,
380    }
381}
382
383fn mentions_vector_token(text: &str) -> bool {
384    text.split(|c: char| !c.is_alphanumeric() && c != '_')
385        .any(|token| token == "vector")
386}
387
388/// Plan-time gate for CAST / `::` on the binary JSONB columns. The runtime
389/// failure is data-dependent (CAST only errors when a non-UTF8 byte is hit;
390/// JSONB header bytes are often valid ASCII, so it can silently "succeed" and
391/// return binary garbage), so reject before scanning. Token-scan heuristic in
392/// the spirit of `projection_mentions_vector`; an aliased column that slips
393/// through still hits the `enrich` runtime hint.
394fn jsonb_cast_misuse(sql: &str) -> bool {
395    const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
396    let lowered = sql.to_ascii_lowercase();
397    let bytes = lowered.as_bytes();
398    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
399
400    // `<col> :: <type>`
401    for column in JSONB_COLUMNS {
402        let mut start = 0;
403        while let Some(pos) = lowered[start..].find(column) {
404            let begin = start + pos;
405            let end = begin + column.len();
406            start = end;
407            let bounded = (begin == 0 || !is_ident(bytes[begin - 1]))
408                && (end == bytes.len() || !is_ident(bytes[end]));
409            if bounded && lowered[end..].trim_start().starts_with("::") {
410                return true;
411            }
412        }
413    }
414
415    // `CAST(<qualifier.>col AS <type>`
416    let mut start = 0;
417    while let Some(pos) = lowered[start..].find("cast") {
418        let begin = start + pos;
419        start = begin + 4;
420        if begin > 0 && is_ident(bytes[begin - 1]) {
421            continue;
422        }
423        let Some(open) = lowered[begin + 4..].trim_start().strip_prefix('(') else {
424            continue;
425        };
426        let mut operand = open.trim_start();
427        if let Some(dot) = operand.find('.')
428            && dot > 0
429            && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
430        {
431            operand = &operand[dot + 1..];
432        }
433        for column in JSONB_COLUMNS {
434            if let Some(after) = operand.strip_prefix(column)
435                && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
436                && after
437                    .trim_start()
438                    .strip_prefix("as")
439                    .is_some_and(|rest| rest.starts_with(char::is_whitespace))
440            {
441                return true;
442            }
443        }
444    }
445    false
446}
447
448/// Plan-time gate for the one substring shape that reliably exhausts the
449/// wall-clock cap: a leading-wildcard LIKE/ILIKE over the *whole-document*
450/// stringify of a binary JSONB column - `json_extract(variant_data|options,
451/// '$') LIKE '%...%'`. That materializes every row's entire JSONB blob just to
452/// substring-scan it, and the leading `%` defeats every index; over parts
453/// (>1M rows) it does not finish, even scoped to a day. A single-field extract
454/// (`'$.name'`) or any non-leading pattern is left to run - only the
455/// whole-document murder shape is rejected, so the agent gets the indexed path
456/// in milliseconds instead of a timeout. Token-scan heuristic in the spirit of
457/// `jsonb_cast_misuse`; the timeout message remains the backstop for anything
458/// that slips through.
459/// TODO(#47): lance v8's FM-Index gives raw-byte substring search
460/// (`contains(variant_data, 'needle')`); retire this gate once it lands.
461fn jsonb_fulldoc_like_scan(sql: &str) -> bool {
462    const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
463    const NEEDLE: &str = "json_extract";
464    let lowered = sql.to_ascii_lowercase();
465    let bytes = lowered.as_bytes();
466    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
467
468    let mut start = 0;
469    while let Some(pos) = lowered[start..].find(NEEDLE) {
470        let begin = start + pos;
471        start = begin + NEEDLE.len();
472        if begin > 0 && is_ident(bytes[begin - 1]) {
473            continue;
474        }
475        let Some(rest) = lowered[start..].trim_start().strip_prefix('(') else {
476            continue;
477        };
478        let mut operand = rest.trim_start();
479        // optional `qualifier.`
480        if let Some(dot) = operand.find('.')
481            && dot > 0
482            && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
483        {
484            operand = &operand[dot + 1..];
485        }
486        let Some(col) = JSONB_COLUMNS.into_iter().find(|c| operand.starts_with(c)) else {
487            continue;
488        };
489        // Require the whole-document path `, '$' )` exactly - a single-field
490        // extract (`'$.name'`) is fine and must keep running.
491        let tail = operand[col.len()..].trim_start();
492        let Some(tail) = tail
493            .strip_prefix(',')
494            .map(str::trim_start)
495            .and_then(|t| t.strip_prefix("'$'"))
496            .map(str::trim_start)
497            .and_then(|t| t.strip_prefix(')'))
498        else {
499            continue;
500        };
501        // Step past any wrapper close-parens (`lower(...)`/`upper(...)`).
502        let mut tail = tail.trim_start();
503        while let Some(next) = tail.strip_prefix(')') {
504            tail = next.trim_start();
505        }
506        if let Some(next) = tail.strip_prefix("not")
507            && next.starts_with(char::is_whitespace)
508        {
509            tail = next.trim_start();
510        }
511        for op in ["like", "ilike"] {
512            if let Some(next) = tail.strip_prefix(op)
513                && next.starts_with(char::is_whitespace)
514                && next.trim_start().starts_with("'%")
515            {
516                return true;
517            }
518        }
519    }
520    false
521}
522
523fn build_context() -> Result<SessionContext, SqlError> {
524    let runtime = RuntimeEnvBuilder::new()
525        .with_memory_limit(MEM_LIMIT_BYTES, 1.0)
526        .build_arc()
527        .map_err(|error| SqlError::Infra(anyhow!("datafusion runtime init failed: {error}")))?;
528    // information_schema is the standard self-discovery path (SELECT ... FROM
529    // information_schema.columns); agents reach for it before any doc.
530    let state = SessionStateBuilder::new()
531        .with_config(SessionConfig::new().with_information_schema(true))
532        .with_runtime_env(runtime)
533        .with_default_features()
534        .build();
535    Ok(SessionContext::new_with_state(state))
536}
537
538/// Plan-time key renames: each table's storage `id` is exposed under a
539/// self-describing name so the same value never changes name between tables -
540/// agents copy column names across queries. One source drives both the
541/// registered views and fts() output so they cannot diverge.
542fn renamed_key(table: &str) -> Option<&'static str> {
543    match table {
544        "messages" => Some("message_id"),
545        "sessions" => Some("session_id"),
546        _ => None,
547    }
548}
549
550fn register(ctx: &SessionContext, tables: &Tables) -> Result<(), SqlError> {
551    for (name, dataset) in [
552        ("sessions", &tables.sessions),
553        ("messages", &tables.messages),
554    ] {
555        let Some(dataset) = dataset else { continue };
556        // LanceTableProvider (not the bare Dataset impl) so WHERE/projection/
557        // limit push into Lance's indexed scan; (false, false) hides _rowid /
558        // _rowaddr from the SQL schema. The view applies `renamed_key`
559        // plan-time only; storage keeps `id`.
560        let provider = LanceTableProvider::new(dataset.clone(), false, false);
561        let key = renamed_key(name).unwrap_or("id");
562        let view = renamed_view(name, Arc::new(provider), "id", key)
563            .map_err(|error| SqlError::Infra(anyhow!("build {name} view: {error}")))?;
564        ctx.register_table(name, Arc::new(view))
565            .map_err(|error| SqlError::Infra(anyhow!("register table {name}: {error}")))?;
566    }
567    // `parts` hides the `data` blob column behind a projecting view: blob
568    // columns scan as `{position, size}` descriptor structs, so any SQL touch
569    // dies in the planner with an opaque CAST error. The view inlines at plan
570    // time - filters still push into the Lance scan underneath.
571    if let Some(parts) = &tables.parts {
572        let provider = LanceTableProvider::new(parts.clone(), false, false);
573        let keep: Vec<_> = parts
574            .schema()
575            .fields
576            .iter()
577            .filter(|field| field.name != "data")
578            .map(|field| col(field.name.as_str()))
579            .collect();
580        let plan = LogicalPlanBuilder::scan("parts", provider_as_source(Arc::new(provider)), None)
581            .and_then(|builder| builder.project(keep))
582            .and_then(LogicalPlanBuilder::build)
583            .map_err(|error| SqlError::Infra(anyhow!("build parts view: {error}")))?;
584        ctx.register_table("parts", Arc::new(ViewTable::new(plan, None)))
585            .map_err(|error| SqlError::Infra(anyhow!("register table parts: {error}")))?;
586    }
587    // `fts('messages', '{...}')` BM25 search-in-SQL (vendored provider with a
588    // declared `_score` column - see `ScoredFtsUdtf`), and lance's JSON /
589    // contains_tokens UDFs for filtering inside the JSON columns. Only the
590    // referenced tables are present, matching the registered views above.
591    let datasets = [
592        ("sessions", &tables.sessions),
593        ("messages", &tables.messages),
594        ("parts", &tables.parts),
595    ]
596    .into_iter()
597    .filter_map(|(name, dataset)| dataset.clone().map(|d| (name.to_owned(), d)))
598    .collect();
599    let fts = ScoredFtsUdtf { datasets };
600    ctx.register_udtf("fts", Arc::new(fts));
601    register_functions(ctx);
602    // Shadow lance's strict json_get_* by name: the strict versions abort the
603    // whole scan when any row's field is non-scalar (e.g. tool_result `result`
604    // arrays), turning one polymorphic value into a dead query.
605    for udf in lenient_json_udfs() {
606        ctx.register_udf(udf);
607    }
608    // `any_value` (Postgres 16 / DuckDB / BigQuery - agents reach for it)
609    // doesn't exist in DataFusion 53; alias first_value, which satisfies the
610    // same contract (any_value promises no ordering, so first-encountered is
611    // a valid answer). register_udaf indexes aliases.
612    if let Some(first_value) = ctx.state().aggregate_functions().get("first_value") {
613        ctx.register_udaf(first_value.as_ref().clone().with_aliases(["any_value"]));
614    }
615    // `fts` as a *scalar* exists only to fail at plan time with the correction:
616    // agents pattern-match FTS into WHERE (MySQL MATCH / Postgres @@ priors)
617    // and DataFusion's stock error is "Did you mean 'cos'?". Scalar and
618    // table-function registries are separate namespaces, so the real fts()
619    // UDTF in FROM position is unaffected.
620    ctx.register_udf(ScalarUDF::new_from_impl(FtsMisuse::new()));
621    Ok(())
622}
623
624/// Wrap `provider` in a view projecting every column, with `from` renamed to
625/// `to`. The view inlines at plan time, so filters and projections still push
626/// into the underlying Lance scan.
627fn renamed_view(
628    scan_name: &str,
629    provider: Arc<dyn TableProvider>,
630    from: &str,
631    to: &str,
632) -> Result<ViewTable, DataFusionError> {
633    let projection: Vec<_> = provider
634        .schema()
635        .fields()
636        .iter()
637        .map(|field| {
638            let column = col(field.name().as_str());
639            if field.name() == from {
640                column.alias(to)
641            } else {
642                column
643            }
644        })
645        .collect();
646    let plan = LogicalPlanBuilder::scan(scan_name, provider_as_source(provider), None)?
647        .project(projection)?
648        .build()?;
649    Ok(ViewTable::new(plan, None))
650}
651
652const FTS_MISUSE: &str = "fts is a table function and goes in FROM, not in WHERE or the \
653    projection. For filtering use WHERE contains_tokens(search_text, 'word1 word2') (all \
654    words must match; index-accelerated). For ranked results: SELECT m.message_id, f._score \
655    FROM fts('messages', '{\"match\":{\"column\":\"search_text\",\"terms\":\"...\"}}') f \
656    JOIN messages m ON m.message_id = f.message_id ORDER BY f._score DESC.";
657
658/// See the registration comment: a plan-time teaching error for `WHERE fts(...)`.
659#[derive(Debug, PartialEq, Eq, Hash)]
660struct FtsMisuse {
661    signature: Signature,
662}
663
664impl FtsMisuse {
665    fn new() -> Self {
666        Self {
667            signature: Signature::variadic_any(Volatility::Immutable),
668        }
669    }
670}
671
672impl ScalarUDFImpl for FtsMisuse {
673    fn as_any(&self) -> &dyn std::any::Any {
674        self
675    }
676
677    fn name(&self) -> &str {
678        "fts"
679    }
680
681    fn signature(&self) -> &Signature {
682        &self.signature
683    }
684
685    fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
686        Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
687    }
688
689    fn invoke_with_args(
690        &self,
691        _args: ScalarFunctionArgs,
692    ) -> Result<ColumnarValue, DataFusionError> {
693        Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
694    }
695}
696
697/// Vendored replacement for lance's `FtsQueryUDTF` (lance-7.0.0
698/// src/dataset/udtf.rs). The upstream provider omits `_score` from its
699/// declared schema while leaving the scanner's scoring autoprojection on, so
700/// `_score` is physically appended but logically unknown: naming it in SQL
701/// fails ("No field named _score") and any aggregate over fts() dies on
702/// DataFusion's physical-vs-logical schema check (COUNT plans 0 columns,
703/// receives 1). This provider declares `_score` as a regular nullable Float32
704/// column, projects it explicitly, and disables the autoprojection - which is
705/// also lance's documented intended end state for score columns
706/// (scanner.rs "_score/_distance should become regular output columns").
707/// Delete once fixed upstream.
708#[derive(Debug)]
709struct ScoredFtsUdtf {
710    datasets: HashMap<String, Arc<Dataset>>,
711}
712
713impl TableFunctionImpl for ScoredFtsUdtf {
714    fn call(
715        &self,
716        expr: &[Expr],
717    ) -> Result<Arc<dyn TableProvider>, lance::deps::datafusion::error::DataFusionError> {
718        let [table_expr, query_expr] = expr else {
719            return Err(DataFusionError::Execution(
720                "fts() takes (table_name, fts_query_json)".to_owned(),
721            ));
722        };
723        let Expr::Literal(ScalarValue::Utf8(Some(table_name)), _) = table_expr else {
724            return Err(DataFusionError::Execution(
725                "fts() first argument must be a table name string".to_owned(),
726            ));
727        };
728        let Expr::Literal(ScalarValue::Utf8(Some(fts_query)), _) = query_expr else {
729            return Err(DataFusionError::Execution(
730                "fts() second argument must be the fts query as a JSON string".to_owned(),
731            ));
732        };
733        let dataset = self.datasets.get(table_name).ok_or_else(|| {
734            DataFusionError::Execution(format!("fts(): table {table_name} not found"))
735        })?;
736        let mut full_schema = Schema::from(dataset.schema());
737        full_schema = full_schema
738            .try_with_column(Field::new(SCORE_COLUMN, DataType::Float32, true))
739            .map_err(|error| DataFusionError::ArrowError(Box::new(error), None))?;
740        let provider: Arc<dyn TableProvider> = Arc::new(ScoredFtsProvider {
741            dataset: dataset.clone(),
742            fts_query: FullTextSearchQuery::new_query(from_json(fts_query)?),
743            full_schema: Arc::new(full_schema),
744        });
745        // Same `renamed_key` as the registered views, so fts() output joins
746        // without a name switch.
747        match renamed_key(table_name) {
748            Some(key) => Ok(Arc::new(renamed_view("fts", provider, "id", key)?)),
749            None => Ok(provider),
750        }
751    }
752}
753
754const SCORE_COLUMN: &str = "_score";
755
756#[derive(Debug)]
757struct ScoredFtsProvider {
758    dataset: Arc<Dataset>,
759    fts_query: FullTextSearchQuery,
760    full_schema: SchemaRef,
761}
762
763#[async_trait::async_trait]
764impl TableProvider for ScoredFtsProvider {
765    fn as_any(&self) -> &dyn std::any::Any {
766        self
767    }
768
769    fn schema(&self) -> SchemaRef {
770        self.full_schema.clone()
771    }
772
773    fn table_type(&self) -> TableType {
774        TableType::Temporary
775    }
776
777    async fn scan(
778        &self,
779        _state: &dyn Session,
780        projection: Option<&Vec<usize>>,
781        filters: &[Expr],
782        limit: Option<usize>,
783    ) -> Result<Arc<dyn ExecutionPlan>, lance::deps::datafusion::error::DataFusionError> {
784        let mut scan = self.dataset.scan();
785        scan.full_text_search(self.fts_query.clone())?;
786        // `_score` is a declared column projected explicitly below; with the
787        // autoprojection off, the physical batch always matches the logical
788        // plan (the mismatch is what breaks aggregates upstream).
789        scan.disable_scoring_autoprojection();
790        match projection {
791            Some(projection) if projection.is_empty() => {
792                scan.empty_project()?;
793            }
794            Some(projection) => {
795                let columns: Vec<&str> = projection
796                    .iter()
797                    .map(|idx| self.full_schema.field(*idx).name().as_str())
798                    .collect();
799                scan.project(&columns)?;
800            }
801            None => {
802                let columns: Vec<&str> = self
803                    .full_schema
804                    .fields()
805                    .iter()
806                    .map(|field| field.name().as_str())
807                    .collect();
808                scan.project(&columns)?;
809            }
810        }
811        if let Some(combined) = filters
812            .iter()
813            .cloned()
814            .reduce(|left, right| left.and(right))
815        {
816            scan.filter_expr(combined);
817        }
818        scan.limit(limit.map(|l| l as i64), None)?;
819        scan.create_plan().await.map_err(DataFusionError::from)
820    }
821}
822
823/// The four scalar shapes the lenient JSON getters produce.
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
825enum JsonGet {
826    Text,
827    Int,
828    Float,
829    Bool,
830}
831
832/// Deepest key path the lenient getters accept; deeper nesting is what
833/// json_extract's JSONPath is for.
834const MAX_JSON_KEYS: usize = 6;
835
836/// Lenient replacements for lance's `json_get_string` / `_int` / `_float` /
837/// `_bool`. The strict originals call jsonb's exact converters and turn one
838/// non-scalar field value into a query-wide abort ("Failed to convert to
839/// string: InvalidCast"). Lenient semantics: a string getter serializes
840/// objects/arrays to JSON text; the typed getters return NULL on a
841/// non-coercible value. Unlike lance's one-key originals they take a variadic
842/// key path - `json_get_string(col, 'a', 'b')` - the datafusion-functions-json
843/// convention agents reach for first. Registered after `register_functions`
844/// so they shadow by name.
845fn lenient_json_udfs() -> [ScalarUDF; 4] {
846    let make = |name: &'static str, kind: JsonGet, return_type: DataType| {
847        ScalarUDF::new_from_impl(LenientJsonGet {
848            name,
849            kind,
850            return_type,
851            signature: json_key_path_signature(),
852        })
853    };
854    [
855        make("json_get_string", JsonGet::Text, DataType::Utf8),
856        make("json_get_int", JsonGet::Int, DataType::Int64),
857        make("json_get_float", JsonGet::Float, DataType::Float64),
858        make("json_get_bool", JsonGet::Bool, DataType::Boolean),
859    ]
860}
861
862/// `(LargeBinary, Utf8)` through `(LargeBinary, Utf8 x MAX_JSON_KEYS)`.
863fn json_key_path_signature() -> Signature {
864    let arities = (1..=MAX_JSON_KEYS)
865        .map(|keys| {
866            let mut types = vec![DataType::LargeBinary];
867            types.extend(std::iter::repeat_n(DataType::Utf8, keys));
868            TypeSignature::Exact(types)
869        })
870        .collect();
871    Signature::one_of(arities, Volatility::Immutable)
872}
873
874/// See [`lenient_json_udfs`].
875#[derive(Debug, PartialEq, Eq, Hash)]
876struct LenientJsonGet {
877    name: &'static str,
878    kind: JsonGet,
879    return_type: DataType,
880    signature: Signature,
881}
882
883impl ScalarUDFImpl for LenientJsonGet {
884    fn as_any(&self) -> &dyn std::any::Any {
885        self
886    }
887
888    fn name(&self) -> &str {
889        self.name
890    }
891
892    fn signature(&self) -> &Signature {
893        &self.signature
894    }
895
896    fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
897        Ok(self.return_type.clone())
898    }
899
900    fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue, DataFusionError> {
901        json_get_lenient(&args.args, &self.kind)
902    }
903}
904
905/// One step of the key walk: object member by name, array element by index.
906fn json_step(raw: jsonb::RawJsonb<'_>, key: &str) -> Option<jsonb::OwnedJsonb> {
907    let value = if raw.is_object().unwrap_or(false) {
908        raw.get_by_name(key, false).ok().flatten()
909    } else if raw.is_array().unwrap_or(false) {
910        key.parse::<usize>()
911            .ok()
912            .and_then(|index| raw.get_by_index(index).ok().flatten())
913    } else {
914        None
915    };
916    value.filter(|value| !value.as_raw().is_null().unwrap_or(false))
917}
918
919fn json_get_lenient(
920    args: &[ColumnarValue],
921    kind: &JsonGet,
922) -> Result<ColumnarValue, DataFusionError> {
923    let arrays = ColumnarValue::values_to_arrays(args)?;
924    let Some((jsonb_arg, key_args)) = arrays.split_first().filter(|(_, keys)| !keys.is_empty())
925    else {
926        return Err(DataFusionError::Execution(
927            "json_get_* takes (json_column, 'key', ...) - at least one key".to_owned(),
928        ));
929    };
930    let jsonb_array = jsonb_arg
931        .as_any()
932        .downcast_ref::<LargeBinaryArray>()
933        .ok_or_else(|| {
934            DataFusionError::Execution(
935                "json_get_* argument 1 must be a JSON column (variant_data, options)".to_owned(),
936            )
937        })?;
938    let key_arrays: Vec<&StringArray> = key_args
939        .iter()
940        .map(|key_arg| {
941            key_arg
942                .as_any()
943                .downcast_ref::<StringArray>()
944                .ok_or_else(|| {
945                    DataFusionError::Execution("json_get_* keys must be string literals".to_owned())
946                })
947        })
948        .collect::<Result<_, _>>()?;
949
950    let field = |row: usize| -> Option<jsonb::OwnedJsonb> {
951        if jsonb_array.is_null(row) {
952            return None;
953        }
954        let mut keys = key_arrays.iter();
955        let first = keys.next()?;
956        if first.is_null(row) {
957            return None;
958        }
959        let mut current = json_step(
960            jsonb::RawJsonb::new(jsonb_array.value(row)),
961            first.value(row),
962        )?;
963        for key_array in keys {
964            if key_array.is_null(row) {
965                return None;
966            }
967            current = json_step(current.as_raw(), key_array.value(row))?;
968        }
969        Some(current)
970    };
971
972    let rows = jsonb_array.len();
973    let array: Arc<dyn Array> = match kind {
974        JsonGet::Text => {
975            let mut builder = StringBuilder::with_capacity(rows, 1024);
976            for row in 0..rows {
977                match field(row) {
978                    // Scalar strings come back unquoted; objects/arrays/
979                    // numbers serialize to JSON text instead of erroring.
980                    Some(value) => match value.as_raw().to_str() {
981                        Ok(text) => builder.append_value(text),
982                        Err(_) => builder.append_value(value.to_string()),
983                    },
984                    None => builder.append_null(),
985                }
986            }
987            Arc::new(builder.finish())
988        }
989        JsonGet::Int => {
990            let mut builder = Int64Builder::with_capacity(rows);
991            for row in 0..rows {
992                builder.append_option(field(row).and_then(|value| value.as_raw().to_i64().ok()));
993            }
994            Arc::new(builder.finish())
995        }
996        JsonGet::Float => {
997            let mut builder = Float64Builder::with_capacity(rows);
998            for row in 0..rows {
999                builder.append_option(field(row).and_then(|value| value.as_raw().to_f64().ok()));
1000            }
1001            Arc::new(builder.finish())
1002        }
1003        JsonGet::Bool => {
1004            let mut builder = BooleanBuilder::with_capacity(rows);
1005            for row in 0..rows {
1006                builder.append_option(field(row).and_then(|value| value.as_raw().to_bool().ok()));
1007            }
1008            Arc::new(builder.finish())
1009        }
1010    };
1011    Ok(ColumnarValue::Array(array))
1012}
1013
1014/// Failures name the fix: append a recovery hint to the DataFusion error
1015/// classes agents actually hit, so a failed call teaches the correct next
1016/// query instead of starting a guessing loop. First match wins.
1017fn enrich(message: &str) -> String {
1018    const HINTS: &[(&str, &str)] = &[
1019        (
1020            "No field named",
1021            "columns are messages(session_id, message_id, timestamp, role, source_agent, \
1022             project, content [system-role only], search_text [the conversational text], \
1023             embedding_model, options) | sessions(session_id, parent_session_id, \
1024             parent_message_id, source_agent, created_at, project, options) | \
1025             parts(session_id, message_id, id, ordinal, type, provenance, variant_data, \
1026             options). Part bodies (tool params/results, text) live in parts.variant_data - \
1027             read them with json_extract(variant_data, '$.field'). For text search use \
1028             contains_tokens(search_text, '...') in WHERE, or the fts('messages', ...) \
1029             table function in FROM for ranked results; to read a transcript use pond_get. \
1030             Full doc: resource schema://pond-sql.",
1031        ),
1032        (
1033            "Encountered non UTF-8 data",
1034            "JSON columns (variant_data, options) are binary JSONB - CAST / ::text does not \
1035             work on them. Stringify the whole value with json_extract(col, '$'), or fetch \
1036             one field with json_extract(col, '$.field').",
1037        ),
1038        (
1039            "Resources exhausted",
1040            "the query ran out of memory - usually from carrying whole JSON columns \
1041             (variant_data, options) through a join or sort. Project narrow fields with \
1042             json_extract(col, '$.field') instead of whole columns, filter before joining, \
1043             or export the full set with format=parquet.",
1044        ),
1045        (
1046            "LIKE prefix queries are not supported for bitmap indexes",
1047            "prefix LIKE ('x%') and starts_with() fail on bitmap-indexed columns \
1048             (messages.source_agent). Use equality, \
1049             split_part(source_agent, '/', 1) = '...', or an infix pattern (LIKE '%x%').",
1050        ),
1051        (
1052            "call to 'json_",
1053            "JSON function signatures: json_get_string|json_get_int|json_get_float|\
1054             json_get_bool(col, 'key', ...) walk a key path (array steps by numeric \
1055             index); json_get(col, 'key') returns JSONB for chaining; json_extract(col, \
1056             '$.a.b') takes a JSONPath and returns JSON text of any value (the right tool \
1057             for deeply nested or mixed-type fields).",
1058        ),
1059        (
1060            "Invalid function 'json",
1061            "available JSON functions: json_get_string, json_get_int, json_get_float, \
1062             json_get_bool (col, 'key', ...); json_get(col, 'key') -> JSONB for chaining; \
1063             json_extract(col, '$.a.b') -> JSON text; json_array_contains; \
1064             json_array_length. See resource schema://pond-sql.",
1065        ),
1066        (
1067            // Defensive: lance's fts `boolean` query can plan a CollectLeft
1068            // HashJoin over multi-partition match arms, which the optimizer
1069            // does not always repair (works through pond's vendored fts()
1070            // provider; kept for any path that still trips it).
1071            "does not satisfy distribution requirements",
1072            "this fts query shape planned an unexecutable join. For AND semantics use a \
1073             single match query with operator And: fts('messages', \
1074             '{\"match\":{\"column\":\"search_text\",\"terms\":\"a b\",\"operator\":\"And\"}}'), \
1075             optionally with LIKE post-filters in WHERE.",
1076        ),
1077        (
1078            "position is not found but required for phrase queries",
1079            "the full-text index is built without positions, so \"phrase\" queries are \
1080             unavailable. Use a match query with operator And plus LIKE post-filters for \
1081             exact-substring matching.",
1082        ),
1083    ];
1084    for (pattern, hint) in HINTS {
1085        if message.contains(pattern) {
1086            return format!("{message}\nhint: {hint}");
1087        }
1088    }
1089    message.to_owned()
1090}
1091
1092/// Decode lance JSONB columns to JSON text, then drop columns that don't render
1093/// readably (the embedding `vector` FixedSizeList and any leftover binary).
1094fn displayable(batch: &RecordBatch) -> Result<RecordBatch, ArrowError> {
1095    let decoded = lance_arrow::json::convert_lance_json_to_arrow(batch)?;
1096    let keep: Vec<usize> = decoded
1097        .schema()
1098        .fields()
1099        .iter()
1100        .enumerate()
1101        .filter(|(_, field)| is_displayable(field.data_type()))
1102        .map(|(index, _)| index)
1103        .collect();
1104    decoded.project(&keep)
1105}
1106
1107fn is_displayable(data_type: &DataType) -> bool {
1108    !matches!(
1109        data_type,
1110        DataType::FixedSizeList(_, _)
1111            | DataType::Binary
1112            | DataType::LargeBinary
1113            | DataType::BinaryView
1114            | DataType::FixedSizeBinary(_)
1115    )
1116}
1117
1118/// One physical line per row: embedded newlines in cell values (markdown,
1119/// multi-line commands) otherwise explode a row across many table lines that
1120/// hard-wrap unreadably in narrow clients. The literal two-char `\n` matches
1121/// the JSON escaping agents already read, and keeps row boundaries
1122/// unambiguous. Inline table mode only - json and export modes keep raw data.
1123fn collapse_newlines(batches: &[RecordBatch]) -> Result<Vec<RecordBatch>, ArrowError> {
1124    fn escape<O: OffsetSizeTrait>(array: &GenericStringArray<O>) -> ArrayRef {
1125        let escaped: GenericStringArray<O> =
1126            array.iter().map(|value| value.map(escape_cell)).collect();
1127        Arc::new(escaped)
1128    }
1129    fn escape_cell(text: &str) -> std::borrow::Cow<'_, str> {
1130        if text.contains(['\n', '\r']) {
1131            std::borrow::Cow::Owned(text.replace("\r\n", "\\n").replace(['\n', '\r'], "\\n"))
1132        } else {
1133            std::borrow::Cow::Borrowed(text)
1134        }
1135    }
1136    batches
1137        .iter()
1138        .map(|batch| {
1139            let columns: Vec<ArrayRef> = batch
1140                .columns()
1141                .iter()
1142                .map(|array| match array.data_type() {
1143                    DataType::Utf8 => array
1144                        .as_any()
1145                        .downcast_ref::<StringArray>()
1146                        .map_or_else(|| array.clone(), escape),
1147                    DataType::LargeUtf8 => array
1148                        .as_any()
1149                        .downcast_ref::<GenericStringArray<i64>>()
1150                        .map_or_else(|| array.clone(), escape),
1151                    DataType::Utf8View => array
1152                        .as_any()
1153                        .downcast_ref::<StringViewArray>()
1154                        .map_or_else(
1155                            || array.clone(),
1156                            |view| {
1157                                let escaped: StringViewArray =
1158                                    view.iter().map(|value| value.map(escape_cell)).collect();
1159                                Arc::new(escaped)
1160                            },
1161                        ),
1162                    _ => array.clone(),
1163                })
1164                .collect();
1165            RecordBatch::try_new(batch.schema(), columns)
1166        })
1167        .collect()
1168}
1169
1170fn render_inline(
1171    display: &[RecordBatch],
1172    max_rows: usize,
1173    elapsed: Duration,
1174) -> Result<String, ArrowError> {
1175    let total: usize = display.iter().map(RecordBatch::num_rows).sum();
1176    let elapsed_ms = elapsed.as_millis();
1177    if total == 0 {
1178        // Still render the header so the caller sees the result columns.
1179        return Ok(format!(
1180            "0 rows ({elapsed_ms} ms).\n{}",
1181            pretty_format_batches(display)?
1182        ));
1183    }
1184    let render = |shown: usize| -> Result<String, ArrowError> {
1185        let limited = collapse_newlines(&limit_batches(display, shown))?;
1186        Ok(pretty_format_batches(&limited)?.to_string())
1187    };
1188    let mut shown = total.min(max_rows);
1189    let mut table = render(shown)?;
1190    while table.len() > INLINE_BUDGET_BYTES && shown > 1 {
1191        shown = (shown / 2).max(1);
1192        table = render(shown)?;
1193    }
1194    let mut out = format!("{total} row(s) in {elapsed_ms} ms; showing {shown}.\n{table}");
1195    if shown < total {
1196        out.push_str(&format!(
1197            "\n... {} row(s) omitted. To page: ORDER BY <indexed col> (e.g. timestamp, \
1198             message_id), then in the next call add `WHERE (col, message_id) < \
1199             (<last_col>, <last_message_id>)` - keyset pagination, see schema://pond-sql. \
1200             For the full set: format=parquet or format=ndjson.",
1201            total - shown
1202        ));
1203    }
1204    Ok(out)
1205}
1206
1207fn limit_batches(batches: &[RecordBatch], max_rows: usize) -> Vec<RecordBatch> {
1208    let mut out = Vec::new();
1209    let mut remaining = max_rows;
1210    for batch in batches {
1211        if remaining == 0 {
1212            break;
1213        }
1214        if batch.num_rows() <= remaining {
1215            remaining -= batch.num_rows();
1216            out.push(batch.clone());
1217        } else {
1218            out.push(batch.slice(0, remaining));
1219            remaining = 0;
1220        }
1221    }
1222    out
1223}
1224
1225fn encode_parquet(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1226    let schema = batches
1227        .first()
1228        .map(RecordBatch::schema)
1229        .ok_or_else(|| SqlError::Query("query returned no columns to export".to_owned()))?;
1230    let mut buffer = Vec::new();
1231    let mut writer = ArrowWriter::try_new(&mut buffer, schema, None)
1232        .map_err(|error| SqlError::Infra(anyhow!("parquet init failed: {error}")))?;
1233    for batch in batches {
1234        writer
1235            .write(batch)
1236            .map_err(|error| SqlError::Infra(anyhow!("parquet write failed: {error}")))?;
1237    }
1238    writer
1239        .close()
1240        .map_err(|error| SqlError::Infra(anyhow!("parquet close failed: {error}")))?;
1241    Ok(buffer)
1242}
1243
1244fn encode_ndjson(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1245    let mut buffer = Vec::new();
1246    {
1247        let mut writer = LineDelimitedWriter::new(&mut buffer);
1248        let refs: Vec<&RecordBatch> = batches.iter().collect();
1249        writer
1250            .write_batches(&refs)
1251            .map_err(|error| SqlError::Infra(anyhow!("ndjson write failed: {error}")))?;
1252        writer
1253            .finish()
1254            .map_err(|error| SqlError::Infra(anyhow!("ndjson finish failed: {error}")))?;
1255    }
1256    Ok(buffer)
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    #![allow(clippy::expect_used)]
1262
1263    use super::*;
1264
1265    fn rejected(sql: &str) -> bool {
1266        matches!(parse_and_gate(sql), Err(SqlError::Query(_)))
1267    }
1268
1269    fn parses_as(sql: &str, expected: StatementKind) -> bool {
1270        match parse_and_gate(sql) {
1271            Ok(parsed) => matches!(
1272                (&parsed.kind, &expected),
1273                (StatementKind::Query, StatementKind::Query)
1274                    | (StatementKind::Explain, StatementKind::Explain)
1275            ),
1276            Err(_) => false,
1277        }
1278    }
1279
1280    #[test]
1281    fn mentions_table_is_sound_for_open_pruning() {
1282        // Referenced => must be detected (no false negative, else a valid query
1283        // breaks). Case-insensitive: DataFusion lowercases identifiers.
1284        assert!(mentions_table("SELECT * FROM messages", "messages"));
1285        assert!(mentions_table("select * from MESSAGES", "messages"));
1286        assert!(mentions_table(
1287            "SELECT s.id FROM sessions s JOIN parts p ON s.id = p.session_id",
1288            "parts",
1289        ));
1290        assert!(mentions_table(
1291            "SELECT * FROM fts('messages', '{\"match\":{}}')",
1292            "messages",
1293        ));
1294        assert!(mentions_table(
1295            "WITH x AS (SELECT * FROM sessions) SELECT * FROM x",
1296            "sessions",
1297        ));
1298        // Not referenced => skip the open. Word boundary: a longer identifier
1299        // that merely contains the name is not a reference.
1300        assert!(!mentions_table("SELECT * FROM messages", "parts"));
1301        assert!(!mentions_table("SELECT * FROM messages", "sessions"));
1302        assert!(!mentions_table(
1303            "SELECT counterparts FROM messages",
1304            "parts"
1305        ));
1306    }
1307
1308    #[test]
1309    fn allows_single_select_and_cte() {
1310        assert!(parses_as("SELECT 1", StatementKind::Query));
1311        assert!(parses_as(
1312            "SELECT role, count(*) FROM messages GROUP BY role",
1313            StatementKind::Query
1314        ));
1315        assert!(parses_as(
1316            "WITH t AS (SELECT 1 AS a) SELECT a FROM t",
1317            StatementKind::Query
1318        ));
1319    }
1320
1321    #[test]
1322    fn allows_explain_of_select() {
1323        assert!(parses_as("EXPLAIN SELECT 1", StatementKind::Explain));
1324        assert!(parses_as(
1325            "EXPLAIN ANALYZE SELECT role FROM messages",
1326            StatementKind::Explain
1327        ));
1328    }
1329
1330    #[test]
1331    fn rejects_explain_of_non_query() {
1332        // EXPLAIN of a side-effecting statement: the inner statement is what
1333        // would matter; reject to keep the surface tight.
1334        assert!(rejected("EXPLAIN INSERT INTO messages VALUES ('x')"));
1335    }
1336
1337    #[test]
1338    fn rejects_writes_and_side_effects() {
1339        assert!(rejected("INSERT INTO messages VALUES ('x')"));
1340        assert!(rejected("UPDATE messages SET role = 'x'"));
1341        assert!(rejected("DELETE FROM messages"));
1342        assert!(rejected("CREATE TABLE t (x INT)"));
1343        assert!(rejected("CREATE VIEW v AS SELECT 1"));
1344        assert!(rejected("DROP TABLE messages"));
1345        assert!(rejected(
1346            "CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION '/etc'"
1347        ));
1348        assert!(rejected("COPY (SELECT 1) TO '/tmp/x.parquet'"));
1349        assert!(rejected("SET a = 1"));
1350    }
1351
1352    #[test]
1353    fn rejects_multiple_statements() {
1354        assert!(rejected("SELECT 1; SELECT 2"));
1355        assert!(rejected("SELECT 1; DROP TABLE messages"));
1356    }
1357
1358    #[test]
1359    fn rejects_unparseable() {
1360        assert!(rejected("NOT SQL AT ALL ;;"));
1361    }
1362
1363    fn mentions_vector(sql: &str) -> bool {
1364        match parse_and_gate(sql) {
1365            Ok(parsed) => projection_mentions_vector(parsed.projection_query()),
1366            Err(_) => false,
1367        }
1368    }
1369
1370    #[test]
1371    fn explicit_vector_projection_is_rejected() {
1372        assert!(mentions_vector("SELECT vector FROM messages"));
1373        assert!(mentions_vector("SELECT id, vector FROM messages"));
1374        assert!(mentions_vector("SELECT m.vector FROM messages m"));
1375        assert!(mentions_vector("SELECT array_length(vector) FROM messages"));
1376        assert!(mentions_vector("EXPLAIN SELECT vector FROM messages"));
1377    }
1378
1379    #[test]
1380    fn enrich_appends_recovery_hints() {
1381        // One literal error string per class, captured from real failed calls.
1382        let cases = [
1383            (
1384                "SQL error: Schema error: No field named created_at.",
1385                "schema://pond-sql",
1386            ),
1387            (
1388                "SQL error: External error: Arrow error: Invalid argument error: \
1389                 Encountered non UTF-8 data",
1390                "json_extract",
1391            ),
1392            (
1393                "SQL error: External error: Not supported: LIKE prefix queries are not \
1394                 supported for bitmap indexes",
1395                "split_part",
1396            ),
1397            (
1398                "SQL error: Error during planning: Failed to coerce arguments to satisfy \
1399                 a call to 'json_get_string' function",
1400                "JSONPath",
1401            ),
1402            (
1403                "SQL error: Error during planning: Invalid function 'json_get_json'.",
1404                "json_extract",
1405            ),
1406            (
1407                "SQL error: Resources exhausted: Additional allocation failed for \
1408                 HashJoinInput[0] with top memory consumers",
1409                "json_extract",
1410            ),
1411        ];
1412        for (raw, marker) in cases {
1413            let enriched = enrich(raw);
1414            assert!(enriched.starts_with(raw), "original kept: {enriched}");
1415            assert!(enriched.contains("hint:"), "hint appended: {enriched}");
1416            assert!(enriched.contains(marker), "hint names the fix: {enriched}");
1417        }
1418        // Unrecognized errors pass through untouched.
1419        assert_eq!(
1420            enrich("SQL error: division by zero"),
1421            "SQL error: division by zero"
1422        );
1423    }
1424
1425    #[test]
1426    fn select_star_and_where_vector_are_allowed() {
1427        // `SELECT *` falls through to the existing silent-strip in displayable.
1428        assert!(!mentions_vector("SELECT * FROM messages"));
1429        // Filtering on `vector` is documented as legal (`vector IS NOT NULL`).
1430        assert!(!mentions_vector(
1431            "SELECT message_id FROM messages WHERE vector IS NOT NULL"
1432        ));
1433    }
1434
1435    #[test]
1436    fn jsonb_cast_misuse_detects_cast_and_coloncolon() {
1437        for sql in [
1438            "SELECT CAST(variant_data AS VARCHAR) FROM parts",
1439            "SELECT cast(p.variant_data as text) FROM parts p",
1440            "SELECT variant_data::text FROM parts",
1441            "SELECT p.variant_data :: varchar FROM parts p",
1442            "SELECT options::text FROM messages",
1443            "SELECT lower(CAST(variant_data AS VARCHAR)) FROM parts",
1444        ] {
1445            assert!(jsonb_cast_misuse(sql), "should reject: {sql}");
1446        }
1447    }
1448
1449    #[test]
1450    fn jsonb_cast_misuse_allows_legitimate_use() {
1451        for sql in [
1452            "SELECT json_extract(variant_data, '$') FROM parts",
1453            "SELECT json_get_string(variant_data, 'name') FROM parts",
1454            "SELECT CAST(ordinal AS BIGINT) FROM parts",
1455            "SELECT timestamp::date FROM messages",
1456            // `options` as part of a longer identifier is not the column.
1457            "SELECT my_options::text FROM t",
1458            "SELECT CAST(json_extract(variant_data, '$.x') AS BIGINT) FROM parts",
1459        ] {
1460            assert!(!jsonb_cast_misuse(sql), "should allow: {sql}");
1461        }
1462    }
1463
1464    #[test]
1465    fn jsonb_fulldoc_like_scan_detects_whole_document_substring() {
1466        for sql in [
1467            "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE '%needle%'",
1468            "SELECT * FROM parts p WHERE lower(json_extract(p.variant_data, '$')) LIKE '%x%'",
1469            "SELECT * FROM messages WHERE json_extract(options, '$') ILIKE '%y%'",
1470            "SELECT * FROM parts WHERE json_extract(variant_data,'$') NOT LIKE '%z%'",
1471            // The real timeout shape: day-scoped join still scans every part.
1472            "SELECT p.message_id FROM parts p JOIN messages m ON p.message_id = m.message_id \
1473             WHERE m.timestamp >= '2026-06-11' AND lower(json_extract(p.variant_data, '$')) \
1474             LIKE '%weekly limit%'",
1475        ] {
1476            assert!(jsonb_fulldoc_like_scan(sql), "should reject: {sql}");
1477        }
1478    }
1479
1480    #[test]
1481    fn jsonb_fulldoc_like_scan_allows_targeted_and_nonleading() {
1482        for sql in [
1483            // single-field extract, not the whole document
1484            "SELECT * FROM parts WHERE json_extract(variant_data, '$.name') LIKE '%x%'",
1485            // non-leading (prefix) pattern can be served without a full stringify
1486            "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE 'pre%'",
1487            // plain text LIKE has no whole-document stringify
1488            "SELECT * FROM messages WHERE search_text LIKE '%x%'",
1489            // indexed predicate, the path agents should take
1490            "SELECT * FROM messages WHERE contains_tokens(search_text, 'x')",
1491            // projecting the stringified value is fine; no LIKE scan
1492            "SELECT json_extract(variant_data, '$') FROM parts LIMIT 1",
1493        ] {
1494            assert!(!jsonb_fulldoc_like_scan(sql), "should allow: {sql}");
1495        }
1496    }
1497
1498    #[test]
1499    fn render_inline_collapses_newlines_in_cells() {
1500        let schema = Arc::new(Schema::new(vec![Field::new("t", DataType::Utf8, true)]));
1501        let batch = RecordBatch::try_new(
1502            schema,
1503            vec![Arc::new(StringArray::from(vec![Some(
1504                "line one\nline two\r\nline three",
1505            )]))],
1506        )
1507        .expect("single-column batch");
1508        let out = render_inline(&[batch], 10, Duration::from_millis(1)).expect("render succeeds");
1509        assert!(
1510            out.contains("line one\\nline two\\nline three"),
1511            "newlines collapse to literal \\n: {out}"
1512        );
1513        // The data row renders as one physical line: header rule, header,
1514        // rule, row, rule - the row itself never wraps.
1515        let row_lines: Vec<&str> = out
1516            .lines()
1517            .filter(|line| line.contains("line one"))
1518            .collect();
1519        assert_eq!(row_lines.len(), 1, "one physical line per row: {out}");
1520    }
1521}