Skip to main content

pond/
sql.rs

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