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;
52const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
55const INLINE_BUDGET_BYTES: usize = 80_000;
57const MAX_EXPORT_BYTES: usize = 100 * 1024 * 1024;
60pub const DEFAULT_INLINE_ROWS: usize = 100;
62pub const MAX_INLINE_ROWS: usize = 1_000;
64
65#[derive(Debug, Clone, Copy)]
68pub enum Format {
69 Parquet,
70 Ndjson,
71}
72
73impl Format {
74 pub fn ext(self) -> &'static str {
75 match self {
76 Self::Parquet => "parquet",
77 Self::Ndjson => "ndjson",
78 }
79 }
80
81 pub fn mime(self) -> &'static str {
82 match self {
83 Self::Parquet => "application/vnd.apache.parquet",
84 Self::Ndjson => "application/x-ndjson",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy)]
91pub enum Mode {
92 Inline,
94 Export(Format),
96}
97
98pub struct Tables {
104 pub sessions: Option<Arc<Dataset>>,
105 pub messages: Option<Arc<Dataset>>,
106 pub parts: Option<Arc<Dataset>>,
107}
108
109pub fn mentions_table(sql: &str, table: &str) -> bool {
116 sql.to_ascii_lowercase()
117 .split(|c: char| !c.is_alphanumeric() && c != '_')
118 .any(|token| token == table)
119}
120
121pub enum Outcome {
123 Inline(String),
125 Export {
127 bytes: Vec<u8>,
128 format: Format,
129 rows: usize,
130 columns: Vec<String>,
131 },
132}
133
134#[derive(Debug)]
138pub enum SqlError {
139 Query(String),
140 Infra(anyhow::Error),
141}
142
143fn infra(error: ArrowError) -> SqlError {
144 SqlError::Infra(anyhow::Error::new(error))
145}
146
147pub async fn run(
150 tables: &Tables,
151 sql: &str,
152 mode: Mode,
153 inline_rows: usize,
154) -> Result<Outcome, SqlError> {
155 let parsed = parse_and_gate(sql)?;
156 if matches!(parsed.kind, StatementKind::Explain) && matches!(mode, Mode::Export(_)) {
157 return Err(SqlError::Query(
158 "EXPLAIN returns a plan, not a result set; use format=text (or json) to read it"
159 .to_owned(),
160 ));
161 }
162 if projection_mentions_vector(parsed.projection_query()) {
163 return Err(SqlError::Query(
164 "the `vector` column is not selectable from pond_sql_query (it is a \
165 FixedSizeList<f32> embedding, ~600 bytes per row and not useful in a result). \
166 For semantic search use pond_search. Filtering on it is allowed in WHERE \
167 (e.g. `vector IS NOT NULL`)."
168 .to_owned(),
169 ));
170 }
171 if jsonb_cast_misuse(sql) {
172 return Err(SqlError::Query(
173 "CAST / `::` does not work on the binary JSONB columns (variant_data, options) - \
174 when the bytes happen to be valid text it can even silently return garbage. \
175 Stringify the whole value with json_extract(col, '$') or read one field with \
176 json_extract(col, '$.field')."
177 .to_owned(),
178 ));
179 }
180 if jsonb_fulldoc_like_scan(sql) {
181 return Err(SqlError::Query(
182 "a leading-wildcard LIKE over the whole JSONB document - \
183 json_extract(variant_data, '$') LIKE '%...%' - stringifies and scans every row, \
184 so over parts it will not finish within the time limit. There is no substring \
185 index on tool bodies yet (TODO #47: lance v8 FM-Index). Instead match a single \
186 field with json_extract(variant_data, '$.field') LIKE '...', scope to one session \
187 with session_id = '<id>' and read it with pond_get, or search conversational text \
188 with contains_tokens(search_text, '...')."
189 .to_owned(),
190 ));
191 }
192 let ctx = build_context()?;
193 register(&ctx, tables)?;
194
195 let options = SQLOptions::new()
201 .with_allow_ddl(false)
202 .with_allow_dml(false)
203 .with_allow_statements(matches!(parsed.kind, StatementKind::Explain));
204 let df = ctx
205 .sql_with_options(sql, options)
206 .await
207 .map_err(|error| SqlError::Query(enrich(&format!("SQL error: {error}"))))?;
208
209 let result_schema = Arc::new(df.schema().as_arrow().clone());
212 let started = Instant::now();
213 let collected = tokio::time::timeout(QUERY_TIMEOUT, df.collect())
219 .await
220 .map_err(|_| {
221 SqlError::Query(format!(
222 "query exceeded the {}s limit; add a narrower WHERE or a LIMIT. If you were \
223 substring-scanning variant_data (json_extract + LIKE), there is no \
224 substring index on tool bodies yet: filter parts by type and \
225 json_get_string(variant_data, 'name') first, or search conversational \
226 text with contains_tokens(search_text, '...') instead.",
227 QUERY_TIMEOUT.as_secs()
228 ))
229 })?
230 .map_err(|error| SqlError::Query(enrich(&format!("SQL error: {error}"))))?;
231 let elapsed = started.elapsed();
232
233 let display: Vec<RecordBatch> = if collected.is_empty() {
234 vec![displayable(&RecordBatch::new_empty(result_schema)).map_err(infra)?]
235 } else {
236 collected
237 .into_iter()
238 .map(|batch| displayable(&batch))
239 .collect::<Result<_, _>>()
240 .map_err(infra)?
241 };
242
243 match mode {
244 Mode::Inline => Ok(Outcome::Inline(
245 render_inline(&display, inline_rows, elapsed).map_err(infra)?,
246 )),
247 Mode::Export(format) => {
248 let rows = display.iter().map(RecordBatch::num_rows).sum();
249 let columns = display
250 .first()
251 .map(|batch| {
252 batch
253 .schema()
254 .fields()
255 .iter()
256 .map(|field| field.name().clone())
257 .collect::<Vec<_>>()
258 })
259 .unwrap_or_default();
260 let bytes = match format {
261 Format::Parquet => encode_parquet(&display)?,
262 Format::Ndjson => encode_ndjson(&display)?,
263 };
264 if bytes.len() > MAX_EXPORT_BYTES {
265 return Err(SqlError::Query(format!(
266 "export is {} bytes, over the {MAX_EXPORT_BYTES} byte limit; \
267 narrow the query or aggregate",
268 bytes.len()
269 )));
270 }
271 Ok(Outcome::Export {
272 bytes,
273 format,
274 rows,
275 columns,
276 })
277 }
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum StatementKind {
284 Query,
286 Explain,
288}
289
290struct ParsedStatement {
296 kind: StatementKind,
297 query: lance::deps::datafusion::sql::sqlparser::ast::Query,
298}
299
300impl ParsedStatement {
301 fn projection_query(&self) -> &lance::deps::datafusion::sql::sqlparser::ast::Query {
302 &self.query
303 }
304}
305
306fn parse_and_gate(sql: &str) -> Result<ParsedStatement, SqlError> {
313 let statements = DFParser::parse_sql(sql)
314 .map_err(|error| SqlError::Query(format!("SQL parse error: {error}")))?;
315 if statements.len() != 1 {
316 return Err(SqlError::Query(
317 "pond_sql_query runs exactly one statement; submit a single SELECT".to_owned(),
318 ));
319 }
320 let Some(front) = statements.front() else {
321 return Err(read_only_rejection());
322 };
323 match front {
324 DfStatement::Statement(boxed) => match boxed.as_ref() {
325 SqlStatement::Query(query) => Ok(ParsedStatement {
326 kind: StatementKind::Query,
327 query: query.as_ref().clone(),
328 }),
329 _ => Err(read_only_rejection()),
330 },
331 DfStatement::Explain(explain) => match explain.statement.as_ref() {
332 DfStatement::Statement(inner) => match inner.as_ref() {
333 SqlStatement::Query(query) => Ok(ParsedStatement {
334 kind: StatementKind::Explain,
335 query: query.as_ref().clone(),
336 }),
337 _ => Err(read_only_rejection()),
338 },
339 _ => Err(read_only_rejection()),
340 },
341 _ => Err(read_only_rejection()),
342 }
343}
344
345fn read_only_rejection() -> SqlError {
346 SqlError::Query(
349 "pond's SQL surface is read-only: only a single SELECT/WITH (or EXPLAIN of one) is \
350 allowed (no INSERT/UPDATE/DELETE/CREATE/DROP/COPY/SET)"
351 .to_owned(),
352 )
353}
354
355fn projection_mentions_vector(query: &lance::deps::datafusion::sql::sqlparser::ast::Query) -> bool {
366 walk_set_expr_for_vector(query.body.as_ref())
367}
368
369fn walk_set_expr_for_vector(expr: &SetExpr) -> bool {
370 match expr {
371 SetExpr::Select(select) => select
372 .projection
373 .iter()
374 .any(|item| mentions_vector_token(&item.to_string())),
375 SetExpr::Query(inner) => walk_set_expr_for_vector(inner.body.as_ref()),
376 SetExpr::SetOperation { left, right, .. } => {
377 walk_set_expr_for_vector(left) || walk_set_expr_for_vector(right)
378 }
379 _ => false,
380 }
381}
382
383fn mentions_vector_token(text: &str) -> bool {
384 text.split(|c: char| !c.is_alphanumeric() && c != '_')
385 .any(|token| token == "vector")
386}
387
388fn jsonb_cast_misuse(sql: &str) -> bool {
395 const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
396 let lowered = sql.to_ascii_lowercase();
397 let bytes = lowered.as_bytes();
398 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
399
400 for column in JSONB_COLUMNS {
402 let mut start = 0;
403 while let Some(pos) = lowered[start..].find(column) {
404 let begin = start + pos;
405 let end = begin + column.len();
406 start = end;
407 let bounded = (begin == 0 || !is_ident(bytes[begin - 1]))
408 && (end == bytes.len() || !is_ident(bytes[end]));
409 if bounded && lowered[end..].trim_start().starts_with("::") {
410 return true;
411 }
412 }
413 }
414
415 let mut start = 0;
417 while let Some(pos) = lowered[start..].find("cast") {
418 let begin = start + pos;
419 start = begin + 4;
420 if begin > 0 && is_ident(bytes[begin - 1]) {
421 continue;
422 }
423 let Some(open) = lowered[begin + 4..].trim_start().strip_prefix('(') else {
424 continue;
425 };
426 let mut operand = open.trim_start();
427 if let Some(dot) = operand.find('.')
428 && dot > 0
429 && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
430 {
431 operand = &operand[dot + 1..];
432 }
433 for column in JSONB_COLUMNS {
434 if let Some(after) = operand.strip_prefix(column)
435 && !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
436 && after
437 .trim_start()
438 .strip_prefix("as")
439 .is_some_and(|rest| rest.starts_with(char::is_whitespace))
440 {
441 return true;
442 }
443 }
444 }
445 false
446}
447
448fn jsonb_fulldoc_like_scan(sql: &str) -> bool {
462 const JSONB_COLUMNS: [&str; 2] = ["variant_data", "options"];
463 const NEEDLE: &str = "json_extract";
464 let lowered = sql.to_ascii_lowercase();
465 let bytes = lowered.as_bytes();
466 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
467
468 let mut start = 0;
469 while let Some(pos) = lowered[start..].find(NEEDLE) {
470 let begin = start + pos;
471 start = begin + NEEDLE.len();
472 if begin > 0 && is_ident(bytes[begin - 1]) {
473 continue;
474 }
475 let Some(rest) = lowered[start..].trim_start().strip_prefix('(') else {
476 continue;
477 };
478 let mut operand = rest.trim_start();
479 if let Some(dot) = operand.find('.')
481 && dot > 0
482 && operand.as_bytes()[..dot].iter().all(|b| is_ident(*b))
483 {
484 operand = &operand[dot + 1..];
485 }
486 let Some(col) = JSONB_COLUMNS.into_iter().find(|c| operand.starts_with(c)) else {
487 continue;
488 };
489 let tail = operand[col.len()..].trim_start();
492 let Some(tail) = tail
493 .strip_prefix(',')
494 .map(str::trim_start)
495 .and_then(|t| t.strip_prefix("'$'"))
496 .map(str::trim_start)
497 .and_then(|t| t.strip_prefix(')'))
498 else {
499 continue;
500 };
501 let mut tail = tail.trim_start();
503 while let Some(next) = tail.strip_prefix(')') {
504 tail = next.trim_start();
505 }
506 if let Some(next) = tail.strip_prefix("not")
507 && next.starts_with(char::is_whitespace)
508 {
509 tail = next.trim_start();
510 }
511 for op in ["like", "ilike"] {
512 if let Some(next) = tail.strip_prefix(op)
513 && next.starts_with(char::is_whitespace)
514 && next.trim_start().starts_with("'%")
515 {
516 return true;
517 }
518 }
519 }
520 false
521}
522
523fn build_context() -> Result<SessionContext, SqlError> {
524 let runtime = RuntimeEnvBuilder::new()
525 .with_memory_limit(MEM_LIMIT_BYTES, 1.0)
526 .build_arc()
527 .map_err(|error| SqlError::Infra(anyhow!("datafusion runtime init failed: {error}")))?;
528 let state = SessionStateBuilder::new()
531 .with_config(SessionConfig::new().with_information_schema(true))
532 .with_runtime_env(runtime)
533 .with_default_features()
534 .build();
535 Ok(SessionContext::new_with_state(state))
536}
537
538fn renamed_key(table: &str) -> Option<&'static str> {
543 match table {
544 "messages" => Some("message_id"),
545 "sessions" => Some("session_id"),
546 _ => None,
547 }
548}
549
550fn register(ctx: &SessionContext, tables: &Tables) -> Result<(), SqlError> {
551 for (name, dataset) in [
552 ("sessions", &tables.sessions),
553 ("messages", &tables.messages),
554 ] {
555 let Some(dataset) = dataset else { continue };
556 let provider = LanceTableProvider::new(dataset.clone(), false, false);
561 let key = renamed_key(name).unwrap_or("id");
562 let view = renamed_view(name, Arc::new(provider), "id", key)
563 .map_err(|error| SqlError::Infra(anyhow!("build {name} view: {error}")))?;
564 ctx.register_table(name, Arc::new(view))
565 .map_err(|error| SqlError::Infra(anyhow!("register table {name}: {error}")))?;
566 }
567 if let Some(parts) = &tables.parts {
572 let provider = LanceTableProvider::new(parts.clone(), false, false);
573 let keep: Vec<_> = parts
574 .schema()
575 .fields
576 .iter()
577 .filter(|field| field.name != "data")
578 .map(|field| col(field.name.as_str()))
579 .collect();
580 let plan = LogicalPlanBuilder::scan("parts", provider_as_source(Arc::new(provider)), None)
581 .and_then(|builder| builder.project(keep))
582 .and_then(LogicalPlanBuilder::build)
583 .map_err(|error| SqlError::Infra(anyhow!("build parts view: {error}")))?;
584 ctx.register_table("parts", Arc::new(ViewTable::new(plan, None)))
585 .map_err(|error| SqlError::Infra(anyhow!("register table parts: {error}")))?;
586 }
587 let datasets = [
592 ("sessions", &tables.sessions),
593 ("messages", &tables.messages),
594 ("parts", &tables.parts),
595 ]
596 .into_iter()
597 .filter_map(|(name, dataset)| dataset.clone().map(|d| (name.to_owned(), d)))
598 .collect();
599 let fts = ScoredFtsUdtf { datasets };
600 ctx.register_udtf("fts", Arc::new(fts));
601 register_functions(ctx);
602 for udf in lenient_json_udfs() {
606 ctx.register_udf(udf);
607 }
608 if let Some(first_value) = ctx.state().aggregate_functions().get("first_value") {
613 ctx.register_udaf(first_value.as_ref().clone().with_aliases(["any_value"]));
614 }
615 ctx.register_udf(ScalarUDF::new_from_impl(FtsMisuse::new()));
621 Ok(())
622}
623
624fn renamed_view(
628 scan_name: &str,
629 provider: Arc<dyn TableProvider>,
630 from: &str,
631 to: &str,
632) -> Result<ViewTable, DataFusionError> {
633 let projection: Vec<_> = provider
634 .schema()
635 .fields()
636 .iter()
637 .map(|field| {
638 let column = col(field.name().as_str());
639 if field.name() == from {
640 column.alias(to)
641 } else {
642 column
643 }
644 })
645 .collect();
646 let plan = LogicalPlanBuilder::scan(scan_name, provider_as_source(provider), None)?
647 .project(projection)?
648 .build()?;
649 Ok(ViewTable::new(plan, None))
650}
651
652const FTS_MISUSE: &str = "fts is a table function and goes in FROM, not in WHERE or the \
653 projection. For filtering use WHERE contains_tokens(search_text, 'word1 word2') (all \
654 words must match; index-accelerated). For ranked results: SELECT m.message_id, f._score \
655 FROM fts('messages', '{\"match\":{\"column\":\"search_text\",\"terms\":\"...\"}}') f \
656 JOIN messages m ON m.message_id = f.message_id ORDER BY f._score DESC.";
657
658#[derive(Debug, PartialEq, Eq, Hash)]
660struct FtsMisuse {
661 signature: Signature,
662}
663
664impl FtsMisuse {
665 fn new() -> Self {
666 Self {
667 signature: Signature::variadic_any(Volatility::Immutable),
668 }
669 }
670}
671
672impl ScalarUDFImpl for FtsMisuse {
673 fn as_any(&self) -> &dyn std::any::Any {
674 self
675 }
676
677 fn name(&self) -> &str {
678 "fts"
679 }
680
681 fn signature(&self) -> &Signature {
682 &self.signature
683 }
684
685 fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
686 Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
687 }
688
689 fn invoke_with_args(
690 &self,
691 _args: ScalarFunctionArgs,
692 ) -> Result<ColumnarValue, DataFusionError> {
693 Err(DataFusionError::Plan(FTS_MISUSE.to_owned()))
694 }
695}
696
697#[derive(Debug)]
709struct ScoredFtsUdtf {
710 datasets: HashMap<String, Arc<Dataset>>,
711}
712
713impl TableFunctionImpl for ScoredFtsUdtf {
714 fn call(
715 &self,
716 expr: &[Expr],
717 ) -> Result<Arc<dyn TableProvider>, lance::deps::datafusion::error::DataFusionError> {
718 let [table_expr, query_expr] = expr else {
719 return Err(DataFusionError::Execution(
720 "fts() takes (table_name, fts_query_json)".to_owned(),
721 ));
722 };
723 let Expr::Literal(ScalarValue::Utf8(Some(table_name)), _) = table_expr else {
724 return Err(DataFusionError::Execution(
725 "fts() first argument must be a table name string".to_owned(),
726 ));
727 };
728 let Expr::Literal(ScalarValue::Utf8(Some(fts_query)), _) = query_expr else {
729 return Err(DataFusionError::Execution(
730 "fts() second argument must be the fts query as a JSON string".to_owned(),
731 ));
732 };
733 let dataset = self.datasets.get(table_name).ok_or_else(|| {
734 DataFusionError::Execution(format!("fts(): table {table_name} not found"))
735 })?;
736 let mut full_schema = Schema::from(dataset.schema());
737 full_schema = full_schema
738 .try_with_column(Field::new(SCORE_COLUMN, DataType::Float32, true))
739 .map_err(|error| DataFusionError::ArrowError(Box::new(error), None))?;
740 let provider: Arc<dyn TableProvider> = Arc::new(ScoredFtsProvider {
741 dataset: dataset.clone(),
742 fts_query: FullTextSearchQuery::new_query(from_json(fts_query)?),
743 full_schema: Arc::new(full_schema),
744 });
745 match renamed_key(table_name) {
748 Some(key) => Ok(Arc::new(renamed_view("fts", provider, "id", key)?)),
749 None => Ok(provider),
750 }
751 }
752}
753
754const SCORE_COLUMN: &str = "_score";
755
756#[derive(Debug)]
757struct ScoredFtsProvider {
758 dataset: Arc<Dataset>,
759 fts_query: FullTextSearchQuery,
760 full_schema: SchemaRef,
761}
762
763#[async_trait::async_trait]
764impl TableProvider for ScoredFtsProvider {
765 fn as_any(&self) -> &dyn std::any::Any {
766 self
767 }
768
769 fn schema(&self) -> SchemaRef {
770 self.full_schema.clone()
771 }
772
773 fn table_type(&self) -> TableType {
774 TableType::Temporary
775 }
776
777 async fn scan(
778 &self,
779 _state: &dyn Session,
780 projection: Option<&Vec<usize>>,
781 filters: &[Expr],
782 limit: Option<usize>,
783 ) -> Result<Arc<dyn ExecutionPlan>, lance::deps::datafusion::error::DataFusionError> {
784 let mut scan = self.dataset.scan();
785 scan.full_text_search(self.fts_query.clone())?;
786 scan.disable_scoring_autoprojection();
790 match projection {
791 Some(projection) if projection.is_empty() => {
792 scan.empty_project()?;
793 }
794 Some(projection) => {
795 let columns: Vec<&str> = projection
796 .iter()
797 .map(|idx| self.full_schema.field(*idx).name().as_str())
798 .collect();
799 scan.project(&columns)?;
800 }
801 None => {
802 let columns: Vec<&str> = self
803 .full_schema
804 .fields()
805 .iter()
806 .map(|field| field.name().as_str())
807 .collect();
808 scan.project(&columns)?;
809 }
810 }
811 if let Some(combined) = filters
812 .iter()
813 .cloned()
814 .reduce(|left, right| left.and(right))
815 {
816 scan.filter_expr(combined);
817 }
818 scan.limit(limit.map(|l| l as i64), None)?;
819 scan.create_plan().await.map_err(DataFusionError::from)
820 }
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
825enum JsonGet {
826 Text,
827 Int,
828 Float,
829 Bool,
830}
831
832const MAX_JSON_KEYS: usize = 6;
835
836fn lenient_json_udfs() -> [ScalarUDF; 4] {
846 let make = |name: &'static str, kind: JsonGet, return_type: DataType| {
847 ScalarUDF::new_from_impl(LenientJsonGet {
848 name,
849 kind,
850 return_type,
851 signature: json_key_path_signature(),
852 })
853 };
854 [
855 make("json_get_string", JsonGet::Text, DataType::Utf8),
856 make("json_get_int", JsonGet::Int, DataType::Int64),
857 make("json_get_float", JsonGet::Float, DataType::Float64),
858 make("json_get_bool", JsonGet::Bool, DataType::Boolean),
859 ]
860}
861
862fn json_key_path_signature() -> Signature {
864 let arities = (1..=MAX_JSON_KEYS)
865 .map(|keys| {
866 let mut types = vec![DataType::LargeBinary];
867 types.extend(std::iter::repeat_n(DataType::Utf8, keys));
868 TypeSignature::Exact(types)
869 })
870 .collect();
871 Signature::one_of(arities, Volatility::Immutable)
872}
873
874#[derive(Debug, PartialEq, Eq, Hash)]
876struct LenientJsonGet {
877 name: &'static str,
878 kind: JsonGet,
879 return_type: DataType,
880 signature: Signature,
881}
882
883impl ScalarUDFImpl for LenientJsonGet {
884 fn as_any(&self) -> &dyn std::any::Any {
885 self
886 }
887
888 fn name(&self) -> &str {
889 self.name
890 }
891
892 fn signature(&self) -> &Signature {
893 &self.signature
894 }
895
896 fn return_type(&self, _arg_types: &[DataType]) -> Result<DataType, DataFusionError> {
897 Ok(self.return_type.clone())
898 }
899
900 fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue, DataFusionError> {
901 json_get_lenient(&args.args, &self.kind)
902 }
903}
904
905fn json_step(raw: jsonb::RawJsonb<'_>, key: &str) -> Option<jsonb::OwnedJsonb> {
907 let value = if raw.is_object().unwrap_or(false) {
908 raw.get_by_name(key, false).ok().flatten()
909 } else if raw.is_array().unwrap_or(false) {
910 key.parse::<usize>()
911 .ok()
912 .and_then(|index| raw.get_by_index(index).ok().flatten())
913 } else {
914 None
915 };
916 value.filter(|value| !value.as_raw().is_null().unwrap_or(false))
917}
918
919fn json_get_lenient(
920 args: &[ColumnarValue],
921 kind: &JsonGet,
922) -> Result<ColumnarValue, DataFusionError> {
923 let arrays = ColumnarValue::values_to_arrays(args)?;
924 let Some((jsonb_arg, key_args)) = arrays.split_first().filter(|(_, keys)| !keys.is_empty())
925 else {
926 return Err(DataFusionError::Execution(
927 "json_get_* takes (json_column, 'key', ...) - at least one key".to_owned(),
928 ));
929 };
930 let jsonb_array = jsonb_arg
931 .as_any()
932 .downcast_ref::<LargeBinaryArray>()
933 .ok_or_else(|| {
934 DataFusionError::Execution(
935 "json_get_* argument 1 must be a JSON column (variant_data, options)".to_owned(),
936 )
937 })?;
938 let key_arrays: Vec<&StringArray> = key_args
939 .iter()
940 .map(|key_arg| {
941 key_arg
942 .as_any()
943 .downcast_ref::<StringArray>()
944 .ok_or_else(|| {
945 DataFusionError::Execution("json_get_* keys must be string literals".to_owned())
946 })
947 })
948 .collect::<Result<_, _>>()?;
949
950 let field = |row: usize| -> Option<jsonb::OwnedJsonb> {
951 if jsonb_array.is_null(row) {
952 return None;
953 }
954 let mut keys = key_arrays.iter();
955 let first = keys.next()?;
956 if first.is_null(row) {
957 return None;
958 }
959 let mut current = json_step(
960 jsonb::RawJsonb::new(jsonb_array.value(row)),
961 first.value(row),
962 )?;
963 for key_array in keys {
964 if key_array.is_null(row) {
965 return None;
966 }
967 current = json_step(current.as_raw(), key_array.value(row))?;
968 }
969 Some(current)
970 };
971
972 let rows = jsonb_array.len();
973 let array: Arc<dyn Array> = match kind {
974 JsonGet::Text => {
975 let mut builder = StringBuilder::with_capacity(rows, 1024);
976 for row in 0..rows {
977 match field(row) {
978 Some(value) => match value.as_raw().to_str() {
981 Ok(text) => builder.append_value(text),
982 Err(_) => builder.append_value(value.to_string()),
983 },
984 None => builder.append_null(),
985 }
986 }
987 Arc::new(builder.finish())
988 }
989 JsonGet::Int => {
990 let mut builder = Int64Builder::with_capacity(rows);
991 for row in 0..rows {
992 builder.append_option(field(row).and_then(|value| value.as_raw().to_i64().ok()));
993 }
994 Arc::new(builder.finish())
995 }
996 JsonGet::Float => {
997 let mut builder = Float64Builder::with_capacity(rows);
998 for row in 0..rows {
999 builder.append_option(field(row).and_then(|value| value.as_raw().to_f64().ok()));
1000 }
1001 Arc::new(builder.finish())
1002 }
1003 JsonGet::Bool => {
1004 let mut builder = BooleanBuilder::with_capacity(rows);
1005 for row in 0..rows {
1006 builder.append_option(field(row).and_then(|value| value.as_raw().to_bool().ok()));
1007 }
1008 Arc::new(builder.finish())
1009 }
1010 };
1011 Ok(ColumnarValue::Array(array))
1012}
1013
1014fn enrich(message: &str) -> String {
1018 const HINTS: &[(&str, &str)] = &[
1019 (
1020 "No field named",
1021 "columns are messages(session_id, message_id, timestamp, role, source_agent, \
1022 project, content [system-role only], search_text [the conversational text], \
1023 embedding_model, options) | sessions(session_id, parent_session_id, \
1024 parent_message_id, source_agent, created_at, project, options) | \
1025 parts(session_id, message_id, id, ordinal, type, provenance, variant_data, \
1026 options). Part bodies (tool params/results, text) live in parts.variant_data - \
1027 read them with json_extract(variant_data, '$.field'). For text search use \
1028 contains_tokens(search_text, '...') in WHERE, or the fts('messages', ...) \
1029 table function in FROM for ranked results; to read a transcript use pond_get. \
1030 Full doc: resource schema://pond-sql.",
1031 ),
1032 (
1033 "Encountered non UTF-8 data",
1034 "JSON columns (variant_data, options) are binary JSONB - CAST / ::text does not \
1035 work on them. Stringify the whole value with json_extract(col, '$'), or fetch \
1036 one field with json_extract(col, '$.field').",
1037 ),
1038 (
1039 "Resources exhausted",
1040 "the query ran out of memory - usually from carrying whole JSON columns \
1041 (variant_data, options) through a join or sort. Project narrow fields with \
1042 json_extract(col, '$.field') instead of whole columns, filter before joining, \
1043 or export the full set with format=parquet.",
1044 ),
1045 (
1046 "LIKE prefix queries are not supported for bitmap indexes",
1047 "prefix LIKE ('x%') and starts_with() fail on bitmap-indexed columns \
1048 (messages.source_agent). Use equality, \
1049 split_part(source_agent, '/', 1) = '...', or an infix pattern (LIKE '%x%').",
1050 ),
1051 (
1052 "call to 'json_",
1053 "JSON function signatures: json_get_string|json_get_int|json_get_float|\
1054 json_get_bool(col, 'key', ...) walk a key path (array steps by numeric \
1055 index); json_get(col, 'key') returns JSONB for chaining; json_extract(col, \
1056 '$.a.b') takes a JSONPath and returns JSON text of any value (the right tool \
1057 for deeply nested or mixed-type fields).",
1058 ),
1059 (
1060 "Invalid function 'json",
1061 "available JSON functions: json_get_string, json_get_int, json_get_float, \
1062 json_get_bool (col, 'key', ...); json_get(col, 'key') -> JSONB for chaining; \
1063 json_extract(col, '$.a.b') -> JSON text; json_array_contains; \
1064 json_array_length. See resource schema://pond-sql.",
1065 ),
1066 (
1067 "does not satisfy distribution requirements",
1072 "this fts query shape planned an unexecutable join. For AND semantics use a \
1073 single match query with operator And: fts('messages', \
1074 '{\"match\":{\"column\":\"search_text\",\"terms\":\"a b\",\"operator\":\"And\"}}'), \
1075 optionally with LIKE post-filters in WHERE.",
1076 ),
1077 (
1078 "position is not found but required for phrase queries",
1079 "the full-text index is built without positions, so \"phrase\" queries are \
1080 unavailable. Use a match query with operator And plus LIKE post-filters for \
1081 exact-substring matching.",
1082 ),
1083 ];
1084 for (pattern, hint) in HINTS {
1085 if message.contains(pattern) {
1086 return format!("{message}\nhint: {hint}");
1087 }
1088 }
1089 message.to_owned()
1090}
1091
1092fn displayable(batch: &RecordBatch) -> Result<RecordBatch, ArrowError> {
1095 let decoded = lance_arrow::json::convert_lance_json_to_arrow(batch)?;
1096 let keep: Vec<usize> = decoded
1097 .schema()
1098 .fields()
1099 .iter()
1100 .enumerate()
1101 .filter(|(_, field)| is_displayable(field.data_type()))
1102 .map(|(index, _)| index)
1103 .collect();
1104 decoded.project(&keep)
1105}
1106
1107fn is_displayable(data_type: &DataType) -> bool {
1108 !matches!(
1109 data_type,
1110 DataType::FixedSizeList(_, _)
1111 | DataType::Binary
1112 | DataType::LargeBinary
1113 | DataType::BinaryView
1114 | DataType::FixedSizeBinary(_)
1115 )
1116}
1117
1118fn collapse_newlines(batches: &[RecordBatch]) -> Result<Vec<RecordBatch>, ArrowError> {
1124 fn escape<O: OffsetSizeTrait>(array: &GenericStringArray<O>) -> ArrayRef {
1125 let escaped: GenericStringArray<O> =
1126 array.iter().map(|value| value.map(escape_cell)).collect();
1127 Arc::new(escaped)
1128 }
1129 fn escape_cell(text: &str) -> std::borrow::Cow<'_, str> {
1130 if text.contains(['\n', '\r']) {
1131 std::borrow::Cow::Owned(text.replace("\r\n", "\\n").replace(['\n', '\r'], "\\n"))
1132 } else {
1133 std::borrow::Cow::Borrowed(text)
1134 }
1135 }
1136 batches
1137 .iter()
1138 .map(|batch| {
1139 let columns: Vec<ArrayRef> = batch
1140 .columns()
1141 .iter()
1142 .map(|array| match array.data_type() {
1143 DataType::Utf8 => array
1144 .as_any()
1145 .downcast_ref::<StringArray>()
1146 .map_or_else(|| array.clone(), escape),
1147 DataType::LargeUtf8 => array
1148 .as_any()
1149 .downcast_ref::<GenericStringArray<i64>>()
1150 .map_or_else(|| array.clone(), escape),
1151 DataType::Utf8View => array
1152 .as_any()
1153 .downcast_ref::<StringViewArray>()
1154 .map_or_else(
1155 || array.clone(),
1156 |view| {
1157 let escaped: StringViewArray =
1158 view.iter().map(|value| value.map(escape_cell)).collect();
1159 Arc::new(escaped)
1160 },
1161 ),
1162 _ => array.clone(),
1163 })
1164 .collect();
1165 RecordBatch::try_new(batch.schema(), columns)
1166 })
1167 .collect()
1168}
1169
1170fn render_inline(
1171 display: &[RecordBatch],
1172 max_rows: usize,
1173 elapsed: Duration,
1174) -> Result<String, ArrowError> {
1175 let total: usize = display.iter().map(RecordBatch::num_rows).sum();
1176 let elapsed_ms = elapsed.as_millis();
1177 if total == 0 {
1178 return Ok(format!(
1180 "0 rows ({elapsed_ms} ms).\n{}",
1181 pretty_format_batches(display)?
1182 ));
1183 }
1184 let render = |shown: usize| -> Result<String, ArrowError> {
1185 let limited = collapse_newlines(&limit_batches(display, shown))?;
1186 Ok(pretty_format_batches(&limited)?.to_string())
1187 };
1188 let mut shown = total.min(max_rows);
1189 let mut table = render(shown)?;
1190 while table.len() > INLINE_BUDGET_BYTES && shown > 1 {
1191 shown = (shown / 2).max(1);
1192 table = render(shown)?;
1193 }
1194 let mut out = format!("{total} row(s) in {elapsed_ms} ms; showing {shown}.\n{table}");
1195 if shown < total {
1196 out.push_str(&format!(
1197 "\n... {} row(s) omitted. To page: ORDER BY <indexed col> (e.g. timestamp, \
1198 message_id), then in the next call add `WHERE (col, message_id) < \
1199 (<last_col>, <last_message_id>)` - keyset pagination, see schema://pond-sql. \
1200 For the full set: format=parquet or format=ndjson.",
1201 total - shown
1202 ));
1203 }
1204 Ok(out)
1205}
1206
1207fn limit_batches(batches: &[RecordBatch], max_rows: usize) -> Vec<RecordBatch> {
1208 let mut out = Vec::new();
1209 let mut remaining = max_rows;
1210 for batch in batches {
1211 if remaining == 0 {
1212 break;
1213 }
1214 if batch.num_rows() <= remaining {
1215 remaining -= batch.num_rows();
1216 out.push(batch.clone());
1217 } else {
1218 out.push(batch.slice(0, remaining));
1219 remaining = 0;
1220 }
1221 }
1222 out
1223}
1224
1225fn encode_parquet(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1226 let schema = batches
1227 .first()
1228 .map(RecordBatch::schema)
1229 .ok_or_else(|| SqlError::Query("query returned no columns to export".to_owned()))?;
1230 let mut buffer = Vec::new();
1231 let mut writer = ArrowWriter::try_new(&mut buffer, schema, None)
1232 .map_err(|error| SqlError::Infra(anyhow!("parquet init failed: {error}")))?;
1233 for batch in batches {
1234 writer
1235 .write(batch)
1236 .map_err(|error| SqlError::Infra(anyhow!("parquet write failed: {error}")))?;
1237 }
1238 writer
1239 .close()
1240 .map_err(|error| SqlError::Infra(anyhow!("parquet close failed: {error}")))?;
1241 Ok(buffer)
1242}
1243
1244fn encode_ndjson(batches: &[RecordBatch]) -> Result<Vec<u8>, SqlError> {
1245 let mut buffer = Vec::new();
1246 {
1247 let mut writer = LineDelimitedWriter::new(&mut buffer);
1248 let refs: Vec<&RecordBatch> = batches.iter().collect();
1249 writer
1250 .write_batches(&refs)
1251 .map_err(|error| SqlError::Infra(anyhow!("ndjson write failed: {error}")))?;
1252 writer
1253 .finish()
1254 .map_err(|error| SqlError::Infra(anyhow!("ndjson finish failed: {error}")))?;
1255 }
1256 Ok(buffer)
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 #![allow(clippy::expect_used)]
1262
1263 use super::*;
1264
1265 fn rejected(sql: &str) -> bool {
1266 matches!(parse_and_gate(sql), Err(SqlError::Query(_)))
1267 }
1268
1269 fn parses_as(sql: &str, expected: StatementKind) -> bool {
1270 match parse_and_gate(sql) {
1271 Ok(parsed) => matches!(
1272 (&parsed.kind, &expected),
1273 (StatementKind::Query, StatementKind::Query)
1274 | (StatementKind::Explain, StatementKind::Explain)
1275 ),
1276 Err(_) => false,
1277 }
1278 }
1279
1280 #[test]
1281 fn mentions_table_is_sound_for_open_pruning() {
1282 assert!(mentions_table("SELECT * FROM messages", "messages"));
1285 assert!(mentions_table("select * from MESSAGES", "messages"));
1286 assert!(mentions_table(
1287 "SELECT s.id FROM sessions s JOIN parts p ON s.id = p.session_id",
1288 "parts",
1289 ));
1290 assert!(mentions_table(
1291 "SELECT * FROM fts('messages', '{\"match\":{}}')",
1292 "messages",
1293 ));
1294 assert!(mentions_table(
1295 "WITH x AS (SELECT * FROM sessions) SELECT * FROM x",
1296 "sessions",
1297 ));
1298 assert!(!mentions_table("SELECT * FROM messages", "parts"));
1301 assert!(!mentions_table("SELECT * FROM messages", "sessions"));
1302 assert!(!mentions_table(
1303 "SELECT counterparts FROM messages",
1304 "parts"
1305 ));
1306 }
1307
1308 #[test]
1309 fn allows_single_select_and_cte() {
1310 assert!(parses_as("SELECT 1", StatementKind::Query));
1311 assert!(parses_as(
1312 "SELECT role, count(*) FROM messages GROUP BY role",
1313 StatementKind::Query
1314 ));
1315 assert!(parses_as(
1316 "WITH t AS (SELECT 1 AS a) SELECT a FROM t",
1317 StatementKind::Query
1318 ));
1319 }
1320
1321 #[test]
1322 fn allows_explain_of_select() {
1323 assert!(parses_as("EXPLAIN SELECT 1", StatementKind::Explain));
1324 assert!(parses_as(
1325 "EXPLAIN ANALYZE SELECT role FROM messages",
1326 StatementKind::Explain
1327 ));
1328 }
1329
1330 #[test]
1331 fn rejects_explain_of_non_query() {
1332 assert!(rejected("EXPLAIN INSERT INTO messages VALUES ('x')"));
1335 }
1336
1337 #[test]
1338 fn rejects_writes_and_side_effects() {
1339 assert!(rejected("INSERT INTO messages VALUES ('x')"));
1340 assert!(rejected("UPDATE messages SET role = 'x'"));
1341 assert!(rejected("DELETE FROM messages"));
1342 assert!(rejected("CREATE TABLE t (x INT)"));
1343 assert!(rejected("CREATE VIEW v AS SELECT 1"));
1344 assert!(rejected("DROP TABLE messages"));
1345 assert!(rejected(
1346 "CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION '/etc'"
1347 ));
1348 assert!(rejected("COPY (SELECT 1) TO '/tmp/x.parquet'"));
1349 assert!(rejected("SET a = 1"));
1350 }
1351
1352 #[test]
1353 fn rejects_multiple_statements() {
1354 assert!(rejected("SELECT 1; SELECT 2"));
1355 assert!(rejected("SELECT 1; DROP TABLE messages"));
1356 }
1357
1358 #[test]
1359 fn rejects_unparseable() {
1360 assert!(rejected("NOT SQL AT ALL ;;"));
1361 }
1362
1363 fn mentions_vector(sql: &str) -> bool {
1364 match parse_and_gate(sql) {
1365 Ok(parsed) => projection_mentions_vector(parsed.projection_query()),
1366 Err(_) => false,
1367 }
1368 }
1369
1370 #[test]
1371 fn explicit_vector_projection_is_rejected() {
1372 assert!(mentions_vector("SELECT vector FROM messages"));
1373 assert!(mentions_vector("SELECT id, vector FROM messages"));
1374 assert!(mentions_vector("SELECT m.vector FROM messages m"));
1375 assert!(mentions_vector("SELECT array_length(vector) FROM messages"));
1376 assert!(mentions_vector("EXPLAIN SELECT vector FROM messages"));
1377 }
1378
1379 #[test]
1380 fn enrich_appends_recovery_hints() {
1381 let cases = [
1383 (
1384 "SQL error: Schema error: No field named created_at.",
1385 "schema://pond-sql",
1386 ),
1387 (
1388 "SQL error: External error: Arrow error: Invalid argument error: \
1389 Encountered non UTF-8 data",
1390 "json_extract",
1391 ),
1392 (
1393 "SQL error: External error: Not supported: LIKE prefix queries are not \
1394 supported for bitmap indexes",
1395 "split_part",
1396 ),
1397 (
1398 "SQL error: Error during planning: Failed to coerce arguments to satisfy \
1399 a call to 'json_get_string' function",
1400 "JSONPath",
1401 ),
1402 (
1403 "SQL error: Error during planning: Invalid function 'json_get_json'.",
1404 "json_extract",
1405 ),
1406 (
1407 "SQL error: Resources exhausted: Additional allocation failed for \
1408 HashJoinInput[0] with top memory consumers",
1409 "json_extract",
1410 ),
1411 ];
1412 for (raw, marker) in cases {
1413 let enriched = enrich(raw);
1414 assert!(enriched.starts_with(raw), "original kept: {enriched}");
1415 assert!(enriched.contains("hint:"), "hint appended: {enriched}");
1416 assert!(enriched.contains(marker), "hint names the fix: {enriched}");
1417 }
1418 assert_eq!(
1420 enrich("SQL error: division by zero"),
1421 "SQL error: division by zero"
1422 );
1423 }
1424
1425 #[test]
1426 fn select_star_and_where_vector_are_allowed() {
1427 assert!(!mentions_vector("SELECT * FROM messages"));
1429 assert!(!mentions_vector(
1431 "SELECT message_id FROM messages WHERE vector IS NOT NULL"
1432 ));
1433 }
1434
1435 #[test]
1436 fn jsonb_cast_misuse_detects_cast_and_coloncolon() {
1437 for sql in [
1438 "SELECT CAST(variant_data AS VARCHAR) FROM parts",
1439 "SELECT cast(p.variant_data as text) FROM parts p",
1440 "SELECT variant_data::text FROM parts",
1441 "SELECT p.variant_data :: varchar FROM parts p",
1442 "SELECT options::text FROM messages",
1443 "SELECT lower(CAST(variant_data AS VARCHAR)) FROM parts",
1444 ] {
1445 assert!(jsonb_cast_misuse(sql), "should reject: {sql}");
1446 }
1447 }
1448
1449 #[test]
1450 fn jsonb_cast_misuse_allows_legitimate_use() {
1451 for sql in [
1452 "SELECT json_extract(variant_data, '$') FROM parts",
1453 "SELECT json_get_string(variant_data, 'name') FROM parts",
1454 "SELECT CAST(ordinal AS BIGINT) FROM parts",
1455 "SELECT timestamp::date FROM messages",
1456 "SELECT my_options::text FROM t",
1458 "SELECT CAST(json_extract(variant_data, '$.x') AS BIGINT) FROM parts",
1459 ] {
1460 assert!(!jsonb_cast_misuse(sql), "should allow: {sql}");
1461 }
1462 }
1463
1464 #[test]
1465 fn jsonb_fulldoc_like_scan_detects_whole_document_substring() {
1466 for sql in [
1467 "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE '%needle%'",
1468 "SELECT * FROM parts p WHERE lower(json_extract(p.variant_data, '$')) LIKE '%x%'",
1469 "SELECT * FROM messages WHERE json_extract(options, '$') ILIKE '%y%'",
1470 "SELECT * FROM parts WHERE json_extract(variant_data,'$') NOT LIKE '%z%'",
1471 "SELECT p.message_id FROM parts p JOIN messages m ON p.message_id = m.message_id \
1473 WHERE m.timestamp >= '2026-06-11' AND lower(json_extract(p.variant_data, '$')) \
1474 LIKE '%weekly limit%'",
1475 ] {
1476 assert!(jsonb_fulldoc_like_scan(sql), "should reject: {sql}");
1477 }
1478 }
1479
1480 #[test]
1481 fn jsonb_fulldoc_like_scan_allows_targeted_and_nonleading() {
1482 for sql in [
1483 "SELECT * FROM parts WHERE json_extract(variant_data, '$.name') LIKE '%x%'",
1485 "SELECT * FROM parts WHERE json_extract(variant_data, '$') LIKE 'pre%'",
1487 "SELECT * FROM messages WHERE search_text LIKE '%x%'",
1489 "SELECT * FROM messages WHERE contains_tokens(search_text, 'x')",
1491 "SELECT json_extract(variant_data, '$') FROM parts LIMIT 1",
1493 ] {
1494 assert!(!jsonb_fulldoc_like_scan(sql), "should allow: {sql}");
1495 }
1496 }
1497
1498 #[test]
1499 fn render_inline_collapses_newlines_in_cells() {
1500 let schema = Arc::new(Schema::new(vec![Field::new("t", DataType::Utf8, true)]));
1501 let batch = RecordBatch::try_new(
1502 schema,
1503 vec![Arc::new(StringArray::from(vec![Some(
1504 "line one\nline two\r\nline three",
1505 )]))],
1506 )
1507 .expect("single-column batch");
1508 let out = render_inline(&[batch], 10, Duration::from_millis(1)).expect("render succeeds");
1509 assert!(
1510 out.contains("line one\\nline two\\nline three"),
1511 "newlines collapse to literal \\n: {out}"
1512 );
1513 let row_lines: Vec<&str> = out
1516 .lines()
1517 .filter(|line| line.contains("line one"))
1518 .collect();
1519 assert_eq!(row_lines.len(), 1, "one physical line per row: {out}");
1520 }
1521}