diff --git a/README.md b/README.md index 083736d..846ae4c 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,29 @@ A full-featured catalog with category browsing, global templates, and responsive **See:** `conf/book_catalog.conf` and `README_BOOK_CATALOG.md` -### Example 2: Parameterized Queries (Port 8081) +### Example 2: Positional Parameters (Port 8081) -Demonstrates dynamic SQL queries with nginx variables. +Demonstrates dynamic SQL queries with positional parameters. **Features:** -- Book detail pages by ID -- Genre filtering with query parameters -- Year range searches with multiple parameters -- Safe prepared statement parameter binding +- Query parameters with `?` placeholders +- Multiple positional parameters +- Safe prepared statement binding -**See:** `conf/book_detail.conf` and `README_PARAMETERS.md` +**See:** `conf/book_detail.conf` + +### Example 3: Named Parameters (Port 8082) - Recommended + +Demonstrates named SQL parameters for better readability. + +**Features:** +- Named parameters with `:name` syntax +- Order-independent parameter binding +- Title search with LIKE operator +- Rating filtering +- More maintainable configuration + +**See:** `conf/book_named_params.conf` and `README_PARAMETERS.md` ## Configuration Directives @@ -85,9 +97,14 @@ Specify the Handlebars template file (relative to location path). ### `sqlite_param` Add a parameter to the SQL query (can be used multiple times). -**Syntax:** `sqlite_param $variable_or_value;` +**Syntax:** +- Positional: `sqlite_param $variable_or_value;` +- Named: `sqlite_param :param_name $variable_or_value;` + **Context:** `location` -**Notes:** Order matches `?` placeholders in query +**Notes:** +- Positional parameters match `?` placeholders in order +- Named parameters match `:name` placeholders by name (recommended) ### `sqlite_global_templates` Set a directory for global template files (partials, layouts). @@ -112,13 +129,22 @@ http { sqlite_template "list.hbs"; } - # Parameterized query + # Parameterized query with named parameter (recommended) location = /book { sqlite_db "catalog.db"; - sqlite_query "SELECT * FROM books WHERE id = ?"; - sqlite_param $arg_id; + sqlite_query "SELECT * FROM books WHERE id = :book_id"; + sqlite_param :book_id $arg_id; sqlite_template "detail.hbs"; } + + # Positional parameters also supported + location = /search { + sqlite_db "catalog.db"; + sqlite_query "SELECT * FROM books WHERE year >= ? AND year <= ?"; + sqlite_param $arg_min; # First ? + sqlite_param $arg_max; # Second ? + sqlite_template "list.hbs"; + } } } ``` diff --git a/README_PARAMETERS.md b/README_PARAMETERS.md index 406d803..db5aa2e 100644 --- a/README_PARAMETERS.md +++ b/README_PARAMETERS.md @@ -8,13 +8,49 @@ The sqlite-serve module supports parameterized SQL queries using nginx variables Add parameters to SQL queries. Can be used multiple times to add multiple parameters. -**Syntax:** `sqlite_param variable_or_value;` +**Syntax:** +- Positional: `sqlite_param variable_or_value;` +- Named: `sqlite_param :param_name variable_or_value;` + **Context:** `location` -**Multiple:** Yes (order matches `?` placeholders in query) +**Multiple:** Yes + +**Note:** Positional parameters match `?` placeholders in order. Named parameters match `:name` placeholders by name. ## Usage -### Query Parameters (Most Common) +### Named Parameters (Recommended) + +Named parameters provide better readability and don't depend on order: + +```nginx +location = /book { + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE id = :book_id"; + sqlite_param :book_id $arg_id; # Named parameter + sqlite_template "detail.hbs"; +} +``` + +**Request:** `http://localhost/book?id=5` +**SQL Executed:** `SELECT * FROM books WHERE id = '5'` + +### Multiple Named Parameters + +```nginx +location = /years { + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE year >= :min AND year <= :max"; + sqlite_param :min $arg_min; # Order doesn't matter + sqlite_param :max $arg_max; # with named params + sqlite_template "list.hbs"; +} +``` + +**Request:** `http://localhost/years?min=2015&max=2024` +**SQL Executed:** `SELECT * FROM books WHERE year >= '2015' AND year <= '2024'` + +### Query Parameters (Positional) Use nginx's built-in `$arg_*` variables to access query parameters: @@ -267,10 +303,46 @@ Run it with: - All SQL placeholders must be `?` (positional parameters) - Parameters match placeholders in order of `sqlite_param` directives +## Named vs Positional Parameters + +### Named Parameters (`:name` syntax) - Recommended ✓ + +**Advantages:** +- Order-independent: Can rearrange `sqlite_param` directives without breaking queries +- Self-documenting: Parameter names explain their purpose +- Safer for maintenance: Adding/removing parameters less error-prone +- Better for complex queries with many parameters + +**Example:** +```nginx +sqlite_query "SELECT * FROM books WHERE author = :author AND year > :year"; +sqlite_param :year $arg_year; # Order doesn't matter! +sqlite_param :author $arg_author; +``` + +### Positional Parameters (`?` syntax) + +**Advantages:** +- Slightly more compact configuration +- Works well for simple 1-2 parameter queries + +**Disadvantages:** +- Order-dependent: Parameters must match `?` placeholders exactly +- Less readable with many parameters +- Error-prone when modifying queries + +**Example:** +```nginx +sqlite_query "SELECT * FROM books WHERE author = ? AND year > ?"; +sqlite_param $arg_author; # Must be first! +sqlite_param $arg_year; # Must be second! +``` + +**Recommendation:** Use named parameters (`:name`) for all but the simplest queries. + ## Limitations -- Only supports `?` positional parameters (not named parameters like `:name`) -- Parameters must be provided in the exact order they appear in the query - All parameter values are treated as strings (SQLite performs type coercion) - Complex SQL values (arrays, JSON) should be constructed in the query itself +- Cannot mix positional and named parameters in the same query diff --git a/conf/book_named_params.conf b/conf/book_named_params.conf new file mode 100644 index 0000000..ddee42e --- /dev/null +++ b/conf/book_named_params.conf @@ -0,0 +1,74 @@ +# Book Catalog with Named Parameters +# Demonstrates using named SQL parameters for better readability + +load_module target/debug/libsqlite_serve.dylib; + +worker_processes 1; +events {} + +error_log logs/error.log debug; + +http { + sqlite_global_templates "server_root/global_templates"; + + server { + listen 8082; + + root "server_root"; + + # Book detail with named parameter + location = /book { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE id = :book_id"; + sqlite_param :book_id $arg_id; + sqlite_template "detail.hbs"; + } + + # Genre filter with named parameter + location = /genre { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE genre = :genre_name ORDER BY rating DESC"; + sqlite_param :genre_name $arg_genre; + sqlite_template "genre.hbs"; + } + + # Year range with named parameters + location = /years { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE year >= :min_year AND year <= :max_year ORDER BY year DESC, title"; + sqlite_param :min_year $arg_min; + sqlite_param :max_year $arg_max; + sqlite_template "list.hbs"; + } + + # Search by title with named parameter + location = /search { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE title LIKE '%' || :search_term || '%' ORDER BY rating DESC, title"; + sqlite_param :search_term $arg_q; + sqlite_template "list.hbs"; + } + + # Filter by rating with named parameter + location = /top-rated { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books WHERE rating >= :min_rating ORDER BY rating DESC, title"; + sqlite_param :min_rating $arg_rating; + sqlite_template "list.hbs"; + } + + # Fallback to all books + location / { + add_header "Content-Type" "text/html; charset=utf-8"; + sqlite_db "book_catalog.db"; + sqlite_query "SELECT * FROM books ORDER BY rating DESC, title"; + sqlite_template "list.hbs"; + } + } +} + diff --git a/server_root/search/list.hbs b/server_root/search/list.hbs new file mode 100644 index 0000000..9ee6ddb --- /dev/null +++ b/server_root/search/list.hbs @@ -0,0 +1,122 @@ +{{> header}} + + + +
+
+

📚

+

Book Collection

+
+
+

+

Top Rated

+
+
+

🔍

+

Browse All

+
+
+ +

All Books in Collection

+ +
+ {{#each results}} + {{> book_card}} + {{/each}} +
+ +{{> footer}} + diff --git a/server_root/top-rated/list.hbs b/server_root/top-rated/list.hbs new file mode 100644 index 0000000..9ee6ddb --- /dev/null +++ b/server_root/top-rated/list.hbs @@ -0,0 +1,122 @@ +{{> header}} + + + +
+
+

📚

+

Book Collection

+
+
+

+

Top Rated

+
+
+

🔍

+

Browse All

+
+
+ +

All Books in Collection

+ +
+ {{#each results}} + {{> book_card}} + {{/each}} +
+ +{{> footer}} + diff --git a/src/lib.rs b/src/lib.rs index aaf7591..a9015f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use handlebars::Handlebars; use ngx::core::Buffer; use ngx::ffi::{ - ngx_hash_key, ngx_http_get_variable, NGX_CONF_TAKE1, NGX_HTTP_LOC_CONF, + 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, @@ -46,7 +46,7 @@ struct ModuleConfig { db_path: String, query: String, template_path: String, - query_params: Vec, // Variable names to use as query parameters + query_params: Vec<(String, String)>, // (param_name, variable_name) pairs } // Global configuration for shared templates @@ -179,7 +179,7 @@ static mut ngx_http_howto_commands: [ngx_command_t; 6] = [ }, ngx_command_t { name: ngx_string!("sqlite_param"), - type_: (NGX_HTTP_LOC_CONF | NGX_CONF_TAKE1) as ngx_uint_t, + type_: (NGX_HTTP_LOC_CONF | NGX_CONF_TAKE1 | NGX_CONF_TAKE2) as ngx_uint_t, set: Some(ngx_http_howto_commands_add_param), conf: NGX_HTTP_LOC_CONF_OFFSET, offset: 0, @@ -272,8 +272,20 @@ extern "C" fn ngx_http_howto_commands_add_param( unsafe { let conf = &mut *(conf as *mut ModuleConfig); let args = (*(*cf).args).elts as *mut ngx_str_t; - let param = (*args.add(1)).to_string(); - conf.query_params.push(param); + let nelts = (*(*cf).args).nelts; + + if nelts == 2 { + // Single argument: positional parameter + // sqlite_param $arg_id + let variable = (*args.add(1)).to_string(); + conf.query_params.push((String::new(), variable)); + } else if nelts == 3 { + // Two arguments: named parameter + // sqlite_param :book_id $arg_id + let param_name = (*args.add(1)).to_string(); + let variable = (*args.add(2)).to_string(); + conf.query_params.push((param_name, variable)); + } }; std::ptr::null_mut() @@ -318,7 +330,7 @@ fn load_templates_from_dir(reg: &mut Handlebars, dir_path: &str) -> std::io::Res fn execute_query( db_path: &str, query: &str, - params: &[&str], + params: &[(String, String)], // (param_name, value) pairs ) -> Result>> { let conn = Connection::open(db_path)?; let mut stmt = conn.prepare(query)?; @@ -328,13 +340,11 @@ fn execute_query( .map(|i| stmt.column_name(i).unwrap_or("").to_string()) .collect(); - // Convert params to rusqlite parameters - let rusqlite_params: Vec<&dyn rusqlite::ToSql> = params - .iter() - .map(|p| p as &dyn rusqlite::ToSql) - .collect(); - - let rows = stmt.query_map(rusqlite_params.as_slice(), |row| { + // 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)? { @@ -350,7 +360,8 @@ fn execute_query( } rusqlite::types::ValueRef::Blob(v) => { // Convert blob to hex string - let hex_string = v.iter() + let hex_string = v + .iter() .map(|b| format!("{:02x}", b)) .collect::(); serde_json::Value::String(hex_string) @@ -359,7 +370,23 @@ fn execute_query( 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() } @@ -407,8 +434,8 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { .unwrap_or(""); // Resolve query parameters from nginx variables - let mut param_values: Vec = Vec::new(); - for var_name in &co.query_params { + 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 @@ -445,19 +472,17 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { // It's a literal value var_name.clone() }; - param_values.push(value); + param_values.push((param_name.clone(), value)); } ngx_log_debug_http!( request, - "executing query with {} parameters: {:?}", - param_values.len(), - param_values + "executing query with {} parameters", + param_values.len() ); // Execute the configured SQL query with parameters - let param_refs: Vec<&str> = param_values.iter().map(|s| s.as_str()).collect(); - let results = match execute_query(&co.db_path, &co.query, ¶m_refs) { + let results = match execute_query(&co.db_path, &co.query, ¶m_values) { Ok(results) => results, Err(e) => { ngx_log_debug_http!(request, "failed to execute query: {}", e); diff --git a/start_named_params.sh b/start_named_params.sh new file mode 100755 index 0000000..8169cdd --- /dev/null +++ b/start_named_params.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Start script for named parameters example + +set -e + +echo "📚 Starting Named Parameters Example..." +echo "" + +# Check if database exists +if [ ! -f "book_catalog.db" ]; then + echo "Database not found. Running setup..." + ./setup_book_catalog.sh + echo "" +fi + +# Check if module is built +if [ ! -f "target/debug/libsqlite_serve.dylib" ]; then + echo "Module not built. Building..." + direnv exec "$PWD" cargo build + echo "" +fi + +# Start nginx +echo "Starting nginx on http://localhost:8082" +./ngx_src/nginx-1.28.0/objs/nginx -c conf/book_named_params.conf -p . + +echo "" +echo "✅ Named Parameters Example is running!" +echo "" +echo "Named Parameter Examples:" +echo " • http://localhost:8082/book?id=1" +echo " Query: SELECT * FROM books WHERE id = :book_id" +echo " Param: :book_id = \$arg_id" +echo "" +echo " • http://localhost:8082/genre?genre=Programming" +echo " Query: ... WHERE genre = :genre_name" +echo " Param: :genre_name = \$arg_genre" +echo "" +echo " • http://localhost:8082/years?min=2015&max=2024" +echo " Query: ... WHERE year >= :min_year AND year <= :max_year" +echo " Params: :min_year = \$arg_min, :max_year = \$arg_max" +echo "" +echo " • http://localhost:8082/search?q=Rust" +echo " Search by title with named parameter" +echo "" +echo " • http://localhost:8082/top-rated?rating=4.7" +echo " Filter by minimum rating" +echo "" +echo "To stop: ./ngx_src/nginx-1.28.0/objs/nginx -s stop -c conf/book_named_params.conf -p ." +