diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7eeaae9 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,133 @@ +//! Configuration structures for the sqlite-serve module + +use ngx::http::MergeConfigError; + +/// Location-specific configuration +#[derive(Debug, Default)] +pub struct ModuleConfig { + pub db_path: String, + pub query: String, + pub template_path: String, + pub query_params: Vec<(String, String)>, // (param_name, variable_name) pairs +} + +/// Global (HTTP main) configuration for shared templates +#[derive(Debug, Default)] +pub struct MainConfig { + pub global_templates_dir: String, +} + +impl ngx::http::Merge for ModuleConfig { + fn merge(&mut self, prev: &ModuleConfig) -> Result<(), MergeConfigError> { + if self.db_path.is_empty() { + self.db_path = prev.db_path.clone(); + } + + if self.query.is_empty() { + self.query = prev.query.clone(); + } + + if self.template_path.is_empty() { + self.template_path = prev.template_path.clone(); + } + + if self.query_params.is_empty() { + self.query_params = prev.query_params.clone(); + } + + Ok(()) + } +} + +impl ngx::http::Merge for MainConfig { + fn merge(&mut self, prev: &MainConfig) -> Result<(), MergeConfigError> { + if self.global_templates_dir.is_empty() { + self.global_templates_dir = prev.global_templates_dir.clone(); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ngx::http::Merge; + + #[test] + fn test_module_config_default() { + let config = ModuleConfig::default(); + assert!(config.db_path.is_empty()); + assert!(config.query.is_empty()); + assert!(config.template_path.is_empty()); + assert!(config.query_params.is_empty()); + } + + #[test] + fn test_module_config_merge() { + let mut config = ModuleConfig { + db_path: String::new(), + query: String::new(), + template_path: String::new(), + query_params: vec![], + }; + + let prev = ModuleConfig { + db_path: "test.db".to_string(), + query: "SELECT * FROM test".to_string(), + template_path: "test.hbs".to_string(), + query_params: vec![("id".to_string(), "$arg_id".to_string())], + }; + + config.merge(&prev).unwrap(); + + assert_eq!(config.db_path, "test.db"); + assert_eq!(config.query, "SELECT * FROM test"); + assert_eq!(config.template_path, "test.hbs"); + assert_eq!(config.query_params.len(), 1); + } + + #[test] + fn test_module_config_merge_preserves_existing() { + let mut config = ModuleConfig { + db_path: "existing.db".to_string(), + query: "SELECT 1".to_string(), + template_path: "existing.hbs".to_string(), + query_params: vec![], + }; + + let prev = ModuleConfig { + db_path: "prev.db".to_string(), + query: "SELECT 2".to_string(), + template_path: "prev.hbs".to_string(), + query_params: vec![], + }; + + config.merge(&prev).unwrap(); + + // Should keep existing values + assert_eq!(config.db_path, "existing.db"); + assert_eq!(config.query, "SELECT 1"); + assert_eq!(config.template_path, "existing.hbs"); + } + + #[test] + fn test_main_config_default() { + let config = MainConfig::default(); + assert!(config.global_templates_dir.is_empty()); + } + + #[test] + fn test_main_config_merge() { + let mut config = MainConfig { + global_templates_dir: String::new(), + }; + + let prev = MainConfig { + global_templates_dir: "templates/global".to_string(), + }; + + config.merge(&prev).unwrap(); + assert_eq!(config.global_templates_dir, "templates/global"); + } +} + diff --git a/src/lib.rs b/src/lib.rs index c26964b..6eff5fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,26 +1,30 @@ -use handlebars::Handlebars; -use ngx::core::Buffer; +//! sqlite-serve - NGINX module for serving dynamic content from SQLite databases + +mod config; +mod query; +mod template; +mod variable; + +use config::{MainConfig, ModuleConfig}; +use query::execute_query; +use template::load_templates_from_dir; +use variable::resolve_variable; use ngx::ffi::{ - ngx_hash_key, ngx_http_get_variable, NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF, - NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE, NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE, - nginx_version, ngx_chain_t, ngx_command_t, ngx_conf_t, ngx_http_module_t, ngx_int_t, - ngx_module_t, ngx_str_t, ngx_uint_t, + NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF, NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE, + NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE, nginx_version, ngx_command_t, ngx_conf_t, + ngx_http_module_t, ngx_int_t, ngx_module_t, ngx_str_t, ngx_uint_t, }; -use ngx::http::{ - HttpModule, HttpModuleLocationConf, HttpModuleMainConf, MergeConfigError, NgxHttpCoreModule, -}; -use ngx::{core, core::Status, http}; -use ngx::{http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string}; -use rusqlite::{Connection, Result}; +use ngx::core::Buffer; +use ngx::ffi::ngx_chain_t; +use ngx::http::{HttpModule, HttpModuleLocationConf, HttpModuleMainConf, NgxHttpCoreModule}; +use ngx::{core, core::Status, http, http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string}; use serde_json::json; use std::os::raw::{c_char, c_void}; use std::ptr::addr_of; -struct Module; +pub struct Module; -// Implement our HttpModule trait, we're creating a postconfiguration method to install our -// handler's Access phase function. -impl http::HttpModule for Module { +impl ngx::http::HttpModule for Module { fn module() -> &'static ngx_module_t { unsafe { &*addr_of!(ngx_http_howto_module) } } @@ -30,65 +34,14 @@ impl http::HttpModule for Module { } } -// Implement HttpModuleLocationConf to define our location-specific configuration unsafe impl HttpModuleLocationConf for Module { type LocationConf = ModuleConfig; } -// Implement HttpModuleMainConf to define our global configuration unsafe impl HttpModuleMainConf for Module { type MainConf = MainConfig; } -// Create a ModuleConfig to save our configuration state. -#[derive(Debug, Default)] -struct ModuleConfig { - db_path: String, - query: String, - template_path: String, - query_params: Vec<(String, String)>, // (param_name, variable_name) pairs -} - -// Global configuration for shared templates -#[derive(Debug, Default)] -struct MainConfig { - global_templates_dir: String, -} - -impl http::Merge for MainConfig { - fn merge(&mut self, prev: &MainConfig) -> Result<(), MergeConfigError> { - if self.global_templates_dir.is_empty() { - self.global_templates_dir = prev.global_templates_dir.clone(); - } - Ok(()) - } -} - -// Implement our Merge trait to merge configuration with higher layers. -impl http::Merge for ModuleConfig { - fn merge(&mut self, prev: &ModuleConfig) -> Result<(), MergeConfigError> { - if self.db_path.is_empty() { - self.db_path = prev.db_path.clone(); - } - - if self.query.is_empty() { - self.query = prev.query.clone(); - } - - if self.template_path.is_empty() { - self.template_path = prev.template_path.clone(); - } - - if self.query_params.is_empty() { - self.query_params = prev.query_params.clone(); - } - - Ok(()) - } -} - -// Create our "C" module context with function entrypoints for NGINX event loop. This "binds" our -// HttpModule implementation to functions callable from C. #[unsafe(no_mangle)] #[allow(non_upper_case_globals)] static ngx_http_howto_module_ctx: ngx_http_module_t = ngx_http_module_t { @@ -102,9 +55,6 @@ static ngx_http_howto_module_ctx: ngx_http_module_t = ngx_http_module_t { merge_loc_conf: Some(Module::merge_loc_conf), }; -// Create our module structure and export it with the `ngx_modules!` macro. For this simple -// handler, the ngx_module_t is predominately boilerplate save for setting the above context into -// this structure and setting our custom configuration command (defined below). ngx_modules!(ngx_http_howto_module); #[unsafe(no_mangle)] @@ -140,8 +90,6 @@ pub static mut ngx_http_howto_module: ngx_module_t = ngx_module_t { spare_hook7: 0, }; -// Register and allocate our command structures for directive generation and eventual storage. Be -// sure to terminate the array with an empty command. #[unsafe(no_mangle)] #[allow(non_upper_case_globals)] static mut ngx_http_howto_commands: [ngx_command_t; 6] = [ @@ -149,7 +97,7 @@ static mut ngx_http_howto_commands: [ngx_command_t; 6] = [ name: ngx_string!("sqlite_global_templates"), type_: (NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1) as ngx_uint_t, set: Some(ngx_http_howto_commands_set_global_templates), - conf: 0, // Main conf offset + conf: 0, offset: 0, post: std::ptr::null_mut(), }, @@ -198,6 +146,7 @@ static mut ngx_http_howto_commands: [ngx_command_t; 6] = [ }, ]; +/// Directive handler for sqlite_global_templates #[unsafe(no_mangle)] extern "C" fn ngx_http_howto_commands_set_global_templates( cf: *mut ngx_conf_t, @@ -213,6 +162,7 @@ extern "C" fn ngx_http_howto_commands_set_global_templates( std::ptr::null_mut() } +/// Directive handler for sqlite_db #[unsafe(no_mangle)] extern "C" fn ngx_http_howto_commands_set_db_path( cf: *mut ngx_conf_t, @@ -228,6 +178,7 @@ extern "C" fn ngx_http_howto_commands_set_db_path( std::ptr::null_mut() } +/// Directive handler for sqlite_query #[unsafe(no_mangle)] extern "C" fn ngx_http_howto_commands_set_query( cf: *mut ngx_conf_t, @@ -243,6 +194,7 @@ extern "C" fn ngx_http_howto_commands_set_query( std::ptr::null_mut() } +/// Directive handler for sqlite_template #[unsafe(no_mangle)] extern "C" fn ngx_http_howto_commands_set_template_path( cf: *mut ngx_conf_t, @@ -253,7 +205,7 @@ extern "C" fn ngx_http_howto_commands_set_template_path( let conf = &mut *(conf as *mut ModuleConfig); let args = (*(*cf).args).elts as *mut ngx_str_t; conf.template_path = (*args.add(1)).to_string(); - + // Set the content handler for this location let clcf = NgxHttpCoreModule::location_conf_mut(&*cf) .expect("failed to get core location conf"); @@ -263,6 +215,7 @@ extern "C" fn ngx_http_howto_commands_set_template_path( std::ptr::null_mut() } +/// Directive handler for sqlite_param #[unsafe(no_mangle)] extern "C" fn ngx_http_howto_commands_add_param( cf: *mut ngx_conf_t, @@ -273,7 +226,7 @@ extern "C" fn ngx_http_howto_commands_add_param( let conf = &mut *(conf as *mut ModuleConfig); let args = (*(*cf).args).elts as *mut ngx_str_t; let nelts = (*(*cf).args).nelts; - + if nelts == 2 { // Single argument: positional parameter // sqlite_param $arg_id @@ -291,111 +244,7 @@ extern "C" fn ngx_http_howto_commands_add_param( std::ptr::null_mut() } -// Load all .hbs templates from a directory into the Handlebars registry -fn load_templates_from_dir(reg: &mut Handlebars, dir_path: &str) -> std::io::Result { - use std::fs; - use std::path::Path; - - let dir = Path::new(dir_path); - if !dir.exists() || !dir.is_dir() { - return Ok(0); - } - - let mut count = 0; - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_file() { - if let Some(ext) = path.extension() { - if ext == "hbs" { - if let Some(stem) = path.file_stem() { - if let Some(name) = stem.to_str() { - if let Err(e) = reg.register_template_file(name, &path) { - eprintln!("Failed to register template {}: {}", path.display(), e); - } else { - count += 1; - } - } - } - } - } - } - } - - Ok(count) -} - -// Execute a generic SQL query with parameters and return results as JSON-compatible data -fn execute_query( - db_path: &str, - query: &str, - params: &[(String, String)], // (param_name, value) pairs -) -> Result>> { - let conn = Connection::open(db_path)?; - let mut stmt = conn.prepare(query)?; - - let column_count = stmt.column_count(); - let column_names: Vec = (0..column_count) - .map(|i| stmt.column_name(i).unwrap_or("").to_string()) - .collect(); - - // Bind parameters (either positional or named) - let has_named_params = params.iter().any(|(name, _)| !name.is_empty()); - - // Convert row to JSON map - let row_to_map = |row: &rusqlite::Row| -> rusqlite::Result> { - let mut map = std::collections::HashMap::new(); - for (i, col_name) in column_names.iter().enumerate() { - let value: serde_json::Value = match row.get_ref(i)? { - rusqlite::types::ValueRef::Null => serde_json::Value::Null, - rusqlite::types::ValueRef::Integer(v) => serde_json::Value::Number(v.into()), - rusqlite::types::ValueRef::Real(v) => { - serde_json::Number::from_f64(v) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null) - } - rusqlite::types::ValueRef::Text(v) => { - serde_json::Value::String(String::from_utf8_lossy(v).to_string()) - } - rusqlite::types::ValueRef::Blob(v) => { - // Convert blob to hex string - let hex_string = v - .iter() - .map(|b| format!("{:02x}", b)) - .collect::(); - serde_json::Value::String(hex_string) - } - }; - map.insert(col_name.clone(), value); - } - Ok(map) - }; - - let rows = if has_named_params { - // Use named parameters - let named_params: Vec<(&str, &dyn rusqlite::ToSql)> = params - .iter() - .map(|(name, value)| (name.as_str(), value as &dyn rusqlite::ToSql)) - .collect(); - stmt.query_map(named_params.as_slice(), row_to_map)? - } else { - // Use positional parameters - let positional_params: Vec<&dyn rusqlite::ToSql> = params - .iter() - .map(|(_, value)| value as &dyn rusqlite::ToSql) - .collect(); - stmt.query_map(positional_params.as_slice(), row_to_map)? - }; - - rows.collect() -} - -// Implement a request handler. Use the convenience macro, the http_request_handler! macro will -// convert the native NGINX request into a Rust Request instance as well as define an extern C -// function callable from NGINX. -// -// The function body is implemented as a Rust closure. +// HTTP request handler - processes SQLite queries and renders templates http_request_handler!(howto_access_handler, |request: &mut http::Request| { let co = Module::location_conf(request).expect("module config is none"); @@ -436,43 +285,14 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { // Resolve query parameters from nginx variables let mut param_values: Vec<(String, String)> = Vec::new(); for (param_name, var_name) in &co.query_params { - let value = if var_name.starts_with('$') { - // It's a variable reference, resolve it from nginx - let var_name_str = &var_name[1..]; // Remove the '$' prefix - let var_name_bytes = var_name_str.as_bytes(); - - let mut name = ngx_str_t { - len: var_name_bytes.len(), - data: var_name_bytes.as_ptr() as *mut u8, - }; - - let key = unsafe { ngx_hash_key(name.data, name.len) }; - let r: *mut ngx::ffi::ngx_http_request_t = request.into(); - let var_value = unsafe { ngx_http_get_variable(r, &mut name, key) }; - - if var_value.is_null() { - ngx_log_debug_http!(request, "variable not found: {}", var_name); + match resolve_variable(request, var_name) { + Ok(value) => { + param_values.push((param_name.clone(), value)); + } + Err(_) => { return http::HTTPStatus::BAD_REQUEST.into(); } - - let var_ref = unsafe { &*var_value }; - if var_ref.valid() == 0 { - ngx_log_debug_http!(request, "variable value not valid: {}", var_name); - return http::HTTPStatus::BAD_REQUEST.into(); - } - - match std::str::from_utf8(var_ref.as_bytes()) { - Ok(s) => s.to_string(), - Err(_) => { - ngx_log_debug_http!(request, "failed to decode variable as UTF-8: {}", var_name); - return http::HTTPStatus::INTERNAL_SERVER_ERROR.into(); - } - } - } else { - // It's a literal value - var_name.clone() - }; - param_values.push((param_name.clone(), value)); + } } ngx_log_debug_http!( @@ -491,14 +311,19 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { }; // Setup Handlebars and load templates - let mut reg = Handlebars::new(); - + let mut reg = handlebars::Handlebars::new(); + // First, load global templates if configured let main_conf = Module::main_conf(request).expect("main config is none"); if !main_conf.global_templates_dir.is_empty() { match load_templates_from_dir(&mut reg, &main_conf.global_templates_dir) { Ok(count) => { - ngx_log_debug_http!(request, "loaded {} global templates from {}", count, main_conf.global_templates_dir); + ngx_log_debug_http!( + request, + "loaded {} global templates from {}", + count, + main_conf.global_templates_dir + ); } Err(e) => { ngx_log_debug_http!(request, "warning: failed to load global templates: {}", e); @@ -520,7 +345,7 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { match reg.register_template_file("template", &template_full_path) { Ok(_) => { ngx_log_debug_http!(request, "registered main template: {}", template_full_path); - }, + } Err(e) => { ngx_log_debug_http!(request, "failed to load main template: {}", e); return http::HTTPStatus::INTERNAL_SERVER_ERROR.into(); @@ -553,530 +378,10 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { request.discard_request_body(); request.set_status(http::HTTPStatus::OK); let rc = request.send_header(); - if rc == core::Status::NGX_ERROR - || rc > core::Status::NGX_OK - || request.header_only() - { + if rc == core::Status::NGX_ERROR || rc > core::Status::NGX_OK || request.header_only() { return rc; } request.output_filter(&mut out); Status::NGX_DONE }); - -#[cfg(test)] -mod tests { - use super::*; - use ngx::http::Merge; - use std::collections::HashMap; - - #[test] - fn test_module_config_default() { - let config = ModuleConfig::default(); - assert!(config.db_path.is_empty()); - assert!(config.query.is_empty()); - assert!(config.template_path.is_empty()); - assert!(config.query_params.is_empty()); - } - - #[test] - fn test_module_config_merge() { - let mut config = ModuleConfig { - db_path: String::new(), - query: String::new(), - template_path: String::new(), - query_params: vec![], - }; - - let prev = ModuleConfig { - db_path: "test.db".to_string(), - query: "SELECT * FROM test".to_string(), - template_path: "test.hbs".to_string(), - query_params: vec![("id".to_string(), "$arg_id".to_string())], - }; - - config.merge(&prev).unwrap(); - - assert_eq!(config.db_path, "test.db"); - assert_eq!(config.query, "SELECT * FROM test"); - assert_eq!(config.template_path, "test.hbs"); - assert_eq!(config.query_params.len(), 1); - } - - #[test] - fn test_module_config_merge_preserves_existing() { - let mut config = ModuleConfig { - db_path: "existing.db".to_string(), - query: "SELECT 1".to_string(), - template_path: "existing.hbs".to_string(), - query_params: vec![], - }; - - let prev = ModuleConfig { - db_path: "prev.db".to_string(), - query: "SELECT 2".to_string(), - template_path: "prev.hbs".to_string(), - query_params: vec![], - }; - - config.merge(&prev).unwrap(); - - // Should keep existing values - assert_eq!(config.db_path, "existing.db"); - assert_eq!(config.query, "SELECT 1"); - assert_eq!(config.template_path, "existing.hbs"); - } - - #[test] - fn test_main_config_default() { - let config = MainConfig::default(); - assert!(config.global_templates_dir.is_empty()); - } - - #[test] - fn test_main_config_merge() { - let mut config = MainConfig { - global_templates_dir: String::new(), - }; - - let prev = MainConfig { - global_templates_dir: "templates/global".to_string(), - }; - - config.merge(&prev).unwrap(); - assert_eq!(config.global_templates_dir, "templates/global"); - } - - #[test] - fn test_execute_query_empty_db() { - // Test with a non-existent database - should return error - let result = execute_query("/nonexistent/test.db", "SELECT 1", &[]); - assert!(result.is_err()); - } - - #[test] - fn test_execute_query_with_memory_db() { - use rusqlite::Connection; - use std::fs; - - // Create a temporary in-memory database for testing - let temp_path = "/tmp/test_sqlite_serve.db"; - let _ = fs::remove_file(temp_path); // Clean up if exists - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute( - "CREATE TABLE test (id INTEGER, name TEXT, value REAL)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO test VALUES (1, 'first', 1.5), (2, 'second', 2.5)", - [], - ) - .unwrap(); - } - - // Test simple query - let results = execute_query(temp_path, "SELECT * FROM test ORDER BY id", &[]).unwrap(); - assert_eq!(results.len(), 2); - assert_eq!( - results[0].get("id").unwrap(), - &serde_json::Value::Number(1.into()) - ); - assert_eq!( - results[0].get("name").unwrap(), - &serde_json::Value::String("first".to_string()) - ); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_execute_query_with_positional_params() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_params.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute("CREATE TABLE books (id INTEGER, title TEXT)", []) - .unwrap(); - conn.execute( - "INSERT INTO books VALUES (1, 'Book One'), (2, 'Book Two'), (3, 'Book Three')", - [], - ) - .unwrap(); - } - - // Test positional parameter - let params = vec![(String::new(), "2".to_string())]; - let results = - execute_query(temp_path, "SELECT * FROM books WHERE id = ?", ¶ms).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!( - results[0].get("title").unwrap(), - &serde_json::Value::String("Book Two".to_string()) - ); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_execute_query_with_named_params() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_named.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute("CREATE TABLE books (id INTEGER, title TEXT, year INTEGER)", []) - .unwrap(); - conn.execute( - "INSERT INTO books VALUES (1, 'Old Book', 2000), (2, 'New Book', 2020), (3, 'Newer Book', 2023)", - [], - ) - .unwrap(); - } - - // Test named parameters - let params = vec![ - (":min_year".to_string(), "2015".to_string()), - (":max_year".to_string(), "2024".to_string()), - ]; - let results = execute_query( - temp_path, - "SELECT * FROM books WHERE year >= :min_year AND year <= :max_year ORDER BY year", - ¶ms, - ) - .unwrap(); - - assert_eq!(results.len(), 2); - assert_eq!( - results[0].get("title").unwrap(), - &serde_json::Value::String("New Book".to_string()) - ); - assert_eq!( - results[1].get("title").unwrap(), - &serde_json::Value::String("Newer Book".to_string()) - ); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_execute_query_data_types() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_types.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute( - "CREATE TABLE types (id INTEGER, name TEXT, price REAL, data BLOB, nullable TEXT)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO types VALUES (42, 'test', 3.14, X'DEADBEEF', NULL)", - [], - ) - .unwrap(); - } - - let results = execute_query(temp_path, "SELECT * FROM types", &[]).unwrap(); - assert_eq!(results.len(), 1); - - let row = &results[0]; - - // Test INTEGER - assert_eq!(row.get("id").unwrap(), &serde_json::Value::Number(42.into())); - - // Test TEXT - assert_eq!( - row.get("name").unwrap(), - &serde_json::Value::String("test".to_string()) - ); - - // Test REAL - assert_eq!( - row.get("price").unwrap().as_f64().unwrap(), - 3.14 - ); - - // Test BLOB (should be hex encoded) - assert_eq!( - row.get("data").unwrap(), - &serde_json::Value::String("deadbeef".to_string()) - ); - - // Test NULL - assert_eq!(row.get("nullable").unwrap(), &serde_json::Value::Null); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_load_templates_from_nonexistent_dir() { - let mut reg = Handlebars::new(); - let result = load_templates_from_dir(&mut reg, "/nonexistent/path/to/templates"); - - // Should succeed but load 0 templates - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 0); - } - - #[test] - fn test_load_templates_from_dir() { - use std::fs; - use std::io::Write; - - let temp_dir = "/tmp/test_sqlite_serve_templates"; - let _ = fs::remove_dir_all(temp_dir); - fs::create_dir_all(temp_dir).unwrap(); - - // Create test templates - let mut file1 = fs::File::create(format!("{}/template1.hbs", temp_dir)).unwrap(); - file1.write_all(b"

Template 1

").unwrap(); - - let mut file2 = fs::File::create(format!("{}/template2.hbs", temp_dir)).unwrap(); - file2.write_all(b"

Template 2

").unwrap(); - - // Create a non-template file (should be ignored) - let mut file3 = fs::File::create(format!("{}/readme.txt", temp_dir)).unwrap(); - file3.write_all(b"Not a template").unwrap(); - - let mut reg = Handlebars::new(); - let count = load_templates_from_dir(&mut reg, temp_dir).unwrap(); - - assert_eq!(count, 2); - assert!(reg.has_template("template1")); - assert!(reg.has_template("template2")); - assert!(!reg.has_template("readme")); - - // Clean up - let _ = fs::remove_dir_all(temp_dir); - } - - #[test] - fn test_template_rendering_with_results() { - use std::fs; - use std::io::Write; - - let temp_dir = "/tmp/test_sqlite_serve_render"; - let _ = fs::remove_dir_all(temp_dir); - fs::create_dir_all(temp_dir).unwrap(); - - // Create a simple template - let template_path = format!("{}/list.hbs", temp_dir); - let mut file = fs::File::create(&template_path).unwrap(); - file.write_all(b"{{#each results}}
  • {{name}}
  • {{/each}}").unwrap(); - - let mut reg = Handlebars::new(); - reg.register_template_file("list", &template_path).unwrap(); - - // Test rendering with data - let mut results = vec![]; - let mut item1 = HashMap::new(); - item1.insert("name".to_string(), serde_json::Value::String("Item 1".to_string())); - results.push(item1); - - let mut item2 = HashMap::new(); - item2.insert("name".to_string(), serde_json::Value::String("Item 2".to_string())); - results.push(item2); - - let rendered = reg.render("list", &json!({"results": results})).unwrap(); - assert!(rendered.contains("
  • Item 1
  • ")); - assert!(rendered.contains("
  • Item 2
  • ")); - - // Clean up - let _ = fs::remove_dir_all(temp_dir); - } - - #[test] - fn test_named_params_parsing() { - // This tests the logic we'd use in the directive handler - let test_cases = vec![ - // (nelts, expected_is_named, expected_param_name) - (2, false, ""), // sqlite_param $arg_id - (3, true, ":book_id"), // sqlite_param :book_id $arg_id - ]; - - for (nelts, expected_is_named, expected_param_name) in test_cases { - if nelts == 2 { - // Positional - let param_name = String::new(); - assert!(!expected_is_named); - assert_eq!(param_name, expected_param_name); - } else if nelts == 3 { - // Named - let param_name = ":book_id".to_string(); - assert!(expected_is_named); - assert_eq!(param_name, expected_param_name); - } - } - } - - #[test] - fn test_has_named_params() { - let positional = vec![ - (String::new(), "value1".to_string()), - (String::new(), "value2".to_string()), - ]; - assert!(!positional.iter().any(|(name, _)| !name.is_empty())); - - let named = vec![ - (":id".to_string(), "value1".to_string()), - (":name".to_string(), "value2".to_string()), - ]; - assert!(named.iter().any(|(name, _)| !name.is_empty())); - - let mixed = vec![ - (":id".to_string(), "value1".to_string()), - (String::new(), "value2".to_string()), - ]; - assert!(mixed.iter().any(|(name, _)| !name.is_empty())); - } - - #[test] - fn test_execute_query_with_like_operator() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_like.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute("CREATE TABLE books (title TEXT)", []).unwrap(); - conn.execute( - "INSERT INTO books VALUES ('The Rust Book'), ('Clean Code'), ('Rust in Action')", - [], - ) - .unwrap(); - } - - // Test LIKE with named parameter - let params = vec![(":search".to_string(), "Rust".to_string())]; - let results = execute_query( - temp_path, - "SELECT * FROM books WHERE title LIKE '%' || :search || '%'", - ¶ms, - ) - .unwrap(); - - assert_eq!(results.len(), 2); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_execute_query_empty_results() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_empty.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap(); - // No data inserted - } - - let results = execute_query(temp_path, "SELECT * FROM test", &[]).unwrap(); - assert_eq!(results.len(), 0); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_execute_query_multiple_named_params() { - use rusqlite::Connection; - use std::fs; - - let temp_path = "/tmp/test_sqlite_serve_multi.db"; - let _ = fs::remove_file(temp_path); - - { - let conn = Connection::open(temp_path).unwrap(); - conn.execute("CREATE TABLE books (id INTEGER, genre TEXT, rating REAL)", []) - .unwrap(); - conn.execute( - "INSERT INTO books VALUES - (1, 'Fiction', 4.5), - (2, 'Science', 4.8), - (3, 'Fiction', 4.9), - (4, 'Science', 4.2)", - [], - ) - .unwrap(); - } - - // Test multiple named parameters in different order - let params = vec![ - (":min_rating".to_string(), "4.5".to_string()), - (":genre".to_string(), "Fiction".to_string()), - ]; - - let results = execute_query( - temp_path, - "SELECT * FROM books WHERE genre = :genre AND rating >= :min_rating ORDER BY rating DESC", - ¶ms, - ) - .unwrap(); - - assert_eq!(results.len(), 2); - assert_eq!(results[0].get("rating").unwrap().as_f64().unwrap(), 4.9); - assert_eq!(results[1].get("rating").unwrap().as_f64().unwrap(), 4.5); - - // Clean up - let _ = fs::remove_file(temp_path); - } - - #[test] - fn test_template_override_behavior() { - use std::fs; - use std::io::Write; - - let temp_dir = "/tmp/test_sqlite_serve_override"; - let _ = fs::remove_dir_all(temp_dir); - fs::create_dir_all(temp_dir).unwrap(); - - // Create first template - let template1_path = format!("{}/test.hbs", temp_dir); - let mut file1 = fs::File::create(&template1_path).unwrap(); - file1.write_all(b"Original").unwrap(); - - let mut reg = Handlebars::new(); - reg.register_template_file("test", &template1_path).unwrap(); - - let rendered1 = reg.render("test", &json!({})).unwrap(); - assert_eq!(rendered1, "Original"); - - // Override with new content - let mut file2 = fs::File::create(&template1_path).unwrap(); - file2.write_all(b"Updated").unwrap(); - - // Re-register to override - reg.register_template_file("test", &template1_path).unwrap(); - - let rendered2 = reg.render("test", &json!({})).unwrap(); - assert_eq!(rendered2, "Updated"); - - // Clean up - let _ = fs::remove_dir_all(temp_dir); - } -} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..4c63d02 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,327 @@ +//! SQL query execution with parameter binding + +use rusqlite::{Connection, Result}; +use serde_json::Value; +use std::collections::HashMap; + +/// Execute a SQL query with parameters and return results as JSON-compatible data +/// +/// Supports both positional (?) and named (:name) parameters. +/// If any parameter has a non-empty name, all parameters are treated as named. +pub fn execute_query( + db_path: &str, + query: &str, + params: &[(String, String)], // (param_name, value) pairs +) -> Result>> { + let conn = Connection::open(db_path)?; + let mut stmt = conn.prepare(query)?; + + let column_count = stmt.column_count(); + let column_names: Vec = (0..column_count) + .map(|i| stmt.column_name(i).unwrap_or("").to_string()) + .collect(); + + // Bind parameters (either positional or named) + let has_named_params = params.iter().any(|(name, _)| !name.is_empty()); + + // Convert row to JSON map + let row_to_map = |row: &rusqlite::Row| -> rusqlite::Result> { + let mut map = HashMap::new(); + for (i, col_name) in column_names.iter().enumerate() { + let value: Value = match row.get_ref(i)? { + rusqlite::types::ValueRef::Null => Value::Null, + rusqlite::types::ValueRef::Integer(v) => Value::Number(v.into()), + rusqlite::types::ValueRef::Real(v) => { + serde_json::Number::from_f64(v) + .map(Value::Number) + .unwrap_or(Value::Null) + } + rusqlite::types::ValueRef::Text(v) => { + Value::String(String::from_utf8_lossy(v).to_string()) + } + rusqlite::types::ValueRef::Blob(v) => { + // Convert blob to hex string + let hex_string = v.iter().map(|b| format!("{:02x}", b)).collect::(); + Value::String(hex_string) + } + }; + map.insert(col_name.clone(), value); + } + Ok(map) + }; + + let rows = if has_named_params { + // Use named parameters + let named_params: Vec<(&str, &dyn rusqlite::ToSql)> = params + .iter() + .map(|(name, value)| (name.as_str(), value as &dyn rusqlite::ToSql)) + .collect(); + stmt.query_map(named_params.as_slice(), row_to_map)? + } else { + // Use positional parameters + let positional_params: Vec<&dyn rusqlite::ToSql> = params + .iter() + .map(|(_, value)| value as &dyn rusqlite::ToSql) + .collect(); + stmt.query_map(positional_params.as_slice(), row_to_map)? + }; + + rows.collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_execute_query_empty_db() { + // Test with a non-existent database - should return error + let result = execute_query("/nonexistent/test.db", "SELECT 1", &[]); + assert!(result.is_err()); + } + + #[test] + fn test_execute_query_with_memory_db() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute( + "CREATE TABLE test (id INTEGER, name TEXT, value REAL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO test VALUES (1, 'first', 1.5), (2, 'second', 2.5)", + [], + ) + .unwrap(); + } + + let results = execute_query(temp_path, "SELECT * FROM test ORDER BY id", &[]).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!( + results[0].get("id").unwrap(), + &Value::Number(1.into()) + ); + assert_eq!( + results[0].get("name").unwrap(), + &Value::String("first".to_string()) + ); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_with_positional_params() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_params.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute("CREATE TABLE books (id INTEGER, title TEXT)", []) + .unwrap(); + conn.execute( + "INSERT INTO books VALUES (1, 'Book One'), (2, 'Book Two'), (3, 'Book Three')", + [], + ) + .unwrap(); + } + + let params = vec![(String::new(), "2".to_string())]; + let results = + execute_query(temp_path, "SELECT * FROM books WHERE id = ?", ¶ms).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].get("title").unwrap(), + &Value::String("Book Two".to_string()) + ); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_with_named_params() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_named.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute("CREATE TABLE books (id INTEGER, title TEXT, year INTEGER)", []) + .unwrap(); + conn.execute( + "INSERT INTO books VALUES (1, 'Old Book', 2000), (2, 'New Book', 2020), (3, 'Newer Book', 2023)", + [], + ) + .unwrap(); + } + + let params = vec![ + (":min_year".to_string(), "2015".to_string()), + (":max_year".to_string(), "2024".to_string()), + ]; + let results = execute_query( + temp_path, + "SELECT * FROM books WHERE year >= :min_year AND year <= :max_year ORDER BY year", + ¶ms, + ) + .unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!( + results[0].get("title").unwrap(), + &Value::String("New Book".to_string()) + ); + assert_eq!( + results[1].get("title").unwrap(), + &Value::String("Newer Book".to_string()) + ); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_data_types() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_types.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute( + "CREATE TABLE types (id INTEGER, name TEXT, price REAL, data BLOB, nullable TEXT)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO types VALUES (42, 'test', 3.14, X'DEADBEEF', NULL)", + [], + ) + .unwrap(); + } + + let results = execute_query(temp_path, "SELECT * FROM types", &[]).unwrap(); + assert_eq!(results.len(), 1); + + let row = &results[0]; + + assert_eq!(row.get("id").unwrap(), &Value::Number(42.into())); + assert_eq!( + row.get("name").unwrap(), + &Value::String("test".to_string()) + ); + assert_eq!(row.get("price").unwrap().as_f64().unwrap(), 3.14); + assert_eq!( + row.get("data").unwrap(), + &Value::String("deadbeef".to_string()) + ); + assert_eq!(row.get("nullable").unwrap(), &Value::Null); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_multiple_named_params() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_multi.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute("CREATE TABLE books (id INTEGER, genre TEXT, rating REAL)", []) + .unwrap(); + conn.execute( + "INSERT INTO books VALUES + (1, 'Fiction', 4.5), + (2, 'Science', 4.8), + (3, 'Fiction', 4.9), + (4, 'Science', 4.2)", + [], + ) + .unwrap(); + } + + let params = vec![ + (":min_rating".to_string(), "4.5".to_string()), + (":genre".to_string(), "Fiction".to_string()), + ]; + + let results = execute_query( + temp_path, + "SELECT * FROM books WHERE genre = :genre AND rating >= :min_rating ORDER BY rating DESC", + ¶ms, + ) + .unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].get("rating").unwrap().as_f64().unwrap(), 4.9); + assert_eq!(results[1].get("rating").unwrap().as_f64().unwrap(), 4.5); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_with_like_operator() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_like.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute("CREATE TABLE books (title TEXT)", []).unwrap(); + conn.execute( + "INSERT INTO books VALUES ('The Rust Book'), ('Clean Code'), ('Rust in Action')", + [], + ) + .unwrap(); + } + + let params = vec![(":search".to_string(), "Rust".to_string())]; + let results = execute_query( + temp_path, + "SELECT * FROM books WHERE title LIKE '%' || :search || '%'", + ¶ms, + ) + .unwrap(); + + assert_eq!(results.len(), 2); + + let _ = fs::remove_file(temp_path); + } + + #[test] + fn test_execute_query_empty_results() { + use rusqlite::Connection; + use std::fs; + + let temp_path = "/tmp/test_sqlite_serve_empty.db"; + let _ = fs::remove_file(temp_path); + + { + let conn = Connection::open(temp_path).unwrap(); + conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap(); + } + + let results = execute_query(temp_path, "SELECT * FROM test", &[]).unwrap(); + assert_eq!(results.len(), 0); + + let _ = fs::remove_file(temp_path); + } +} + diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..3636ea2 --- /dev/null +++ b/src/template.rs @@ -0,0 +1,160 @@ +//! Template loading and management + +use handlebars::Handlebars; +use std::path::Path; + +/// Load all .hbs templates from a directory into the Handlebars registry +/// +/// Each template is registered by its filename (without .hbs extension). +/// Returns the number of templates successfully loaded. +pub fn load_templates_from_dir(reg: &mut Handlebars, dir_path: &str) -> std::io::Result { + use std::fs; + + let dir = Path::new(dir_path); + if !dir.exists() || !dir.is_dir() { + return Ok(0); + } + + let mut count = 0; + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "hbs" { + if let Some(stem) = path.file_stem() { + if let Some(name) = stem.to_str() { + if let Err(e) = reg.register_template_file(name, &path) { + eprintln!("Failed to register template {}: {}", path.display(), e); + } else { + count += 1; + } + } + } + } + } + } + } + + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_templates_from_nonexistent_dir() { + let mut reg = Handlebars::new(); + let result = load_templates_from_dir(&mut reg, "/nonexistent/path/to/templates"); + + // Should succeed but load 0 templates + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + } + + #[test] + fn test_load_templates_from_dir() { + use std::fs; + use std::io::Write; + + let temp_dir = "/tmp/test_sqlite_serve_templates"; + let _ = fs::remove_dir_all(temp_dir); + fs::create_dir_all(temp_dir).unwrap(); + + // Create test templates + let mut file1 = fs::File::create(format!("{}/template1.hbs", temp_dir)).unwrap(); + file1.write_all(b"

    Template 1

    ").unwrap(); + + let mut file2 = fs::File::create(format!("{}/template2.hbs", temp_dir)).unwrap(); + file2.write_all(b"

    Template 2

    ").unwrap(); + + // Create a non-template file (should be ignored) + let mut file3 = fs::File::create(format!("{}/readme.txt", temp_dir)).unwrap(); + file3.write_all(b"Not a template").unwrap(); + + let mut reg = Handlebars::new(); + let count = load_templates_from_dir(&mut reg, temp_dir).unwrap(); + + assert_eq!(count, 2); + assert!(reg.has_template("template1")); + assert!(reg.has_template("template2")); + assert!(!reg.has_template("readme")); + + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn test_template_rendering_with_results() { + use serde_json::json; + use std::fs; + use std::io::Write; + + let temp_dir = "/tmp/test_sqlite_serve_render"; + let _ = fs::remove_dir_all(temp_dir); + fs::create_dir_all(temp_dir).unwrap(); + + let template_path = format!("{}/list.hbs", temp_dir); + let mut file = fs::File::create(&template_path).unwrap(); + file.write_all(b"{{#each results}}
  • {{name}}
  • {{/each}}") + .unwrap(); + + let mut reg = Handlebars::new(); + reg.register_template_file("list", &template_path).unwrap(); + + let mut results = vec![]; + let mut item1 = std::collections::HashMap::new(); + item1.insert( + "name".to_string(), + serde_json::Value::String("Item 1".to_string()), + ); + results.push(item1); + + let mut item2 = std::collections::HashMap::new(); + item2.insert( + "name".to_string(), + serde_json::Value::String("Item 2".to_string()), + ); + results.push(item2); + + let rendered = reg.render("list", &json!({"results": results})).unwrap(); + assert!(rendered.contains("
  • Item 1
  • ")); + assert!(rendered.contains("
  • Item 2
  • ")); + + let _ = fs::remove_dir_all(temp_dir); + } + + #[test] + fn test_template_override_behavior() { + use std::fs; + use std::io::Write; + + let temp_dir = "/tmp/test_sqlite_serve_override"; + let _ = fs::remove_dir_all(temp_dir); + fs::create_dir_all(temp_dir).unwrap(); + + let template1_path = format!("{}/test.hbs", temp_dir); + let mut file1 = fs::File::create(&template1_path).unwrap(); + file1.write_all(b"Original").unwrap(); + + let mut reg = Handlebars::new(); + reg.register_template_file("test", &template1_path).unwrap(); + + let rendered1 = reg.render("test", &serde_json::json!({})).unwrap(); + assert_eq!(rendered1, "Original"); + + // Override with new content + let mut file2 = fs::File::create(&template1_path).unwrap(); + file2.write_all(b"Updated").unwrap(); + + // Re-register to override + reg.register_template_file("test", &template1_path).unwrap(); + + let rendered2 = reg.render("test", &serde_json::json!({})).unwrap(); + assert_eq!(rendered2, "Updated"); + + let _ = fs::remove_dir_all(temp_dir); + } +} + diff --git a/src/variable.rs b/src/variable.rs new file mode 100644 index 0000000..3621122 --- /dev/null +++ b/src/variable.rs @@ -0,0 +1,107 @@ +//! Nginx variable resolution utilities + +use ngx::ffi::{ngx_hash_key, ngx_http_get_variable, ngx_str_t}; +use ngx::http::Request; +use ngx::ngx_log_debug_http; + +/// Resolve a variable name (with $ prefix) or return literal value +/// +/// If var_name starts with '$', resolves it as an nginx variable. +/// Otherwise, returns var_name as a literal string. +pub fn resolve_variable(request: &mut Request, var_name: &str) -> Result { + if var_name.starts_with('$') { + resolve_nginx_variable(request, var_name) + } else { + Ok(var_name.to_string()) + } +} + +/// Resolve an nginx variable by name +fn resolve_nginx_variable(request: &mut Request, var_name: &str) -> Result { + let var_name_str = &var_name[1..]; // Remove the '$' prefix + let var_name_bytes = var_name_str.as_bytes(); + + let mut name = ngx_str_t { + len: var_name_bytes.len(), + data: var_name_bytes.as_ptr() as *mut u8, + }; + + let key = unsafe { ngx_hash_key(name.data, name.len) }; + let r: *mut ngx::ffi::ngx_http_request_t = request.into(); + let var_value = unsafe { ngx_http_get_variable(r, &mut name, key) }; + + if var_value.is_null() { + ngx_log_debug_http!(request, "variable not found: {}", var_name); + return Err(format!("variable not found: {}", var_name)); + } + + let var_ref = unsafe { &*var_value }; + if var_ref.valid() == 0 { + ngx_log_debug_http!(request, "variable value not valid: {}", var_name); + return Err(format!("variable not valid: {}", var_name)); + } + + match std::str::from_utf8(var_ref.as_bytes()) { + Ok(s) => Ok(s.to_string()), + Err(_) => { + ngx_log_debug_http!(request, "failed to decode variable as UTF-8: {}", var_name); + Err(format!("invalid UTF-8 in variable: {}", var_name)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_literal_value() { + // Non-$ prefixed values should be returned as-is + // Note: This test doesn't need a real request since it's just a literal + let result = "literal_value"; + assert_eq!(result, "literal_value"); + } + + #[test] + fn test_has_named_params() { + let positional = vec![ + (String::new(), "value1".to_string()), + (String::new(), "value2".to_string()), + ]; + assert!(!positional.iter().any(|(name, _)| !name.is_empty())); + + let named = vec![ + (":id".to_string(), "value1".to_string()), + (":name".to_string(), "value2".to_string()), + ]; + assert!(named.iter().any(|(name, _)| !name.is_empty())); + + let mixed = vec![ + (":id".to_string(), "value1".to_string()), + (String::new(), "value2".to_string()), + ]; + assert!(mixed.iter().any(|(name, _)| !name.is_empty())); + } + + #[test] + fn test_named_params_parsing() { + // Test parameter name parsing logic + let test_cases = vec![ + (2, false, ""), // sqlite_param $arg_id + (3, true, ":book_id"), // sqlite_param :book_id $arg_id + ]; + + for (nelts, expected_is_named, expected_param_name) in test_cases { + if nelts == 2 { + let param_name = String::new(); + assert!(!expected_is_named); + assert_eq!(param_name, expected_param_name); + } else if nelts == 3 { + let param_name = ":book_id".to_string(); + assert!(expected_is_named); + assert_eq!(param_name, expected_param_name); + } + } + } +} +