Add named parameter support for SQL queries

New Feature: Named SQL Parameters
- Supports both positional (?) and named (:name) parameters
- Named parameters are order-independent and more readable
- Syntax: sqlite_param :param_name $variable

Implementation:
- Updated sqlite_param directive to accept 1 or 2 arguments
- ModuleConfig.query_params now stores (name, variable) pairs
- execute_query() detects named vs positional parameters
- Extracted row_to_map closure to avoid type conflicts
- Named params use rusqlite named parameter binding

Examples (Port 8082):
- Book detail: WHERE id = :book_id
- Genre filter: WHERE genre = :genre_name
- Year range: WHERE year >= :min_year AND year <= :max_year
- Title search: WHERE title LIKE '%' || :search_term || '%'
- Rating filter: WHERE rating >= :min_rating

Benefits of Named Parameters:
- Order-independent: params can be in any order in config
- Self-documenting: :book_id is clearer than first ?
- Maintainable: can add/remove params without reordering
- Recommended for all but simplest queries

Configuration:
- conf/book_named_params.conf: Complete named params example
- start_named_params.sh: Quick start script for port 8082

Documentation:
- Added named vs positional comparison in README_PARAMETERS.md
- Updated README.md with named parameter examples
- Documented both syntaxes in directive reference

All examples tested and working with both parameter styles.
This commit is contained in:
Edward Langley
2025-11-15 15:20:40 -08:00
parent 775467da51
commit e016c2421b
7 changed files with 531 additions and 40 deletions

View File

@ -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";
}
}
}
```

View File

@ -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

View File

@ -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";
}
}
}

122
server_root/search/list.hbs Normal file
View File

@ -0,0 +1,122 @@
{{> header}}
<style>
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.book-card {
background: #fff;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s;
}
.book-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.2);
border-color: #667eea;
}
.book-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.5rem;
}
.book-title {
color: #2d3748;
font-size: 1.25rem;
margin-bottom: 0.25rem;
flex: 1;
}
.book-rating {
background: #fef3c7;
color: #92400e;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-weight: bold;
font-size: 0.9rem;
white-space: nowrap;
margin-left: 0.5rem;
}
.book-author {
color: #667eea;
font-weight: 500;
margin-bottom: 0.75rem;
}
.book-description {
color: #4a5568;
margin-bottom: 1rem;
line-height: 1.5;
}
.book-details {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
}
.book-genre {
background: #667eea;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: 500;
}
.book-year {
background: #e9ecef;
color: #495057;
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
.book-isbn {
color: #6c757d;
font-family: monospace;
font-size: 0.8rem;
}
.stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
justify-content: space-around;
text-align: center;
}
.stat-item h2 {
font-size: 2.5rem;
margin-bottom: 0.25rem;
}
.stat-item p {
opacity: 0.9;
font-size: 0.9rem;
}
</style>
<div class="stats">
<div class="stat-item">
<h2>📚</h2>
<p>Book Collection</p>
</div>
<div class="stat-item">
<h2>⭐</h2>
<p>Top Rated</p>
</div>
<div class="stat-item">
<h2>🔍</h2>
<p>Browse All</p>
</div>
</div>
<h2 style="color: #2d3748; margin-bottom: 1rem;">All Books in Collection</h2>
<div class="book-grid">
{{#each results}}
{{> book_card}}
{{/each}}
</div>
{{> footer}}

View File

@ -0,0 +1,122 @@
{{> header}}
<style>
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.book-card {
background: #fff;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s;
}
.book-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.2);
border-color: #667eea;
}
.book-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.5rem;
}
.book-title {
color: #2d3748;
font-size: 1.25rem;
margin-bottom: 0.25rem;
flex: 1;
}
.book-rating {
background: #fef3c7;
color: #92400e;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-weight: bold;
font-size: 0.9rem;
white-space: nowrap;
margin-left: 0.5rem;
}
.book-author {
color: #667eea;
font-weight: 500;
margin-bottom: 0.75rem;
}
.book-description {
color: #4a5568;
margin-bottom: 1rem;
line-height: 1.5;
}
.book-details {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
}
.book-genre {
background: #667eea;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: 500;
}
.book-year {
background: #e9ecef;
color: #495057;
padding: 0.25rem 0.75rem;
border-radius: 4px;
}
.book-isbn {
color: #6c757d;
font-family: monospace;
font-size: 0.8rem;
}
.stats {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
justify-content: space-around;
text-align: center;
}
.stat-item h2 {
font-size: 2.5rem;
margin-bottom: 0.25rem;
}
.stat-item p {
opacity: 0.9;
font-size: 0.9rem;
}
</style>
<div class="stats">
<div class="stat-item">
<h2>📚</h2>
<p>Book Collection</p>
</div>
<div class="stat-item">
<h2>⭐</h2>
<p>Top Rated</p>
</div>
<div class="stat-item">
<h2>🔍</h2>
<p>Browse All</p>
</div>
</div>
<h2 style="color: #2d3748; margin-bottom: 1rem;">All Books in Collection</h2>
<div class="book-grid">
{{#each results}}
{{> book_card}}
{{/each}}
</div>
{{> footer}}

View File

@ -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<String>, // 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<Vec<std::collections::HashMap<String, serde_json::Value>>> {
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<std::collections::HashMap<String, serde_json::Value>> {
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::<String>();
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<String> = 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, &param_refs) {
let results = match execute_query(&co.db_path, &co.query, &param_values) {
Ok(results) => results,
Err(e) => {
ngx_log_debug_http!(request, "failed to execute query: {}", e);

50
start_named_params.sh Executable file
View File

@ -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 ."