1use 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
49const MEM_LIMIT_BYTES: usize = 512 * 1024 * 1024;
52pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
56pub 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}
67const INLINE_BUDGET_BYTES: usize = 80_000;
69const MAX_EXPORT_BYTES: usize = 100 * 1024 * 1024;
72pub const DEFAULT_INLINE_ROWS: usize = 100;
74pub const MAX_INLINE_ROWS: usize = 1_000;
76
77#[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#[derive(Debug, Clone, Copy)]
103pub enum Mode {
104 Inline,
106 Export(Format),
108}
109
110pub struct Tables {
116 pub sessions: Option<Arc<Dataset>>,
117 pub messages: Option<Arc<Dataset>>,
118 pub parts: Option<Arc<Dataset>>,
119}
120
121pub 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
133pub enum Outcome {
135 Inline(String),
137 Export {
139 bytes: Vec<u8>,
140 format: Format,
141 rows: usize,
142 columns: Vec<String>,
143 },
144}
145
146#[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
159pub 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 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 let result_schema = Arc::new(df.schema().as_arrow().clone());
225 let started = Instant::now();
226 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306enum StatementKind {
307 Query,
309 Explain,
311}
312
313struct 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
329fn 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 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
378fn 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
411fn 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 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 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
471fn 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 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 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 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 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
561fn 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 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 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 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 for udf in lenient_json_udfs() {
629 ctx.register_udf(udf);
630 }
631 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 ctx.register_udf(ScalarUDF::new_from_impl(FtsMisuse::new()));
644 Ok(())
645}
646
647fn 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#[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#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
848enum JsonGet {
849 Text,
850 Int,
851 Float,
852 Bool,
853}
854
855const MAX_JSON_KEYS: usize = 6;
858
859fn 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
885fn 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#[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
928fn 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 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
1037fn 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 "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
1118fn 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
1144const CELL_CLIP_CHARS: usize = 1_000;
1151
1152fn 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 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 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 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 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 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 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 assert!(!mentions_vector("SELECT * FROM messages"));
1477 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 "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 "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 "SELECT * FROM parts WHERE json_extract(variant_data, '$.name') LIKE '%x%'",
1533 "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE 'pre%'",
1535 "SELECT * FROM messages WHERE search_text LIKE '%x%'",
1537 "SELECT * FROM messages WHERE contains_tokens(search_text, 'x')",
1539 "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 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 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 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}