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_query (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, or search conversational text \
201 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_query, `--timeout` \
238 on pond sql; max {MAX_QUERY_TIMEOUT_SECS}s) if it legitimately needs \
239 longer. For tool analytics use the narrow native columns (tool_name, \
240 call_id, is_failure) instead of json_get_* over variant_data. If you were \
241 substring-scanning variant_data (json_extract + LIKE), there is no \
242 substring index on tool bodies yet: filter parts by type and tool_name \
243 first, or search conversational text with \
244 contains_tokens(search_text, '...') instead.",
245 timeout.as_secs()
246 ))
247 })?
248 .map_err(|error| SqlError::Query(enrich(&format!("SQL error: {error}"))))?;
249 let elapsed = started.elapsed();
250
251 let display: Vec<RecordBatch> = if collected.is_empty() {
252 vec![displayable(&RecordBatch::new_empty(result_schema)).map_err(infra)?]
253 } else {
254 collected
255 .into_iter()
256 .map(|batch| displayable(&batch))
257 .collect::<Result<_, _>>()
258 .map_err(infra)?
259 };
260
261 match mode {
262 Mode::Inline => Ok(Outcome::Inline(
263 render_inline(&display, inline_rows, elapsed).map_err(infra)?,
264 )),
265 Mode::Export(format) => {
266 let rows = display.iter().map(RecordBatch::num_rows).sum();
267 let columns = display
268 .first()
269 .map(|batch| {
270 batch
271 .schema()
272 .fields()
273 .iter()
274 .map(|field| field.name().clone())
275 .collect::<Vec<_>>()
276 })
277 .unwrap_or_default();
278 let bytes = match format {
279 Format::Parquet => encode_parquet(&display)?,
280 Format::Ndjson => encode_ndjson(&display)?,
281 };
282 if bytes.len() > MAX_EXPORT_BYTES {
283 return Err(SqlError::Query(format!(
284 "export is {} bytes, over the {MAX_EXPORT_BYTES} byte limit; \
285 narrow the query or aggregate",
286 bytes.len()
287 )));
288 }
289 Ok(Outcome::Export {
290 bytes,
291 format,
292 rows,
293 columns,
294 })
295 }
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301enum StatementKind {
302 Query,
304 Explain,
306}
307
308struct ParsedStatement {
314 kind: StatementKind,
315 query: lance::deps::datafusion::sql::sqlparser::ast::Query,
316}
317
318impl ParsedStatement {
319 fn projection_query(&self) -> &lance::deps::datafusion::sql::sqlparser::ast::Query {
320 &self.query
321 }
322}
323
324fn parse_and_gate(sql: &str) -> Result<ParsedStatement, SqlError> {
331 let statements = DFParser::parse_sql(sql)
332 .map_err(|error| SqlError::Query(format!("SQL parse error: {error}")))?;
333 if statements.len() != 1 {
334 return Err(SqlError::Query(
335 "pond_sql_query runs exactly one statement; submit a single SELECT".to_owned(),
336 ));
337 }
338 let Some(front) = statements.front() else {
339 return Err(read_only_rejection());
340 };
341 match front {
342 DfStatement::Statement(boxed) => match boxed.as_ref() {
343 SqlStatement::Query(query) => Ok(ParsedStatement {
344 kind: StatementKind::Query,
345 query: query.as_ref().clone(),
346 }),
347 _ => Err(read_only_rejection()),
348 },
349 DfStatement::Explain(explain) => match explain.statement.as_ref() {
350 DfStatement::Statement(inner) => match inner.as_ref() {
351 SqlStatement::Query(query) => Ok(ParsedStatement {
352 kind: StatementKind::Explain,
353 query: query.as_ref().clone(),
354 }),
355 _ => Err(read_only_rejection()),
356 },
357 _ => Err(read_only_rejection()),
358 },
359 _ => Err(read_only_rejection()),
360 }
361}
362
363fn read_only_rejection() -> SqlError {
364 SqlError::Query(
367 "pond's SQL surface is read-only: only a single SELECT/WITH (or EXPLAIN of one) is \
368 allowed (no INSERT/UPDATE/DELETE/CREATE/DROP/COPY/SET)"
369 .to_owned(),
370 )
371}
372
373fn projection_mentions_vector(query: &lance::deps::datafusion::sql::sqlparser::ast::Query) -> bool {
384 walk_set_expr_for_vector(query.body.as_ref())
385}
386
387fn walk_set_expr_for_vector(expr: &SetExpr) -> bool {
388 match expr {
389 SetExpr::Select(select) => select
390 .projection
391 .iter()
392 .any(|item| mentions_vector_token(&item.to_string())),
393 SetExpr::Query(inner) => walk_set_expr_for_vector(inner.body.as_ref()),
394 SetExpr::SetOperation { left, right, .. } => {
395 walk_set_expr_for_vector(left) || walk_set_expr_for_vector(right)
396 }
397 _ => false,
398 }
399}
400
401fn mentions_vector_token(text: &str) -> bool {
402 text.split(|c: char| !c.is_alphanumeric() && c != '_')
403 .any(|token| token == "vector")
404}
405
406fn jsonb_cast_misuse(sql: &str) -> bool {
413 const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
414 let lowered = sql.to_ascii_lowercase();
415 let bytes = lowered.as_bytes();
416 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
417
418 for column in JSONB_COLUMNS {
420 let mut start = 0;
421 while let Some(pos) = lowered[start..].find(column) {
422 let begin = start + pos;
423 let end = begin + column.len();
424 start = end;
425 let bounded = (begin == 0 || !is_ident(bytes[begin - 1]))
426 && (end == bytes.len() || !is_ident(bytes[end]));
427 if bounded && lowered[end..].trim_start().starts_with("::") {
428 return true;
429 }
430 }
431 }
432
433 let mut start = 0;
435 while let Some(pos) = lowered[start..].find("cast") {
436 let begin = start + pos;
437 start = begin + 4;
438 if begin > 0 && is_ident(bytes[begin - 1]) {
439 continue;
440 }
441 let Some(open) = lowered[begin + 4..].trim_start().strip_prefix('(') else {
442 continue;
443 };
444 let mut operand = open.trim_start();
445 if let Some(dot) = operand.find('.')
446 && dot > 0
447 && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
448 {
449 operand = &operand[dot + 1..];
450 }
451 for column in JSONB_COLUMNS {
452 if let Some(after) = operand.strip_prefix(column)
453 && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
454 && after
455 .trim_start()
456 .strip_prefix("as")
457 .is_some_and(|rest| rest.starts_with(char::is_whitespace))
458 {
459 return true;
460 }
461 }
462 }
463 false
464}
465
466fn jsonb_fulldoc_like_scan(sql: &str) -> bool {
480 const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
481 const NEEDLE: &str = "json_extract";
482 let lowered = sql.to_ascii_lowercase();
483 let bytes = lowered.as_bytes();
484 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
485
486 let mut start = 0;
487 while let Some(pos) = lowered[start..].find(NEEDLE) {
488 let begin = start + pos;
489 start = begin + NEEDLE.len();
490 if begin > 0 && is_ident(bytes[begin - 1]) {
491 continue;
492 }
493 let Some(rest) = lowered[start..].trim_start().strip_prefix('(') else {
494 continue;
495 };
496 let mut operand = rest.trim_start();
497 if let Some(dot) = operand.find('.')
499 && dot > 0
500 && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
501 {
502 operand = &operand[dot + 1..];
503 }
504 let Some(col) = JSONB_COLUMNS.into_iter().find(|c| operand.starts_with(c)) else {
505 continue;
506 };
507 let tail = operand[col.len()..].trim_start();
510 let Some(tail) = tail
511 .strip_prefix(',')
512 .map(str::trim_start)
513 .and_then(|t| t.strip_prefix("'$'"))
514 .map(str::trim_start)
515 .and_then(|t| t.strip_prefix(')'))
516 else {
517 continue;
518 };
519 let mut tail = tail.trim_start();
521 while let Some(next) = tail.strip_prefix(')') {
522 tail = next.trim_start();
523 }
524 if let Some(next) = tail.strip_prefix("not")
525 && next.starts_with(char::is_whitespace)
526 {
527 tail = next.trim_start();
528 }
529 for op in ["like", "ilike"] {
530 if let Some(next) = tail.strip_prefix(op)
531 && next.starts_with(char::is_whitespace)
532 && next.trim_start().starts_with("'%")
533 {
534 return true;
535 }
536 }
537 }
538 false
539}
540
541fn build_context() -> Result<SessionContext, SqlError> {
542 let runtime = RuntimeEnvBuilder::new()
543 .with_memory_limit(MEM_LIMIT_BYTES, 1.0)
544 .build_arc()
545 .map_err(|error| SqlError::Infra(anyhow!("datafusion runtime init failed: {error}")))?;
546 let state = SessionStateBuilder::new()
549 .with_config(SessionConfig::new().with_information_schema(true))
550 .with_runtime_env(runtime)
551 .with_default_features()
552 .build();
553 Ok(SessionContext::new_with_state(state))
554}
555
556fn renamed_key(table: &str) -> Option<&'static str> {
561 match table {
562 "messages" => Some("message_id"),
563 "sessions" => Some("session_id"),
564 _ => None,
565 }
566}
567
568fn register(ctx: &SessionContext, tables: &Tables) -> Result<(), SqlError> {
569 for (name, dataset) in [
570 ("sessions", &tables.sessions),
571 ("messages", &tables.messages),
572 ] {
573 let Some(dataset) = dataset else { continue };
574 let provider = LanceTableProvider::new(dataset.clone(), false, false);
579 let key = renamed_key(name).unwrap_or("id");
580 let view = renamed_view(name, Arc::new(provider), "id", key)
581 .map_err(|error| SqlError::Infra(anyhow!("build {name} view: {error}")))?;
582 ctx.register_table(name, Arc::new(view))
583 .map_err(|error| SqlError::Infra(anyhow!("register table {name}: {error}")))?;
584 }
585 if let Some(parts) = &tables.parts {
590 let provider = LanceTableProvider::new(parts.clone(), false, false);
591 let keep: Vec<_> = parts
592 .schema()
593 .fields
594 .iter()
595 .filter(|field| field.name != "data")
596 .map(|field| col(field.name.as_str()))
597 .collect();
598 let plan = LogicalPlanBuilder::scan("parts", provider_as_source(Arc::new(provider)), None)
599 .and_then(|builder| builder.project(keep))
600 .and_then(LogicalPlanBuilder::build)
601 .map_err(|error| SqlError::Infra(anyhow!("build parts view: {error}")))?;
602 ctx.register_table("parts", Arc::new(ViewTable::new(plan, None)))
603 .map_err(|error| SqlError::Infra(anyhow!("register table parts: {error}")))?;
604 }
605 let datasets = [
610 ("sessions", &tables.sessions),
611 ("messages", &tables.messages),
612 ("parts", &tables.parts),
613 ]
614 .into_iter()
615 .filter_map(|(name, dataset)| dataset.clone().map(|d| (name.to_owned(), d)))
616 .collect();
617 let fts = ScoredFtsUdtf { datasets };
618 ctx.register_udtf("fts", Arc::new(fts));
619 register_functions(ctx);
620 for udf in lenient_json_udfs() {
624 ctx.register_udf(udf);
625 }
626 if let Some(first_value) = ctx.state().aggregate_functions().get("first_value") {
631 ctx.register_udaf(first_value.as_ref().clone().with_aliases(["any_value"]));
632 }
633 ctx.register_udf(ScalarUDF::new_from_impl(FtsMisuse::new()));
639 Ok(())
640}
641
642fn renamed_view(
646 scan_name: &str,
647 provider: Arc<dyn TableProvider>,
648 from: &str,
649 to: &str,
650) -> Result<ViewTable, DataFusionError> {
651 let projection: Vec<_> = provider
652 .schema()
653 .fields()
654 .iter()
655 .map(|field| {
656 let column = col(field.name().as_str());
657 if field.name() == from {
658 column.alias(to)
659 } else {
660 column
661 }
662 })
663 .collect();
664 let plan = LogicalPlanBuilder::scan(scan_name, provider_as_source(provider), None)?
665 .project(projection)?
666 .build()?;
667 Ok(ViewTable::new(plan, None))
668}
669
670const FTS_MISUSE: &str = "fts is a table function and goes in FROM, not in WHERE or the \
671 projection. For filtering use WHERE contains_tokens(search_text, 'word1 word2') (all \
672 words must match; index-accelerated). For ranked results: SELECT m.message_id, f._score \
673 FROM fts('messages', '{\"match\":{\"column\":\"search_text\",\"terms\":\"...\"}}') f \
674 JOIN messages m ON m.message_id = f.message_id ORDER BY f._score DESC.";
675
676#[derive(Debug, PartialEq, Eq, Hash)]
678struct FtsMisuse {
679 signature: Signature,
680}
681
682impl FtsMisuse {
683 fn new() -> Self {
684 Self {
685 signature: Signature::variadic_any(Volatility::Immutable),
686 }
687 }
688}
689
690impl ScalarUDFImpl for FtsMisuse {
691 fn as_any(&self) -> &dyn std::any::Any {
692 self
693 }
694
695 fn name(&self) -> &str {
696 "fts"
697 }
698
699 fn signature(&self) -> &Signature {
700 &self.signature
701 }
702
703 fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
704 Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
705 }
706
707 fn invoke_with_args(
708 &self,
709 _args: ScalarFunctionArgs,
710 ) -> Result<ColumnarValue, DataFusionError> {
711 Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
712 }
713}
714
715#[derive(Debug)]
727struct ScoredFtsUdtf {
728 datasets: HashMap<String, Arc<Dataset>>,
729}
730
731impl TableFunctionImpl for ScoredFtsUdtf {
732 fn call(
733 &self,
734 expr: &[Expr],
735 ) -> Result<Arc<dyn TableProvider>, lance::deps::datafusion::error::DataFusionError> {
736 let [table_expr, query_expr] = expr else {
737 return Err(DataFusionError::Execution(
738 "fts() takes (table_name, fts_query_json)".to_owned(),
739 ));
740 };
741 let Expr::Literal(ScalarValue::Utf8(Some(table_name)), _) = table_expr else {
742 return Err(DataFusionError::Execution(
743 "fts() first argument must be a table name string".to_owned(),
744 ));
745 };
746 let Expr::Literal(ScalarValue::Utf8(Some(fts_query)), _) = query_expr else {
747 return Err(DataFusionError::Execution(
748 "fts() second argument must be the fts query as a JSON string".to_owned(),
749 ));
750 };
751 let dataset = self.datasets.get(table_name).ok_or_else(|| {
752 DataFusionError::Execution(format!("fts(): table {table_name} not found"))
753 })?;
754 let mut full_schema = Schema::from(dataset.schema());
755 full_schema = full_schema
756 .try_with_column(Field::new(SCORE_COLUMN, DataType::Float32, true))
757 .map_err(|error| DataFusionError::ArrowError(Box::new(error), None))?;
758 let provider: Arc<dyn TableProvider> = Arc::new(ScoredFtsProvider {
759 dataset: dataset.clone(),
760 fts_query: FullTextSearchQuery::new_query(from_json(fts_query)?),
761 full_schema: Arc::new(full_schema),
762 });
763 match renamed_key(table_name) {
766 Some(key) => Ok(Arc::new(renamed_view("fts", provider, "id", key)?)),
767 None => Ok(provider),
768 }
769 }
770}
771
772const SCORE_COLUMN: &str = "_score";
773
774#[derive(Debug)]
775struct ScoredFtsProvider {
776 dataset: Arc<Dataset>,
777 fts_query: FullTextSearchQuery,
778 full_schema: SchemaRef,
779}
780
781#[async_trait::async_trait]
782impl TableProvider for ScoredFtsProvider {
783 fn as_any(&self) -> &dyn std::any::Any {
784 self
785 }
786
787 fn schema(&self) -> SchemaRef {
788 self.full_schema.clone()
789 }
790
791 fn table_type(&self) -> TableType {
792 TableType::Temporary
793 }
794
795 async fn scan(
796 &self,
797 _state: &dyn Session,
798 projection: Option<&Vec<usize>>,
799 filters: &[Expr],
800 limit: Option<usize>,
801 ) -> Result<Arc<dyn ExecutionPlan>, lance::deps::datafusion::error::DataFusionError> {
802 let mut scan = self.dataset.scan();
803 scan.full_text_search(self.fts_query.clone())?;
804 scan.disable_scoring_autoprojection();
808 match projection {
809 Some(projection) if projection.is_empty() => {
810 scan.empty_project()?;
811 }
812 Some(projection) => {
813 let columns: Vec<&str> = projection
814 .iter()
815 .map(|idx| self.full_schema.field(*idx).name().as_str())
816 .collect();
817 scan.project(&columns)?;
818 }
819 None => {
820 let columns: Vec<&str> = self
821 .full_schema
822 .fields()
823 .iter()
824 .map(|field| field.name().as_str())
825 .collect();
826 scan.project(&columns)?;
827 }
828 }
829 if let Some(combined) = filters
830 .iter()
831 .cloned()
832 .reduce(|left, right| left.and(right))
833 {
834 scan.filter_expr(combined);
835 }
836 scan.limit(limit.map(|l| l as i64), None)?;
837 scan.create_plan().await.map_err(DataFusionError::from)
838 }
839}
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
843enum JsonGet {
844 Text,
845 Int,
846 Float,
847 Bool,
848}
849
850const MAX_JSON_KEYS: usize = 6;
853
854fn lenient_json_udfs() -> [ScalarUDF; 4] {
864 let make = |name: &'static str, kind: JsonGet, return_type: DataType| {
865 ScalarUDF::new_from_impl(LenientJsonGet {
866 name,
867 kind,
868 return_type,
869 signature: json_key_path_signature(),
870 })
871 };
872 [
873 make("json_get_string", JsonGet::Text, DataType::Utf8),
874 make("json_get_int", JsonGet::Int, DataType::Int64),
875 make("json_get_float", JsonGet::Float, DataType::Float64),
876 make("json_get_bool", JsonGet::Bool, DataType::Boolean),
877 ]
878}
879
880fn json_key_path_signature() -> Signature {
882 let arities = (1..=MAX_JSON_KEYS)
883 .map(|keys| {
884 let mut types = vec![DataType::LargeBinary];
885 types.extend(std::iter::repeat_n(DataType::Utf8, keys));
886 TypeSignature::Exact(types)
887 })
888 .collect();
889 Signature::one_of(arities, Volatility::Immutable)
890}
891
892#[derive(Debug, PartialEq, Eq, Hash)]
894struct LenientJsonGet {
895 name: &'static str,
896 kind: JsonGet,
897 return_type: DataType,
898 signature: Signature,
899}
900
901impl ScalarUDFImpl for LenientJsonGet {
902 fn as_any(&self) -> &dyn std::any::Any {
903 self
904 }
905
906 fn name(&self) -> &str {
907 self.name
908 }
909
910 fn signature(&self) -> &Signature {
911 &self.signature
912 }
913
914 fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
915 Ok(self.return_type.clone())
916 }
917
918 fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue, DataFusionError> {
919 json_get_lenient(&args.args, &self.kind)
920 }
921}
922
923fn json_step(raw: jsonb::RawJsonb<'_>, key: &str) -> Option<jsonb::OwnedJsonb> {
925 let value = if raw.is_object().unwrap_or(false) {
926 raw.get_by_name(key, false).ok().flatten()
927 } else if raw.is_array().unwrap_or(false) {
928 key.parse::<usize>()
929 .ok()
930 .and_then(|index| raw.get_by_index(index).ok().flatten())
931 } else {
932 None
933 };
934 value.filter(|value| !value.as_raw().is_null().unwrap_or(false))
935}
936
937fn json_get_lenient(
938 args: &[ColumnarValue],
939 kind: &JsonGet,
940) -> Result<ColumnarValue, DataFusionError> {
941 let arrays = ColumnarValue::values_to_arrays(args)?;
942 let Some((jsonb_arg, key_args)) = arrays.split_first().filter(|(_, keys)| !keys.is_empty())
943 else {
944 return Err(DataFusionError::Execution(
945 "json_get_* takes (json_column, 'key', ...) - at least one key".to_owned(),
946 ));
947 };
948 let jsonb_array = jsonb_arg
949 .as_any()
950 .downcast_ref::<LargeBinaryArray>()
951 .ok_or_else(|| {
952 DataFusionError::Execution(
953 "json_get_* argument 1 must be a JSON column (variant_data, options)".to_owned(),
954 )
955 })?;
956 let key_arrays: Vec<&StringArray> = key_args
957 .iter()
958 .map(|key_arg| {
959 key_arg
960 .as_any()
961 .downcast_ref::<StringArray>()
962 .ok_or_else(|| {
963 DataFusionError::Execution("json_get_* keys must be string literals".to_owned())
964 })
965 })
966 .collect::<Result<_, _>>()?;
967
968 let field = |row: usize| -> Option<jsonb::OwnedJsonb> {
969 if jsonb_array.is_null(row) {
970 return None;
971 }
972 let mut keys = key_arrays.iter();
973 let first = keys.next()?;
974 if first.is_null(row) {
975 return None;
976 }
977 let mut current = json_step(
978 jsonb::RawJsonb::new(jsonb_array.value(row)),
979 first.value(row),
980 )?;
981 for key_array in keys {
982 if key_array.is_null(row) {
983 return None;
984 }
985 current = json_step(current.as_raw(), key_array.value(row))?;
986 }
987 Some(current)
988 };
989
990 let rows = jsonb_array.len();
991 let array: Arc<dyn Array> = match kind {
992 JsonGet::Text => {
993 let mut builder = StringBuilder::with_capacity(rows, 1024);
994 for row in 0..rows {
995 match field(row) {
996 Some(value) => match value.as_raw().to_str() {
999 Ok(text) => builder.append_value(text),
1000 Err(_) => builder.append_value(value.to_string()),
1001 },
1002 None => builder.append_null(),
1003 }
1004 }
1005 Arc::new(builder.finish())
1006 }
1007 JsonGet::Int => {
1008 let mut builder = Int64Builder::with_capacity(rows);
1009 for row in 0..rows {
1010 builder.append_option(field(row).and_then(|value| value.as_raw().to_i64().ok()));
1011 }
1012 Arc::new(builder.finish())
1013 }
1014 JsonGet::Float => {
1015 let mut builder = Float64Builder::with_capacity(rows);
1016 for row in 0..rows {
1017 builder.append_option(field(row).and_then(|value| value.as_raw().to_f64().ok()));
1018 }
1019 Arc::new(builder.finish())
1020 }
1021 JsonGet::Bool => {
1022 let mut builder = BooleanBuilder::with_capacity(rows);
1023 for row in 0..rows {
1024 builder.append_option(field(row).and_then(|value| value.as_raw().to_bool().ok()));
1025 }
1026 Arc::new(builder.finish())
1027 }
1028 };
1029 Ok(ColumnarValue::Array(array))
1030}
1031
1032fn enrich(message: &str) -> String {
1036 const HINTS: &[(&str, &str)] = &[
1037 (
1038 "No field named",
1039 "columns are messages(session_id, message_id, timestamp, role, source_agent, \
1040 project, content [system-role only], search_text [the conversational text], \
1041 embedding_model, options) | sessions(session_id, parent_session_id, \
1042 parent_message_id, source_agent, created_at, project, options) | \
1043 parts(session_id, message_id, id, ordinal, type, provenance, tool_name, \
1044 call_id, is_failure, variant_data, options). Part bodies (tool params/results, \
1045 text) live in parts.variant_data - \
1046 read them with json_extract(variant_data, '$.field'). For text search use \
1047 contains_tokens(search_text, '...') in WHERE, or the fts('messages', ...) \
1048 table function in FROM for ranked results; to read a transcript use pond_get. \
1049 Full doc: resource schema://pond-sql.",
1050 ),
1051 (
1052 "Encountered non UTF-8 data",
1053 "JSON columns (variant_data, options) are binary JSONB - CAST / ::text does not \
1054 work on them. Stringify the whole value with json_extract(col, '$'), or fetch \
1055 one field with json_extract(col, '$.field').",
1056 ),
1057 (
1058 "Resources exhausted",
1059 "the query ran out of memory - usually from carrying whole JSON columns \
1060 (variant_data, options) through a join or sort. Project narrow fields with \
1061 json_extract(col, '$.field') instead of whole columns, filter before joining, \
1062 or export the full set with format=parquet.",
1063 ),
1064 (
1065 "LIKE prefix queries are not supported for bitmap indexes",
1066 "prefix LIKE ('x%') and starts_with() fail on bitmap-indexed columns \
1067 (messages.source_agent). Use equality, \
1068 split_part(source_agent, '/', 1) = '...', or an infix pattern (LIKE '%x%').",
1069 ),
1070 (
1071 "call to 'json_",
1072 "JSON function signatures: json_get_string|json_get_int|json_get_float|\
1073 json_get_bool(col, 'key', ...) walk a key path (array steps by numeric \
1074 index); json_get(col, 'key') returns JSONB for chaining; json_extract(col, \
1075 '$.a.b') takes a JSONPath and returns JSON text of any value (the right tool \
1076 for deeply nested or mixed-type fields).",
1077 ),
1078 (
1079 "Invalid function 'json",
1080 "available JSON functions: json_get_string, json_get_int, json_get_float, \
1081 json_get_bool (col, 'key', ...); json_get(col, 'key') -> JSONB for chaining; \
1082 json_extract(col, '$.a.b') -> JSON text; json_array_contains; \
1083 json_array_length. See resource schema://pond-sql.",
1084 ),
1085 (
1086 "does not satisfy distribution requirements",
1091 "this fts query shape planned an unexecutable join. For AND semantics use a \
1092 single match query with operator And: fts('messages', \
1093 '{\"match\":{\"column\":\"search_text\",\"terms\":\"a b\",\"operator\":\"And\"}}'), \
1094 optionally with LIKE post-filters in WHERE.",
1095 ),
1096 (
1097 "position is not found but required for phrase queries",
1098 "the full-text index is built without positions, so \"phrase\" queries are \
1099 unavailable. Use a match query with operator And plus LIKE post-filters for \
1100 exact-substring matching.",
1101 ),
1102 ];
1103 for (pattern, hint) in HINTS {
1104 if message.contains(pattern) {
1105 return format!("{message}\nhint: {hint}");
1106 }
1107 }
1108 message.to_owned()
1109}
1110
1111fn displayable(batch: &RecordBatch) -> Result<RecordBatch, ArrowError> {
1114 let decoded = lance_arrow::json::convert_lance_json_to_arrow(batch)?;
1115 let keep: Vec<usize> = decoded
1116 .schema()
1117 .fields()
1118 .iter()
1119 .enumerate()
1120 .filter(|(_, field)| is_displayable(field.data_type()))
1121 .map(|(index, _)| index)
1122 .collect();
1123 decoded.project(&keep)
1124}
1125
1126fn is_displayable(data_type: &DataType) -> bool {
1127 !matches!(
1128 data_type,
1129 DataType::FixedSizeList(_, _)
1130 | DataType::Binary
1131 | DataType::LargeBinary
1132 | DataType::BinaryView
1133 | DataType::FixedSizeBinary(_)
1134 )
1135}
1136
1137fn collapse_newlines(batches: &[RecordBatch]) -> Result<Vec<RecordBatch>, ArrowError> {
1143 fn escape<O: OffsetSizeTrait>(array: &GenericStringArray<O>) -> ArrayRef {
1144 let escaped: GenericStringArray<O> =
1145 array.iter().map(|value| value.map(escape_cell)).collect();
1146 Arc::new(escaped)
1147 }
1148 fn escape_cell(text: &str) -> std::borrow::Cow<'_, str> {
1149 if text.contains(['\n', '\r']) {
1150 std::borrow::Cow::Owned(text.replace("\r\n", "\\n").replace(['\n', '\r'], "\\n"))
1151 } else {
1152 std::borrow::Cow::Borrowed(text)
1153 }
1154 }
1155 batches
1156 .iter()
1157 .map(|batch| {
1158 let columns: Vec<ArrayRef> = batch
1159 .columns()
1160 .iter()
1161 .map(|array| match array.data_type() {
1162 DataType::Utf8 => array
1163 .as_any()
1164 .downcast_ref::<StringArray>()
1165 .map_or_else(|| array.clone(), escape),
1166 DataType::LargeUtf8 => array
1167 .as_any()
1168 .downcast_ref::<GenericStringArray<i64>>()
1169 .map_or_else(|| array.clone(), escape),
1170 DataType::Utf8View => array
1171 .as_any()
1172 .downcast_ref::<StringViewArray>()
1173 .map_or_else(
1174 || array.clone(),
1175 |view| {
1176 let escaped: StringViewArray =
1177 view.iter().map(|value| value.map(escape_cell)).collect();
1178 Arc::new(escaped)
1179 },
1180 ),
1181 _ => array.clone(),
1182 })
1183 .collect();
1184 RecordBatch::try_new(batch.schema(), columns)
1185 })
1186 .collect()
1187}
1188
1189fn render_inline(
1190 display: &[RecordBatch],
1191 max_rows: usize,
1192 elapsed: Duration,
1193) -> Result<String, ArrowError> {
1194 let total: usize = display.iter().map(RecordBatch::num_rows).sum();
1195 let elapsed_ms = elapsed.as_millis();
1196 if total == 0 {
1197 return Ok(format!(
1199 "0 rows ({elapsed_ms} ms).\n{}",
1200 pretty_format_batches(display)?
1201 ));
1202 }
1203 let render = |shown: usize| -> Result<String, ArrowError> {
1204 let limited = collapse_newlines(&limit_batches(display, shown))?;
1205 Ok(pretty_format_batches(&limited)?.to_string())
1206 };
1207 let mut shown = total.min(max_rows);
1208 let mut table = render(shown)?;
1209 while table.len() > INLINE_BUDGET_BYTES && shown > 1 {
1210 shown = (shown / 2).max(1);
1211 table = render(shown)?;
1212 }
1213 let mut out = format!("{total} row(s) in {elapsed_ms} ms; showing {shown}.\n{table}");
1214 if shown < total {
1215 out.push_str(&format!(
1216 "\n... {} row(s) omitted. To page: ORDER BY <indexed col> (e.g. timestamp, \
1217 message_id), then in the next call add `WHERE (col, message_id) < \
1218 (<last_col>, <last_message_id>)` - keyset pagination, see schema://pond-sql. \
1219 For the full set: format=parquet or format=ndjson.",
1220 total - shown
1221 ));
1222 }
1223 Ok(out)
1224}
1225
1226fn limit_batches(batches: &[RecordBatch], max_rows: usize) -> Vec<RecordBatch> {
1227 let mut out = Vec::new();
1228 let mut remaining = max_rows;
1229 for batch in batches {
1230 if remaining == 0 {
1231 break;
1232 }
1233 if batch.num_rows() <= remaining {
1234 remaining -= batch.num_rows();
1235 out.push(batch.clone());
1236 } else {
1237 out.push(batch.slice(0, remaining));
1238 remaining = 0;
1239 }
1240 }
1241 out
1242}
1243
1244fn encode_parquet(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1245 let schema = batches
1246 .first()
1247 .map(RecordBatch::schema)
1248 .ok_or_else(|| SqlError::Query("query returned no columns to export".to_owned()))?;
1249 let mut buffer = Vec::new();
1250 let mut writer = ArrowWriter::try_new(&mut buffer, schema, None)
1251 .map_err(|error| SqlError::Infra(anyhow!("parquet init failed: {error}")))?;
1252 for batch in batches {
1253 writer
1254 .write(batch)
1255 .map_err(|error| SqlError::Infra(anyhow!("parquet write failed: {error}")))?;
1256 }
1257 writer
1258 .close()
1259 .map_err(|error| SqlError::Infra(anyhow!("parquet close failed: {error}")))?;
1260 Ok(buffer)
1261}
1262
1263fn encode_ndjson(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1264 let mut buffer = Vec::new();
1265 {
1266 let mut writer = LineDelimitedWriter::new(&mut buffer);
1267 let refs: Vec<&RecordBatch> = batches.iter().collect();
1268 writer
1269 .write_batches(&refs)
1270 .map_err(|error| SqlError::Infra(anyhow!("ndjson write failed: {error}")))?;
1271 writer
1272 .finish()
1273 .map_err(|error| SqlError::Infra(anyhow!("ndjson finish failed: {error}")))?;
1274 }
1275 Ok(buffer)
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 #![allow(clippy::expect_used)]
1281
1282 use super::*;
1283
1284 fn rejected(sql: &str) -> bool {
1285 matches!(parse_and_gate(sql), Err(SqlError::Query(_)))
1286 }
1287
1288 fn parses_as(sql: &str, expected: StatementKind) -> bool {
1289 match parse_and_gate(sql) {
1290 Ok(parsed) => matches!(
1291 (&parsed.kind, &expected),
1292 (StatementKind::Query, StatementKind::Query)
1293 | (StatementKind::Explain, StatementKind::Explain)
1294 ),
1295 Err(_) => false,
1296 }
1297 }
1298
1299 #[test]
1300 fn mentions_table_is_sound_for_open_pruning() {
1301 assert!(mentions_table("SELECT * FROM messages", "messages"));
1304 assert!(mentions_table("select * from MESSAGES", "messages"));
1305 assert!(mentions_table(
1306 "SELECT s.id FROM sessions s JOIN parts p ON s.id = p.session_id",
1307 "parts",
1308 ));
1309 assert!(mentions_table(
1310 "SELECT * FROM fts('messages', '{\"match\":{}}')",
1311 "messages",
1312 ));
1313 assert!(mentions_table(
1314 "WITH x AS (SELECT * FROM sessions) SELECT * FROM x",
1315 "sessions",
1316 ));
1317 assert!(!mentions_table("SELECT * FROM messages", "parts"));
1320 assert!(!mentions_table("SELECT * FROM messages", "sessions"));
1321 assert!(!mentions_table(
1322 "SELECT counterparts FROM messages",
1323 "parts"
1324 ));
1325 }
1326
1327 #[test]
1328 fn allows_single_select_and_cte() {
1329 assert!(parses_as("SELECT 1", StatementKind::Query));
1330 assert!(parses_as(
1331 "SELECT role, count(*) FROM messages GROUP BY role",
1332 StatementKind::Query
1333 ));
1334 assert!(parses_as(
1335 "WITH t AS (SELECT 1 AS a) SELECT a FROM t",
1336 StatementKind::Query
1337 ));
1338 }
1339
1340 #[test]
1341 fn allows_explain_of_select() {
1342 assert!(parses_as("EXPLAIN SELECT 1", StatementKind::Explain));
1343 assert!(parses_as(
1344 "EXPLAIN ANALYZE SELECT role FROM messages",
1345 StatementKind::Explain
1346 ));
1347 }
1348
1349 #[test]
1350 fn rejects_explain_of_non_query() {
1351 assert!(rejected("EXPLAIN INSERT INTO messages VALUES ('x')"));
1354 }
1355
1356 #[test]
1357 fn rejects_writes_and_side_effects() {
1358 assert!(rejected("INSERT INTO messages VALUES ('x')"));
1359 assert!(rejected("UPDATE messages SET role = 'x'"));
1360 assert!(rejected("DELETE FROM messages"));
1361 assert!(rejected("CREATE TABLE t (x INT)"));
1362 assert!(rejected("CREATE VIEW v AS SELECT 1"));
1363 assert!(rejected("DROP TABLE messages"));
1364 assert!(rejected(
1365 "CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION '/etc'"
1366 ));
1367 assert!(rejected("COPY (SELECT 1) TO '/tmp/x.parquet'"));
1368 assert!(rejected("SET a = 1"));
1369 }
1370
1371 #[test]
1372 fn rejects_multiple_statements() {
1373 assert!(rejected("SELECT 1; SELECT 2"));
1374 assert!(rejected("SELECT 1; DROP TABLE messages"));
1375 }
1376
1377 #[test]
1378 fn rejects_unparseable() {
1379 assert!(rejected("NOT SQL AT ALL ;;"));
1380 }
1381
1382 fn mentions_vector(sql: &str) -> bool {
1383 match parse_and_gate(sql) {
1384 Ok(parsed) => projection_mentions_vector(parsed.projection_query()),
1385 Err(_) => false,
1386 }
1387 }
1388
1389 #[test]
1390 fn explicit_vector_projection_is_rejected() {
1391 assert!(mentions_vector("SELECT vector FROM messages"));
1392 assert!(mentions_vector("SELECT id, vector FROM messages"));
1393 assert!(mentions_vector("SELECT m.vector FROM messages m"));
1394 assert!(mentions_vector("SELECT array_length(vector) FROM messages"));
1395 assert!(mentions_vector("EXPLAIN SELECT vector FROM messages"));
1396 }
1397
1398 #[test]
1399 fn enrich_appends_recovery_hints() {
1400 let cases = [
1402 (
1403 "SQL error: Schema error: No field named created_at.",
1404 "schema://pond-sql",
1405 ),
1406 (
1407 "SQL error: External error: Arrow error: Invalid argument error: \
1408 Encountered non UTF-8 data",
1409 "json_extract",
1410 ),
1411 (
1412 "SQL error: External error: Not supported: LIKE prefix queries are not \
1413 supported for bitmap indexes",
1414 "split_part",
1415 ),
1416 (
1417 "SQL error: Error during planning: Failed to coerce arguments to satisfy \
1418 a call to 'json_get_string' function",
1419 "JSONPath",
1420 ),
1421 (
1422 "SQL error: Error during planning: Invalid function 'json_get_json'.",
1423 "json_extract",
1424 ),
1425 (
1426 "SQL error: Resources exhausted: Additional allocation failed for \
1427 HashJoinInput[0] with top memory consumers",
1428 "json_extract",
1429 ),
1430 ];
1431 for (raw, marker) in cases {
1432 let enriched = enrich(raw);
1433 assert!(enriched.starts_with(raw), "original kept: {enriched}");
1434 assert!(enriched.contains("hint:"), "hint appended: {enriched}");
1435 assert!(enriched.contains(marker), "hint names the fix: {enriched}");
1436 }
1437 assert_eq!(
1439 enrich("SQL error: division by zero"),
1440 "SQL error: division by zero"
1441 );
1442 }
1443
1444 #[test]
1445 fn select_star_and_where_vector_are_allowed() {
1446 assert!(!mentions_vector("SELECT * FROM messages"));
1448 assert!(!mentions_vector(
1450 "SELECT message_id FROM messages WHERE vector IS NOT NULL"
1451 ));
1452 }
1453
1454 #[test]
1455 fn jsonb_cast_misuse_detects_cast_and_coloncolon() {
1456 for sql in [
1457 "SELECT CAST(variant_data AS VARCHAR) FROM parts",
1458 "SELECT cast(p.variant_data as text) FROM parts p",
1459 "SELECT variant_data::text FROM parts",
1460 "SELECT p.variant_data :: varchar FROM parts p",
1461 "SELECT options::text FROM messages",
1462 "SELECT lower(CAST(variant_data AS VARCHAR)) FROM parts",
1463 ] {
1464 assert!(jsonb_cast_misuse(sql), "should reject: {sql}");
1465 }
1466 }
1467
1468 #[test]
1469 fn jsonb_cast_misuse_allows_legitimate_use() {
1470 for sql in [
1471 "SELECT json_extract(variant_data, '$') FROM parts",
1472 "SELECT json_get_string(variant_data, 'name') FROM parts",
1473 "SELECT CAST(ordinal AS BIGINT) FROM parts",
1474 "SELECT timestamp::date FROM messages",
1475 "SELECT my_options::text FROM t",
1477 "SELECT CAST(json_extract(variant_data, '$.x') AS BIGINT) FROM parts",
1478 ] {
1479 assert!(!jsonb_cast_misuse(sql), "should allow: {sql}");
1480 }
1481 }
1482
1483 #[test]
1484 fn jsonb_fulldoc_like_scan_detects_whole_document_substring() {
1485 for sql in [
1486 "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE '%needle%'",
1487 "SELECT * FROM parts p WHERE lower(json_extract(p.variant_data, '$')) LIKE '%x%'",
1488 "SELECT * FROM messages WHERE json_extract(options, '$') ILIKE '%y%'",
1489 "SELECT * FROM parts WHERE json_extract(variant_data,'$') NOT LIKE '%z%'",
1490 "SELECT p.message_id FROM parts p JOIN messages m ON p.message_id = m.message_id \
1492 WHERE m.timestamp >= '2026-06-11' AND lower(json_extract(p.variant_data, '$')) \
1493 LIKE '%weekly limit%'",
1494 ] {
1495 assert!(jsonb_fulldoc_like_scan(sql), "should reject: {sql}");
1496 }
1497 }
1498
1499 #[test]
1500 fn jsonb_fulldoc_like_scan_allows_targeted_and_nonleading() {
1501 for sql in [
1502 "SELECT * FROM parts WHERE json_extract(variant_data, '$.name') LIKE '%x%'",
1504 "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE 'pre%'",
1506 "SELECT * FROM messages WHERE search_text LIKE '%x%'",
1508 "SELECT * FROM messages WHERE contains_tokens(search_text, 'x')",
1510 "SELECT json_extract(variant_data, '$') FROM parts LIMIT 1",
1512 ] {
1513 assert!(!jsonb_fulldoc_like_scan(sql), "should allow: {sql}");
1514 }
1515 }
1516
1517 #[test]
1518 fn render_inline_collapses_newlines_in_cells() {
1519 let schema = Arc::new(Schema::new(vec![Field::new("t", DataType::Utf8, true)]));
1520 let batch = RecordBatch::try_new(
1521 schema,
1522 vec![Arc::new(StringArray::from(vec![Some(
1523 "line one\nline two\r\nline three",
1524 )]))],
1525 )
1526 .expect("single-column batch");
1527 let out = render_inline(&[batch], 10, Duration::from_millis(1)).expect("render succeeds");
1528 assert!(
1529 out.contains("line one\\nline two\\nline three"),
1530 "newlines collapse to literal \\n: {out}"
1531 );
1532 let row_lines: Vec<&str> = out
1535 .lines()
1536 .filter(|line| line.contains("line one"))
1537 .collect();
1538 assert_eq!(row_lines.len(), 1, "one physical line per row: {out}");
1539 }
1540
1541 #[test]
1542 fn effective_timeout_defaults_and_clamps() {
1543 assert_eq!(
1544 effective_timeout(None),
1545 Duration::from_secs(DEFAULT_QUERY_TIMEOUT_SECS)
1546 );
1547 assert_eq!(effective_timeout(Some(60)), Duration::from_secs(60));
1548 assert_eq!(effective_timeout(Some(0)), Duration::from_secs(1));
1549 assert_eq!(
1550 effective_timeout(Some(u64::MAX)),
1551 Duration::from_secs(MAX_QUERY_TIMEOUT_SECS)
1552 );
1553 }
1554}