Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 13 additions & 17 deletions src/activities/execute_sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
//! |----------------------|--------------------------------------|
//! | bool | JSON boolean |
//! | int2/int4/int8 | JSON integer |
//! | float4/float8 | JSON number (NaN/Inf → error) |
//! | float4/float8 | JSON number (NaN/Inf → null) |
//! | text/varchar/bpchar | JSON string |
//! | numeric/decimal | JSON string (exact, preserves scale) |
//! | uuid | JSON string (canonical) |
Expand Down Expand Up @@ -60,8 +60,8 @@ pub struct ExecuteSqlInput {
/// Decode a single column value from a PostgreSQL row into a `serde_json::Value`.
///
/// Dispatches based on the column's declared PostgreSQL type name. Returns an
/// error for unsupported types or non-finite float values rather than silently
/// producing `null`.
/// error for unsupported types. Non-finite float values (NaN / ±Infinity) are
/// represented as JSON `null` since JSON has no representation for them.
fn decode_column(
row: &sqlx::postgres::PgRow,
col: &sqlx::postgres::PgColumn,
Expand Down Expand Up @@ -93,14 +93,12 @@ fn decode_column(
"FLOAT4" => match row.try_get::<Option<f32>, _>(col_name) {
Ok(Some(v)) => {
if v.is_nan() || v.is_infinite() {
Err(format!(
"FLOAT4 column '{col_name}' contains non-finite value (NaN or Inf)"
))
} else if let Some(n) = serde_json::Number::from_f64(v as f64) {
Ok(serde_json::Value::Number(n))
Ok(serde_json::Value::Null)
} else {
Err(format!(
"FLOAT4 column '{col_name}': value cannot be represented as JSON number"
Ok(serde_json::Value::Number(
serde_json::Number::from_f64(v as f64).unwrap_or_else(|| {
panic!("finite f32 {v} must convert to JSON number")
}),
))
}
}
Expand All @@ -110,14 +108,12 @@ fn decode_column(
"FLOAT8" => match row.try_get::<Option<f64>, _>(col_name) {
Ok(Some(v)) => {
if v.is_nan() || v.is_infinite() {
Err(format!(
"FLOAT8 column '{col_name}' contains non-finite value (NaN or Inf)"
))
} else if let Some(n) = serde_json::Number::from_f64(v) {
Ok(serde_json::Value::Number(n))
Ok(serde_json::Value::Null)
} else {
Err(format!(
"FLOAT8 column '{col_name}': value cannot be represented as JSON number"
Ok(serde_json::Value::Number(
serde_json::Number::from_f64(v).unwrap_or_else(|| {
panic!("finite f64 {v} must convert to JSON number")
}),
))
}
}
Expand Down
33 changes: 22 additions & 11 deletions tests/e2e/sql/21_typed_results.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
-- Tests: Typed column result serialization (numeric, uuid, timestamptz, timestamp, date, jsonb,
-- int2, float4, float8, NULL handling, unsupported-type error, NaN/Inf error).
-- int2, float4, float8, NULL handling, unsupported-type error, NaN/Inf → null).
--
-- Each SELECT query runs through execute_sql, which must encode each PostgreSQL type
-- into the result JSON correctly. Tests verify the encoded JSON by reading the
Expand Down Expand Up @@ -442,36 +442,47 @@ END $$;
DROP TABLE _t10;

-- ===========================================================================
-- Test 11: NaN float → workflow fails loudly
-- Test 11: NaN / Infinity float → JSON null (column present, value null)
-- ===========================================================================

CREATE TEMP TABLE _t11 (instance_id TEXT);
INSERT INTO _t11 SELECT df.start(
$$SELECT 'NaN'::float8 AS v$$,
df.as($$SELECT 'NaN'::float8 AS nan_col, 'Infinity'::float8 AS inf_col, 1 AS normal$$, 'r'),
'test-typed-nan'
);

DO $$
DECLARE
inst_id TEXT;
wf_status TEXT;
node_error TEXT;
raw_res JSONB;
row0 JSONB;
BEGIN
SELECT instance_id INTO inst_id FROM _t11;
SELECT df.wait_for_completion(inst_id) INTO wf_status;

IF wf_status != 'failed' THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: expected failed, got %', wf_status;
IF wf_status != 'completed' THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: expected completed, got %', wf_status;
END IF;

SELECT error INTO node_error FROM df.nodes
WHERE instance_id = inst_id AND df.nodes.status = 'failed' LIMIT 1;
SELECT result INTO raw_res FROM df.nodes
WHERE instance_id = inst_id AND result_name = 'r' AND node_type = 'SQL';

row0 := raw_res->'rows'->0;

IF node_error NOT LIKE '%non-finite%' AND node_error NOT LIKE '%NaN%' AND node_error NOT LIKE '%Inf%' THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: expected NaN/Inf error, got: %', node_error;
-- nan_col and inf_col must be present in the row (key exists) with a JSON null value
IF NOT (row0 ? 'nan_col' AND row0->'nan_col' = 'null'::jsonb) THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: NaN not stored as null. row=%', row0;
END IF;
IF NOT (row0 ? 'inf_col' AND row0->'inf_col' = 'null'::jsonb) THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: Infinity not stored as null. row=%', row0;
END IF;
-- The finite column must still be a number
IF jsonb_typeof(row0->'normal') != 'number' THEN
RAISE EXCEPTION 'TEST FAILED [typed-nan]: finite column not a number. row=%', row0;
END IF;

RAISE NOTICE 'PASSED: NaN float → explicit error';
RAISE NOTICE 'PASSED: NaN/Infinity float → JSON null';
END $$;

DROP TABLE _t11;
Expand Down
Loading