From 76eb343c307630ff3f4aaecc67c8e0664fc5ab4b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 18:23:39 -0400 Subject: [PATCH 1/2] A Trino decimal reads as a number, so SUM(amount) is 1234.5 instead of "1234.50" Signed-off-by: Rudy Celekli --- .../utopia-server/src/query_engine/trino.rs | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/utopia-server/src/query_engine/trino.rs b/crates/utopia-server/src/query_engine/trino.rs index 771a86973..d6c484a92 100644 --- a/crates/utopia-server/src/query_engine/trino.rs +++ b/crates/utopia-server/src/query_engine/trino.rs @@ -7,7 +7,7 @@ use super::conn::TrinoConn; use super::{ - rows_to_json_lines, sql_literal, truncate_rows, wrap_limit, QueryEngine, QueryResult, + coerce, rows_to_json_lines, sql_literal, truncate_rows, wrap_limit, QueryEngine, QueryResult, SchemaColumn, HTTP_POLL_BUDGET, STATEMENT_TIMEOUT_SECS, }; use base64::Engine as _; @@ -22,6 +22,8 @@ pub struct TrinoEngine { #[derive(Deserialize)] struct Column { name: String, + #[serde(rename = "type", default)] + ty: String, } #[derive(Deserialize)] @@ -86,7 +88,7 @@ impl TrinoEngine { .json() .await?; let started = Instant::now(); - let mut columns: Option> = None; + let mut columns: Option> = None; let mut rows = Vec::new(); loop { if let Some(e) = page.error { @@ -94,10 +96,7 @@ impl TrinoEngine { anyhow::bail!("{name}{}", e.message); } if columns.is_none() { - columns = page - .columns - .take() - .map(|cs| cs.into_iter().map(|c| c.name).collect()); + columns = page.columns.take(); } if let Some(d) = page.data.take() { rows.extend(d); @@ -120,7 +119,16 @@ impl TrinoEngine { .json() .await?; } - Ok((columns.unwrap_or_default(), rows)) + let columns = columns.unwrap_or_default(); + // 协议把 decimal 写成字符串(其余的数都是 JSON 数),按列类型还原 + for row in &mut rows { + for (v, c) in row.iter_mut().zip(&columns) { + if c.ty.starts_with("decimal") { + *v = coerce("DECIMAL", v); + } + } + } + Ok((columns.into_iter().map(|c| c.name).collect(), rows)) } } @@ -311,6 +319,45 @@ mod tests { assert!(!out.truncated); } + /// Trino 的 REST 协议把 decimal 写成字符串(`SqlDecimal` 走 `writeString`), + /// 别的数是 JSON 数 + #[tokio::test] + async fn a_decimal_reads_as_a_number() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/statement")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "q3", + "columns": [ + { "name": "region", "type": "varchar(5)" }, + { "name": "total", "type": "decimal(12,2)" }, + { "name": "orders", "type": "bigint" } + ], + "data": [ ["east", "1234.50", 7], ["west", null, 0] ], + "stats": { "state": "FINISHED" } + }))) + .mount(&server) + .await; + let conn = TrinoConn::parse(&format!( + "trino://alice@{}/hive?ssl=false", + server.uri().trim_start_matches("http://") + )) + .unwrap(); + let out = TrinoEngine::new(conn) + .execute( + "SELECT region, SUM(amount) AS total, COUNT(*) AS orders FROM orders GROUP BY 1", + ) + .await + .unwrap(); + assert_eq!( + out.rows, + vec![ + r#"{"region":"east","total":1234.5,"orders":7}"#, + r#"{"region":"west","total":null,"orders":0}"# + ] + ); + } + #[tokio::test] async fn a_trino_error_page_becomes_an_error() { let server = MockServer::start().await; From bc53d8b1520a9700f75b26aa937c36dc8309322e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Tue, 27 Oct 2026 00:15:59 -0400 Subject: [PATCH 2/2] Record Trino decimal restoration and its exact-value dependency Signed-off-by: Rudy Celekli --- docs/decisions/0018-the-lakehouse-is-one-protocol-away.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0018-the-lakehouse-is-one-protocol-away.md b/docs/decisions/0018-the-lakehouse-is-one-protocol-away.md index 59e240e7d..65dbe3598 100644 --- a/docs/decisions/0018-the-lakehouse-is-one-protocol-away.md +++ b/docs/decisions/0018-the-lakehouse-is-one-protocol-away.md @@ -37,7 +37,7 @@ Left out on purpose: Snowflake key-pair JWT (local RSA signing, a dependency for 0011 set up defense in depth: parse and admit only SELECT, wrap a LIMIT, a read-only session with a timeout, JSON Lines out. The HTTP family **has no session**, so the third gate is a timeout alone (Trino's `query_max_execution_time` session property, Databricks' `wait_timeout`, Snowflake's `timeout`), and read-only rests entirely on the first gate. The first gate therefore parses with each engine's dialect: `DatabricksDialect`, `SnowflakeDialect`, and `GenericDialect` for Trino (sqlparser has no Trino dialect; Generic is a superset). One test runs the same three checks under all four dialects: SELECT passes, DELETE fails, two statements fail. -The fourth gate is assembled here for the HTTP family. Databricks' `JSON_ARRAY` and Snowflake's `data` return every value as a string; `coerce` restores numbers and booleans from the manifest / rowType column types, otherwise a model handed `"42"` stops doing arithmetic. Key order is assembled by hand instead of through `serde_json::Map`, which sorts keys unless `preserve_order` is on, and column order is the order the query wrote. +The fourth gate is assembled here for the HTTP family. Databricks' `JSON_ARRAY` and Snowflake's `data` return every value as a string; `coerce` restores numbers and booleans from the manifest / rowType column types, otherwise a model handed `"42"` stops doing arithmetic. Trino decimal cells use that same `coerce` path, so short decimals reach JSON Lines as numbers; once the long-exact-value guard in #1413 is integrated, decimals beyond its f64 precision boundary remain strings rather than rounded values. Key order is assembled by hand instead of through `serde_json::Map`, which sorts keys unless `preserve_order` is on, and column order is the order the query wrote. ## Loopback goes direct