use serde_json::Value;
use crate::{
core::ConversationMetadata,
hooks::ExtraColumns,
schema::{SchemaConfig, TableConfig},
};
pub(super) const RESPONSE_COLUMNS: &[&str] = &[
"id",
"conversation_id",
"previous_response_id",
"input",
"created_at",
"safety_identifier",
"model",
"raw_response",
];
pub(super) fn build_response_select_base(schema: &SchemaConfig) -> String {
let s = &schema.responses;
let table = s.qualified_table(schema.owner.as_deref());
let cols: Vec<&str> = RESPONSE_COLUMNS
.iter()
.filter(|&&logical| !s.is_skipped(logical))
.map(|&logical| s.col(logical))
.collect();
format!("SELECT {} FROM {table}", cols.join(", "))
}
pub(super) fn extra_column_defs(tc: &TableConfig) -> Vec<String> {
let names = sorted_extra_column_names(tc);
names
.iter()
.filter_map(|name| {
tc.extra_columns
.get(*name)
.map(|def| format!("{name} {}", def.sql_type))
})
.collect()
}
pub(super) fn sorted_extra_column_names(tc: &TableConfig) -> Vec<&str> {
let mut names: Vec<&str> = tc.extra_columns.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
pub(super) fn value_to_sql_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub(super) fn resolve_extra_column_values<'a>(
tc: &'a TableConfig,
hook_extra: &ExtraColumns,
) -> Vec<(&'a str, Option<String>)> {
sorted_extra_column_names(tc)
.into_iter()
.map(|name| {
let val = hook_extra
.get(name)
.filter(|v| !v.is_null())
.map(value_to_sql_string)
.or_else(|| {
tc.extra_columns
.get(name)
.and_then(|def| def.default_value.as_ref())
.filter(|v| !v.is_null())
.map(value_to_sql_string)
});
(name, val)
})
.collect()
}
pub(super) fn parse_conversation_metadata(
raw: Option<String>,
) -> Result<Option<ConversationMetadata>, String> {
match raw {
Some(s) if !s.is_empty() => {
let s = s.trim();
if s.is_empty() || s.eq_ignore_ascii_case("null") {
return Ok(None);
}
serde_json::from_str::<ConversationMetadata>(s)
.map(Some)
.map_err(|e| e.to_string())
}
_ => Ok(None),
}
}
fn parse_json_or<F>(raw: Option<String>, default: F) -> Result<Value, String>
where
F: FnOnce() -> Value,
{
raw.filter(|s| !s.is_empty()).map_or_else(
|| Ok(default()),
|s| serde_json::from_str(&s).map_err(|e| e.to_string()),
)
}
pub(super) fn parse_raw_response(raw: Option<String>) -> Result<Value, String> {
parse_json_or(raw, || Value::Null)
}
pub(super) fn parse_json_value(raw: Option<String>) -> Result<Value, String> {
parse_json_or(raw, || Value::Array(Vec::new()))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn parse_raw_response_handles_null() {
assert_eq!(parse_raw_response(None).unwrap(), Value::Null);
}
#[test]
fn parse_raw_response_round_trips() {
let payload = json!({"id": "abc"}).to_string();
let parsed = parse_raw_response(Some(payload)).unwrap();
assert_eq!(parsed["id"], "abc");
}
#[test]
fn parse_conversation_metadata_none_returns_ok_none() {
assert!(parse_conversation_metadata(None).unwrap().is_none());
}
#[test]
fn parse_conversation_metadata_empty_string_returns_ok_none() {
assert!(parse_conversation_metadata(Some(String::new()))
.unwrap()
.is_none());
}
#[test]
fn parse_conversation_metadata_null_string_returns_ok_none() {
assert!(parse_conversation_metadata(Some("null".to_string()))
.unwrap()
.is_none());
assert!(parse_conversation_metadata(Some("NULL".to_string()))
.unwrap()
.is_none());
assert!(parse_conversation_metadata(Some("Null".to_string()))
.unwrap()
.is_none());
}
#[test]
fn parse_conversation_metadata_valid_json_object() {
let payload = json!({"key": "value", "count": 42}).to_string();
let parsed = parse_conversation_metadata(Some(payload))
.unwrap()
.expect("should be Some");
assert_eq!(parsed.get("key").expect("key should exist"), "value");
assert_eq!(
parsed
.get("count")
.expect("count should exist")
.as_i64()
.expect("should be i64"),
42
);
}
#[test]
fn parse_conversation_metadata_invalid_json_returns_err() {
let result = parse_conversation_metadata(Some("not json".to_string()));
assert!(result.is_err());
}
#[test]
fn extra_column_defs_empty_by_default() {
let tc = TableConfig::with_table("t");
assert!(extra_column_defs(&tc).is_empty());
}
#[test]
fn extra_column_defs_generates_sql() {
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: None,
},
);
tc.extra_columns.insert(
"EXPIRES_AT".to_string(),
crate::schema::ColumnDef {
sql_type: "TIMESTAMP".to_string(),
default_value: None,
},
);
let defs = extra_column_defs(&tc);
assert_eq!(defs.len(), 2);
assert_eq!(defs[0], "EXPIRES_AT TIMESTAMP");
assert_eq!(defs[1], "TENANT_ID VARCHAR(128)");
}
#[test]
fn sorted_extra_column_names_returns_sorted() {
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"z_col".to_string(),
crate::schema::ColumnDef {
sql_type: "TEXT".to_string(),
default_value: None,
},
);
tc.extra_columns.insert(
"a_col".to_string(),
crate::schema::ColumnDef {
sql_type: "TEXT".to_string(),
default_value: None,
},
);
let names = sorted_extra_column_names(&tc);
assert_eq!(names, vec!["a_col", "z_col"]);
}
#[test]
fn resolve_extra_values_prefers_hook_over_default() {
use crate::hooks::ExtraColumns;
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: Some(json!("default_tenant")),
},
);
let mut hook = ExtraColumns::new();
hook.insert("TENANT_ID".to_string(), json!("hook_tenant"));
let resolved = resolve_extra_column_values(&tc, &hook);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, "TENANT_ID");
assert_eq!(resolved[0].1, Some("hook_tenant".to_string()));
}
#[test]
fn resolve_extra_values_falls_back_to_default() {
use crate::hooks::ExtraColumns;
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: Some(json!("default_tenant")),
},
);
let hook = ExtraColumns::new(); let resolved = resolve_extra_column_values(&tc, &hook);
assert_eq!(resolved[0].1, Some("default_tenant".to_string()));
}
#[test]
fn resolve_extra_values_returns_none_when_no_value() {
use crate::hooks::ExtraColumns;
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: None,
},
);
let hook = ExtraColumns::new(); let resolved = resolve_extra_column_values(&tc, &hook);
assert_eq!(resolved[0].1, None);
}
#[test]
fn resolve_extra_values_skips_null_hook_values() {
use crate::hooks::ExtraColumns;
let mut tc = TableConfig::with_table("t");
tc.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: Some(json!("fallback")),
},
);
let mut hook = ExtraColumns::new();
hook.insert("TENANT_ID".to_string(), Value::Null);
let resolved = resolve_extra_column_values(&tc, &hook);
assert_eq!(resolved[0].1, Some("fallback".to_string()));
}
#[test]
fn select_base_skips_columns() {
let mut cfg = SchemaConfig::default();
cfg.responses
.skip_columns
.insert("raw_response".to_string());
cfg.responses
.skip_columns
.insert("safety_identifier".to_string());
let sql = build_response_select_base(&cfg);
assert!(!sql.contains("raw_response"));
assert!(!sql.contains("safety_identifier"));
assert!(sql.contains("id")); }
#[test]
fn select_base_excludes_extra_columns() {
let mut cfg = SchemaConfig::default();
cfg.responses.extra_columns.insert(
"tenant_id".to_string(),
crate::schema::ColumnDef {
sql_type: "TEXT".to_string(),
default_value: None,
},
);
let sql = build_response_select_base(&cfg);
assert!(!sql.contains("tenant_id"));
assert!(sql.contains("id")); }
#[test]
fn schema_config_produces_oracle_compatible_sql() {
let mut cfg = SchemaConfig {
owner: Some("ADMIN".to_string()),
..SchemaConfig::default()
};
cfg.uppercase_for_oracle();
let sql = build_response_select_base(&cfg);
assert!(
sql.contains("ADMIN.\"RESPONSES\""),
"missing qualified table: {sql}"
);
for col in RESPONSE_COLUMNS {
assert!(sql.contains(col), "missing standard column '{col}': {sql}");
}
}
#[test]
fn schema_config_adds_tenant_column_without_forking() {
let mut cfg = SchemaConfig {
owner: Some("ADMIN".to_string()),
..SchemaConfig::default()
};
cfg.responses.extra_columns.insert(
"TENANT_ID".to_string(),
crate::schema::ColumnDef {
sql_type: "VARCHAR2(128)".to_string(),
default_value: Some(json!("default_tenant")),
},
);
cfg.uppercase_for_oracle();
let defs = extra_column_defs(&cfg.responses);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0], "TENANT_ID VARCHAR2(128)");
let mut hook_extra = ExtraColumns::new();
hook_extra.insert("TENANT_ID".to_string(), json!("acme-corp"));
let resolved = resolve_extra_column_values(&cfg.responses, &hook_extra);
assert_eq!(resolved[0].0, "TENANT_ID");
assert_eq!(resolved[0].1, Some("acme-corp".to_string()));
let empty_hook = ExtraColumns::new();
let resolved = resolve_extra_column_values(&cfg.responses, &empty_hook);
assert_eq!(resolved[0].1, Some("default_tenant".to_string()));
}
#[test]
fn schema_config_skips_unused_columns_without_forking() {
let mut cfg = SchemaConfig::default();
cfg.responses
.skip_columns
.insert("safety_identifier".to_string());
cfg.responses
.skip_columns
.insert("raw_response".to_string());
let sql = build_response_select_base(&cfg);
assert!(
!sql.contains("safety_identifier"),
"should be skipped: {sql}"
);
assert!(!sql.contains("raw_response"), "should be skipped: {sql}");
assert!(sql.contains("id"), "id should remain: {sql}");
assert!(sql.contains("model"), "model should remain: {sql}");
}
#[test]
fn schema_config_remaps_column_names_without_forking() {
let mut cfg = SchemaConfig::default();
cfg.responses
.columns
.insert("id".to_string(), "resp_id".to_string());
cfg.responses
.columns
.insert("input".to_string(), "user_input".to_string());
let sql = build_response_select_base(&cfg);
assert!(sql.contains("resp_id"), "should use remapped name: {sql}");
assert!(
sql.contains("user_input"),
"should use remapped name: {sql}"
);
}
#[test]
fn skip_columns_and_extra_columns_together() {
use crate::{hooks::ExtraColumns, schema::ColumnDef};
let mut cfg = SchemaConfig::default();
cfg.responses
.skip_columns
.insert("raw_response".to_string());
cfg.responses
.skip_columns
.insert("safety_identifier".to_string());
cfg.responses.extra_columns.insert(
"TENANT_ID".to_string(),
ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: None,
},
);
cfg.responses.extra_columns.insert(
"AUDIT_TS".to_string(),
ColumnDef {
sql_type: "TIMESTAMP".to_string(),
default_value: None,
},
);
let sql = build_response_select_base(&cfg);
assert!(
!sql.contains("raw_response"),
"raw_response should be skipped: {sql}"
);
assert!(
!sql.contains("safety_identifier"),
"safety_identifier should be skipped: {sql}"
);
assert!(
!sql.contains("TENANT_ID"),
"TENANT_ID should not be in SELECT: {sql}"
);
assert!(
!sql.contains("AUDIT_TS"),
"AUDIT_TS should not be in SELECT: {sql}"
);
for col in &["id", "input", "model", "conversation_id"] {
assert!(
sql.contains(col),
"core column '{col}' should remain: {sql}"
);
}
let defs = extra_column_defs(&cfg.responses);
assert_eq!(defs.len(), 2);
assert_eq!(defs[0], "AUDIT_TS TIMESTAMP");
assert_eq!(defs[1], "TENANT_ID VARCHAR(128)");
let mut hook = ExtraColumns::new();
hook.insert("TENANT_ID".to_string(), json!("acme"));
hook.insert("AUDIT_TS".to_string(), json!("2025-01-01T00:00:00Z"));
let resolved = resolve_extra_column_values(&cfg.responses, &hook);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].0, "AUDIT_TS");
assert_eq!(resolved[0].1, Some("2025-01-01T00:00:00Z".to_string()));
assert_eq!(resolved[1].0, "TENANT_ID");
assert_eq!(resolved[1].1, Some("acme".to_string()));
}
#[test]
fn column_remapping_with_skip_and_extra() {
use crate::schema::ColumnDef;
let mut cfg = SchemaConfig::default();
cfg.responses.table = "my_responses".to_string();
cfg.responses
.columns
.insert("id".to_string(), "resp_id".to_string());
cfg.responses
.columns
.insert("input".to_string(), "user_input".to_string());
cfg.responses
.skip_columns
.insert("safety_identifier".to_string());
cfg.responses.extra_columns.insert(
"TENANT_ID".to_string(),
ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: None,
},
);
let sql = build_response_select_base(&cfg);
assert!(
sql.contains("my_responses"),
"should use renamed table: {sql}"
);
assert!(
sql.contains("resp_id"),
"should use remapped 'resp_id': {sql}"
);
assert!(
sql.contains("user_input"),
"should use remapped 'user_input': {sql}"
);
let cols_part = sql
.strip_prefix("SELECT ")
.unwrap()
.split(" FROM ")
.next()
.unwrap();
let col_list: Vec<&str> = cols_part.split(", ").collect();
assert!(
!col_list.contains(&"id"),
"should not contain bare 'id': {col_list:?}"
);
assert!(
!col_list.contains(&"input"),
"should not contain bare 'input': {col_list:?}"
);
assert!(
!sql.contains("safety_identifier"),
"safety_identifier should be skipped: {sql}"
);
let defs = extra_column_defs(&cfg.responses);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0], "TENANT_ID VARCHAR(128)");
}
#[test]
fn extra_columns_in_insert_column_list() {
use crate::{hooks::ExtraColumns, schema::ColumnDef};
let mut cfg = SchemaConfig::default();
cfg.responses
.columns
.insert("id".to_string(), "resp_id".to_string());
cfg.responses
.skip_columns
.insert("raw_response".to_string());
cfg.responses.extra_columns.insert(
"TENANT_ID".to_string(),
ColumnDef {
sql_type: "VARCHAR(128)".to_string(),
default_value: Some(json!("unknown")),
},
);
let s = &cfg.responses;
let mut col_names: Vec<&str> = Vec::new();
for &logical in RESPONSE_COLUMNS {
if !s.is_skipped(logical) {
col_names.push(s.col(logical));
}
}
let hook_extra = ExtraColumns::new(); let extra = resolve_extra_column_values(s, &hook_extra);
for (name, _val) in &extra {
col_names.push(name);
}
assert!(
col_names.contains(&"resp_id"),
"should contain remapped 'resp_id': {col_names:?}"
);
assert!(
!col_names.contains(&"id"),
"should not contain original 'id': {col_names:?}"
);
assert!(
!col_names.contains(&"raw_response"),
"should not contain skipped 'raw_response': {col_names:?}"
);
assert!(
col_names.contains(&"TENANT_ID"),
"should contain extra 'TENANT_ID': {col_names:?}"
);
assert_eq!(extra.len(), 1);
assert_eq!(extra[0].0, "TENANT_ID");
assert_eq!(extra[0].1, Some("unknown".to_string()));
}
#[test]
fn value_to_sql_string_conversions() {
assert_eq!(value_to_sql_string(&json!("hello")), "hello");
assert_eq!(value_to_sql_string(&json!(42)), "42");
assert_eq!(value_to_sql_string(&json!(true)), "true");
assert_eq!(value_to_sql_string(&json!(null)), "null");
let obj = json!({"key": "value"});
let result = value_to_sql_string(&obj);
assert_eq!(result, r#"{"key":"value"}"#);
}
}