diff --git a/docs/json-support.md b/docs/json-support.md index c2bec56..c38a941 100644 --- a/docs/json-support.md +++ b/docs/json-support.md @@ -169,8 +169,8 @@ SELECT to_json(123); -- Returns: 123 SELECT to_json(NULL); -- Returns: null ``` -#### json_build_object(variadic) -Builds a JSON object from key-value pairs. +#### json_build_object(variadic) / jsonb_build_object(variadic) +Builds a JSON object from key-value pairs. `jsonb_build_object` is an alias. ```sql SELECT json_build_object('name', 'John', 'age', 30); diff --git a/src/functions/json_functions.rs b/src/functions/json_functions.rs index 5f8b86b..fa99765 100644 --- a/src/functions/json_functions.rs +++ b/src/functions/json_functions.rs @@ -1,6 +1,54 @@ use rusqlite::{Connection, Result, functions::FunctionFlags, types::ValueRef}; use serde_json::Value as JsonValue; +/// Render a SQLite value as a JSON fragment (text as a string, blobs as hex). +fn value_ref_to_json(value: ValueRef<'_>) -> String { + match value { + ValueRef::Null => "null".to_string(), + ValueRef::Integer(i) => i.to_string(), + ValueRef::Real(f) => f.to_string(), + ValueRef::Text(s) => serde_json::to_string(&String::from_utf8_lossy(s)).unwrap(), + ValueRef::Blob(b) => serde_json::to_string(&format!("\\x{}", hex::encode(b))).unwrap(), + } +} + +/// Render a SQLite value as a JSON object key string (escaped by the caller). +fn value_ref_to_key(value: ValueRef<'_>) -> String { + match value { + ValueRef::Null => "null".to_string(), + ValueRef::Integer(i) => i.to_string(), + ValueRef::Real(f) => f.to_string(), + ValueRef::Text(s) => String::from_utf8_lossy(s).into_owned(), + ValueRef::Blob(b) => format!("\\x{}", hex::encode(b)), + } +} + +/// Shared by `json_build_object` and `jsonb_build_object`; emits pairs in +/// argument order, preserving duplicate keys. +fn json_build_object_impl(ctx: &rusqlite::functions::Context<'_>) -> Result { + let len = ctx.len(); + if !len.is_multiple_of(2) { + return Err(rusqlite::Error::UserFunctionError( + "argument list must have even number of elements".into(), + )); + } + + let mut out = String::from("{"); + let mut i = 0; + while i < len { + if i > 0 { + out.push_str(", "); + } + let key = value_ref_to_key(ctx.get_raw(i)); + out.push_str(&serde_json::to_string(&key).unwrap()); + out.push_str(": "); + out.push_str(&value_ref_to_json(ctx.get_raw(i + 1))); + i += 2; + } + out.push('}'); + Ok(out) +} + /// Register JSON/JSONB-related functions in SQLite pub fn register_json_functions(conn: &Connection) -> Result<()> { // json_valid(text) - Validate JSON (SQLite built-in, but we override for consistency) @@ -128,28 +176,20 @@ pub fn register_json_functions(conn: &Connection) -> Result<()> { }, )?; - // json_build_object(variadic) - Build JSON object from key-value pairs - // For simplicity, we'll implement a 2-argument version + // json_build_object(variadic "any") - Build JSON object from key-value pairs + // jsonb_build_object is registered as an alias. conn.create_scalar_function( "json_build_object", - 2, + -1, FunctionFlags::SQLITE_UTF8, - |ctx| { - let key: String = ctx.get(0)?; - let value: String = match ctx.get_raw(1) { - ValueRef::Null => "null".to_string(), - ValueRef::Integer(i) => i.to_string(), - ValueRef::Real(f) => f.to_string(), - ValueRef::Text(s) => serde_json::to_string(&s).unwrap(), - ValueRef::Blob(b) => { - // Convert blob to hex string for JSON - let hex = b.iter().map(|byte| format!("{byte:02x}")).collect::(); - serde_json::to_string(&hex).unwrap() - }, - }; - - Ok(format!("{{\"{key}\": {value}}}")) - }, + json_build_object_impl, + )?; + + conn.create_scalar_function( + "jsonb_build_object", + -1, + FunctionFlags::SQLITE_UTF8, + json_build_object_impl, )?; // json_extract_scalar(json, path) - Extract scalar value from JSON path @@ -1685,6 +1725,89 @@ mod tests { assert!(!not_contains); } + #[test] + fn test_json_build_object() { + let conn = Connection::open_in_memory().unwrap(); + register_json_functions(&conn).unwrap(); + + fn build(conn: &Connection, sql: &str) -> JsonValue { + let raw: String = conn.query_row(sql, [], |row| row.get(0)).unwrap(); + serde_json::from_str(&raw).unwrap() + } + + // Text values must serialise as JSON strings, not arrays of byte values + assert_eq!( + build(&conn, "SELECT json_build_object('d', 'abc')"), + serde_json::json!({"d": "abc"}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('d', '')"), + serde_json::json!({"d": ""}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('d', 'a\"b')"), + serde_json::json!({"d": "a\"b"}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('d', 'a\\b')"), + serde_json::json!({"d": "a\\b"}) + ); + + // Non-text scalars + assert_eq!( + build(&conn, "SELECT json_build_object('id', 1)"), + serde_json::json!({"id": 1}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('n', 1.5)"), + serde_json::json!({"n": 1.5}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('v', NULL)"), + serde_json::json!({"v": null}) + ); + + // Keys containing quotes or backslashes must produce valid JSON + assert_eq!( + build(&conn, "SELECT json_build_object('a\"b', 1)"), + serde_json::json!({"a\"b": 1}) + ); + assert_eq!( + build(&conn, "SELECT json_build_object('a\\b', 1)"), + serde_json::json!({"a\\b": 1}) + ); + + // Variadic pairs are emitted in order + assert_eq!( + build(&conn, "SELECT json_build_object('id', 1, 'name', 'x')"), + serde_json::json!({"id": 1, "name": "x"}) + ); + + // Duplicate keys are preserved (matches PostgreSQL json semantics) + let dup: String = conn + .query_row("SELECT json_build_object('k', 1, 'k', 2)", [], |row| row.get(0)) + .unwrap(); + assert_eq!(dup, "{\"k\": 1, \"k\": 2}"); + + // Zero arguments yields an empty object + let empty: String = conn + .query_row("SELECT json_build_object()", [], |row| row.get(0)) + .unwrap(); + assert_eq!(empty, "{}"); + + // jsonb_build_object is an alias + assert_eq!( + build(&conn, "SELECT jsonb_build_object('d', 'abc')"), + serde_json::json!({"d": "abc"}) + ); + + // Odd argument count is rejected + assert!( + conn.query_row::("SELECT json_build_object('k')", [], |row| row.get(0)) + .is_err() + ); + } + #[test] fn test_custom_json_path_functions() { let conn = Connection::open_in_memory().unwrap(); diff --git a/tests/json_build_object_test.rs b/tests/json_build_object_test.rs new file mode 100644 index 0000000..d8b9adb --- /dev/null +++ b/tests/json_build_object_test.rs @@ -0,0 +1,143 @@ +use tokio::net::TcpListener; +use tokio_postgres::{Client, NoTls}; + +async fn start_test_server() -> (Client, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server_handle = tokio::spawn(async move { + let db_handler = std::sync::Arc::new(pgsqlite::session::DbHandler::new(":memory:").unwrap()); + let (stream, addr) = listener.accept().await.unwrap(); + pgsqlite::handle_test_connection_with_pool(stream, addr, db_handler) + .await + .unwrap(); + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let (client, connection) = tokio_postgres::connect( + &format!("host=localhost port={port} dbname=test user=testuser"), + NoTls, + ) + .await + .unwrap(); + + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("Connection error: {e}"); + } + }); + + (client, server_handle) +} + +/// Run a query over the extended protocol and decode the JSON-typed result. +async fn json_scalar(client: &Client, sql: &str) -> serde_json::Value { + let rows = client.query(sql, &[]).await.unwrap(); + rows[0].get(0) +} + +/// Run a query over the simple-query protocol and return the raw text value. +async fn raw_scalar(client: &Client, sql: &str) -> String { + let messages = client.simple_query(sql).await.unwrap(); + messages + .iter() + .find_map(|msg| match msg { + tokio_postgres::SimpleQueryMessage::Row(row) => row.get(0).map(str::to_string), + _ => None, + }) + .expect("expected a row") +} + +#[tokio::test] +async fn test_json_build_object_text_values() { + let (client, server_handle) = start_test_server().await; + + // Issue #110: text must serialise as a JSON string, not an array of byte values + assert_eq!( + json_scalar(&client, "SELECT json_build_object('d', 'abc')").await, + serde_json::json!({"d": "abc"}) + ); + assert_eq!( + json_scalar(&client, "SELECT json_build_object('d', '')").await, + serde_json::json!({"d": ""}) + ); + assert_eq!( + json_scalar(&client, "SELECT json_build_object('d', 'a\"b')").await, + serde_json::json!({"d": "a\"b"}) + ); + + // Non-text scalars still round-trip + assert_eq!( + json_scalar(&client, "SELECT json_build_object('id', 1)").await, + serde_json::json!({"id": 1}) + ); + + server_handle.abort(); +} + +#[tokio::test] +async fn test_json_build_object_key_escaping() { + let (client, server_handle) = start_test_server().await; + + // Issue #110 secondary: keys containing quotes or backslashes must be escaped + assert_eq!( + json_scalar(&client, "SELECT json_build_object('a\"b', 1)").await, + serde_json::json!({"a\"b": 1}) + ); + assert_eq!( + json_scalar(&client, "SELECT json_build_object('a\\b', 1)").await, + serde_json::json!({"a\\b": 1}) + ); + + server_handle.abort(); +} + +#[tokio::test] +async fn test_json_build_object_variadic() { + let (client, server_handle) = start_test_server().await; + + assert_eq!( + json_scalar(&client, "SELECT json_build_object('id', 1, 'name', 'x')").await, + serde_json::json!({"id": 1, "name": "x"}) + ); + + // Duplicate keys are preserved + assert_eq!( + raw_scalar(&client, "SELECT json_build_object('k', 1, 'k', 2)").await, + "{\"k\": 1, \"k\": 2}" + ); + + // Zero arguments yields an empty object + assert_eq!( + json_scalar(&client, "SELECT json_build_object()").await, + serde_json::json!({}) + ); + + // jsonb_build_object is an alias + assert_eq!( + json_scalar(&client, "SELECT jsonb_build_object('d', 'abc')").await, + serde_json::json!({"d": "abc"}) + ); + + // Odd argument count is rejected + assert!( + client + .query("SELECT json_build_object('k')", &[]) + .await + .is_err() + ); + + server_handle.abort(); +} + +#[tokio::test] +async fn test_json_build_object_simple_query_protocol() { + let (client, server_handle) = start_test_server().await; + + let raw = raw_scalar(&client, "SELECT json_build_object('id', 1, 'name', 'x')").await; + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed, serde_json::json!({"id": 1, "name": "x"})); + + server_handle.abort(); +} diff --git a/tests/sql/features/test_json_functions.sql b/tests/sql/features/test_json_functions.sql index 82f23aa..fcc9037 100644 --- a/tests/sql/features/test_json_functions.sql +++ b/tests/sql/features/test_json_functions.sql @@ -62,12 +62,16 @@ SELECT to_json(NULL) AS null_to_json; SELECT to_jsonb(true) AS bool_to_jsonb; SELECT to_jsonb(3.14159) AS float_to_jsonb; --- Test json_build_object function (2-argument version) +-- Test json_build_object function (variadic) SELECT 'json_build_object tests:' AS test_group; SELECT json_build_object('name', 'John') AS simple_object; SELECT json_build_object('age', 30) AS number_value; SELECT json_build_object('active', true) AS bool_value; SELECT json_build_object('data', NULL) AS null_value; +SELECT json_build_object('id', 1, 'name', 'John') AS multi_pair_object; +SELECT jsonb_build_object('id', 1, 'name', 'John') AS jsonb_object; +SELECT json_build_object() AS empty_object; +SELECT json_build_object('a"b', 1) AS escaped_key_object; -- Test json_extract_path and json_extract_path_text functions SELECT 'json_extract_path tests:' AS test_group;