use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum SqlValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
Text(String),
Timestamp(DateTime<Utc>),
Json(serde_json::Value),
Unmapped {
type_name: String,
},
}
#[derive(Debug)]
pub(crate) struct DecodedColumn {
pub name: String,
pub value: SqlValue,
}
pub(crate) type DecodedRow = Vec<DecodedColumn>;
#[derive(Debug, PartialEq)]
pub(crate) enum ReshapeNote {
TimestampConflict { column: String },
}
fn value_to_json(value: &SqlValue) -> Option<serde_json::Value> {
match value {
SqlValue::Null | SqlValue::Unmapped { .. } => None,
SqlValue::Bool(b) => Some((*b).into()),
SqlValue::Int(i) => Some((*i).into()),
SqlValue::Float(f) => serde_json::Number::from_f64(*f)
.map(Into::into)
.or_else(|| Some(f.to_string().into())),
SqlValue::Text(s) => Some(s.clone().into()),
SqlValue::Timestamp(ts) => Some(ts.to_rfc3339().into()),
SqlValue::Json(v) => Some(v.clone()),
}
}
pub(crate) fn reshape_flat(row: &DecodedRow) -> serde_json::Value {
let mut map = serde_json::Map::new();
for col in row {
if let Some(value) = value_to_json(&col.value) {
map.insert(col.name.clone(), value);
}
}
serde_json::Value::Object(map)
}
pub(crate) fn reshape_jsonb(
row: &DecodedRow,
json_field: &str,
timestamp_field: &str,
) -> Result<(serde_json::Value, Vec<ReshapeNote>), String> {
let Some(col) = row.iter().find(|c| c.name == json_field) else {
return Err(format!(
"the result has no '{json_field}' column; check -O json_field= against the table schema"
));
};
let SqlValue::Json(event) = &col.value else {
return Err(format!(
"column '{json_field}' did not decode as JSONB; check -O json_field= against the table schema"
));
};
let mut notes = Vec::new();
let mut event = event.clone();
if let serde_json::Value::Object(map) = &mut event
&& let Some(ts_col) = row.iter().find(|c| c.name == timestamp_field)
{
if map.contains_key(timestamp_field) {
notes.push(ReshapeNote::TimestampConflict {
column: timestamp_field.to_string(),
});
} else if let Some(value) = value_to_json(&ts_col.value) {
map.insert(timestamp_field.to_string(), value);
}
}
Ok((event, notes))
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, value: SqlValue) -> DecodedColumn {
DecodedColumn {
name: name.to_string(),
value,
}
}
fn ts() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-07-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn flat_maps_every_type() {
let row = vec![
col("time", SqlValue::Timestamp(ts())),
col("event_id", SqlValue::Int(42)),
col("severity", SqlValue::Float(2.5)),
col("category", SqlValue::Text("process".to_string())),
col("success", SqlValue::Bool(true)),
col(
"metadata",
SqlValue::Json(serde_json::json!({"source": "agent"})),
),
];
let event = reshape_flat(&row);
assert_eq!(
event,
serde_json::json!({
"time": "2026-07-01T10:00:00+00:00",
"event_id": 42,
"severity": 2.5,
"category": "process",
"success": true,
"metadata": {"source": "agent"},
})
);
}
#[test]
fn flat_drops_null_and_unmapped_columns() {
let row = vec![
col("category", SqlValue::Text("auth".to_string())),
col("dst_ip", SqlValue::Null),
col(
"search_vector",
SqlValue::Unmapped {
type_name: "tsvector".to_string(),
},
),
];
let event = reshape_flat(&row);
assert_eq!(event, serde_json::json!({"category": "auth"}));
}
#[test]
fn flat_renders_non_finite_floats_as_text() {
let row = vec![col("score", SqlValue::Float(f64::NAN))];
let event = reshape_flat(&row);
assert_eq!(event, serde_json::json!({"score": "NaN"}));
}
#[test]
fn jsonb_emits_verbatim_and_merges_timestamp() {
let row = vec![
col("time", SqlValue::Timestamp(ts())),
col(
"data",
SqlValue::Json(serde_json::json!({"Image": "/usr/bin/curl"})),
),
];
let (event, notes) = reshape_jsonb(&row, "data", "time").unwrap();
assert!(notes.is_empty());
assert_eq!(
event,
serde_json::json!({"Image": "/usr/bin/curl", "time": "2026-07-01T10:00:00+00:00"})
);
}
#[test]
fn jsonb_body_wins_on_timestamp_conflict() {
let row = vec![
col("time", SqlValue::Timestamp(ts())),
col(
"data",
SqlValue::Json(serde_json::json!({"time": "1999-01-01T00:00:00Z"})),
),
];
let (event, notes) = reshape_jsonb(&row, "data", "time").unwrap();
assert_eq!(event, serde_json::json!({"time": "1999-01-01T00:00:00Z"}));
assert_eq!(
notes,
vec![ReshapeNote::TimestampConflict {
column: "time".to_string()
}]
);
}
#[test]
fn jsonb_missing_column_is_a_pointed_error() {
let row = vec![col("time", SqlValue::Timestamp(ts()))];
let err = reshape_jsonb(&row, "data", "time").unwrap_err();
assert!(err.contains("no 'data' column"), "{err}");
}
#[test]
fn jsonb_non_json_column_is_a_pointed_error() {
let row = vec![col("data", SqlValue::Text("not json".to_string()))];
let err = reshape_jsonb(&row, "data", "time").unwrap_err();
assert!(err.contains("did not decode as JSONB"), "{err}");
}
#[test]
fn jsonb_non_object_event_is_emitted_as_is() {
let row = vec![
col("time", SqlValue::Timestamp(ts())),
col("data", SqlValue::Json(serde_json::json!(["a", "b"]))),
];
let (event, notes) = reshape_jsonb(&row, "data", "time").unwrap();
assert!(notes.is_empty());
assert_eq!(event, serde_json::json!(["a", "b"]));
}
fn golden_path(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(name)
}
fn canonical(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
serde_json::Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical(&map[key])))
.collect(),
)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(canonical).collect())
}
other => other.clone(),
}
}
fn check_golden(name: &str, actual: &str) {
let path = golden_path(name);
if std::env::var_os("RSIGMA_UPDATE_GOLDEN").is_some() {
std::fs::write(&path, actual)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
return;
}
let expected = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
.replace("\r\n", "\n");
assert_eq!(actual, expected, "hunt golden drifted for '{name}'");
}
#[test]
fn golden_flat_ndjson() {
let rows = vec![
vec![
col("time", SqlValue::Timestamp(ts())),
col("event_id", SqlValue::Int(9001)),
col("category", SqlValue::Text("process".to_string())),
col("src_ip", SqlValue::Text("10.0.0.8".to_string())),
col("dst_ip", SqlValue::Null),
col("success", SqlValue::Bool(false)),
col(
"search_vector",
SqlValue::Unmapped {
type_name: "tsvector".to_string(),
},
),
],
vec![
col("time", SqlValue::Timestamp(ts())),
col("event_id", SqlValue::Int(9002)),
col("category", SqlValue::Text("authentication".to_string())),
col("metadata", SqlValue::Json(serde_json::json!({"mfa": true}))),
],
];
let mut actual = String::new();
for row in &rows {
let event = canonical(&reshape_flat(row));
actual.push_str(&serde_json::to_string(&event).unwrap());
actual.push('\n');
}
check_golden("hunt_events_flat.ndjson", &actual);
}
#[test]
fn golden_jsonb_ndjson() {
let rows = vec![vec![
col("time", SqlValue::Timestamp(ts())),
col(
"data",
SqlValue::Json(serde_json::json!({
"Image": "/usr/bin/curl",
"CommandLine": "curl --insecure https://example.invalid",
})),
),
]];
let mut actual = String::new();
for row in &rows {
let (event, notes) = reshape_jsonb(row, "data", "time").unwrap();
assert!(notes.is_empty());
actual.push_str(&serde_json::to_string(&canonical(&event)).unwrap());
actual.push('\n');
}
check_golden("hunt_events_jsonb.ndjson", &actual);
}
}