From d69b1d1d9f9ee42b81862742a7a30f114299c450 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:07:09 +0100 Subject: [PATCH 01/10] Make URL and POST parameters immutable, separate from SET variables - URL and POST parameters are now immutable after request initialization - SET command creates user-defined variables in separate namespace - Variable lookup: SET variables shadow request parameters - Added sqlpage.variables('set') to inspect user-defined variables - Simplified API: most functions now use &RequestInfo instead of &mut - All tests passing (151 total) --- examples/official-site/extensions-to-sql.md | 29 ++++++++++++---- .../migrations/20_variables_function.sql | 21 ++++++++++-- src/webserver/database/execute_queries.rs | 27 ++++++--------- .../database/sqlpage_functions/functions.rs | 26 ++++++++++----- src/webserver/database/syntax_tree.rs | 32 +++++++++--------- src/webserver/http.rs | 6 ++-- src/webserver/http_request_info.rs | 23 ++++++++----- .../it_works_immutable_variables.sql | 33 +++++++++++++++++++ 8 files changed, 135 insertions(+), 62 deletions(-) create mode 100644 tests/sql_test_files/it_works_immutable_variables.sql diff --git a/examples/official-site/extensions-to-sql.md b/examples/official-site/extensions-to-sql.md index 88fa4067..9cf6130f 100644 --- a/examples/official-site/extensions-to-sql.md +++ b/examples/official-site/extensions-to-sql.md @@ -75,10 +75,15 @@ SELECT (select 1) AS one; ## Variables SQLPage communicates information about incoming HTTP requests to your SQL code through prepared statement variables. -You can use - - `$var` to reference a GET variable (an URL parameter), - - `:var` to reference a POST variable (a value filled by an user in a form field), - - `set var = ...` to set the value of `$var`. + +### Variable Types and Mutability + +There are two types of variables in SQLPage: + +1. **Request parameters** (immutable): URL parameters and form data from the HTTP request +2. **User-defined variables** (mutable): Variables created with the `SET` command + +Request parameters cannot be modified after the request is received. This ensures the original request data remains intact throughout request processing. ### POST parameters @@ -111,20 +116,30 @@ When a URL parameter is not set, its value is `NULL`. ### The SET command -`SET` stores a value in SQLPage (not in the database). Only strings and `NULL` are stored. +`SET` creates or updates a user-defined variable in SQLPage (not in the database). Only strings and `NULL` are stored. ```sql -- Give a default value to a variable SET post_id = COALESCE($post_id, 0); + +-- User-defined variables shadow URL parameters with the same name +SET my_var = 'custom value'; -- This value takes precedence over ?my_var=... ``` +**Variable Lookup Precedence:** +- `$var`: checks user-defined variables first, then URL parameters +- `:var`: checks user-defined variables first, then POST parameters + +This means `SET` variables always take precedence over request parameters when using `$var` or `:var` syntax. + +**How SET works:** - If the right-hand side is purely literals/variables, SQLPage computes it directly. See the section about *static simple select* above. - If it needs the database (for example, calls a database function), SQLPage runs an internal `SELECT` to compute it and stores the first column of the first row of results. Only a single textual value (**string or `NULL`**) is stored. -`set id = 1` will store the string `'1'`, not the number `1`. +`SET id = 1` will store the string `'1'`, not the number `1`. -On databases with a strict type system, such as PostgreSQL, if you need a number, you will need to cast your variables: `select * from post where id = $id::int`. +On databases with a strict type system, such as PostgreSQL, if you need a number, you will need to cast your variables: `SELECT * FROM post WHERE id = $id::int`. Complex structures can be stored as json strings. diff --git a/examples/official-site/sqlpage/migrations/20_variables_function.sql b/examples/official-site/sqlpage/migrations/20_variables_function.sql index 69fe1f51..dce7b2cb 100644 --- a/examples/official-site/sqlpage/migrations/20_variables_function.sql +++ b/examples/official-site/sqlpage/migrations/20_variables_function.sql @@ -9,10 +9,27 @@ VALUES ( 'variables', '0.15.0', 'variable', - 'Returns a JSON string containing all variables passed as URL parameters or posted through a form. + 'Returns a JSON string containing variables from the HTTP request and user-defined variables. The database''s json handling functions can then be used to process the data. +## Variable Types + +SQLPage distinguishes between three types of variables: + +- **GET variables**: URL parameters from the query string (immutable) +- **POST variables**: Form data from POST requests (immutable) +- **SET variables**: User-defined variables created with the `SET` command (mutable) + +## Usage + +- `sqlpage.variables()` - returns all variables (GET, POST, and SET combined, with SET variables taking precedence) +- `sqlpage.variables(''get'')` - returns only URL parameters +- `sqlpage.variables(''post'')` - returns only POST form data +- `sqlpage.variables(''set'')` - returns only user-defined variables created with `SET` + +When a SET variable has the same name as a GET or POST variable, the SET variable takes precedence in the combined result. + ## Example: a form with a variable number of fields ### Making a form based on questions in a database table @@ -95,6 +112,6 @@ VALUES ( 'variables', 1, 'method', - 'Optional. The HTTP request method (GET or POST). Must be a literal string. When not provided, all variables are returned.', + 'Optional. Filter variables by source: ''get'' (URL parameters), ''post'' (form data), or ''set'' (user-defined variables). When not provided, all variables are returned with SET variables taking precedence over request parameters.', 'TEXT' ); diff --git a/src/webserver/database/execute_queries.rs b/src/webserver/database/execute_queries.rs index 8b31a436..44d24866 100644 --- a/src/webserver/database/execute_queries.rs +++ b/src/webserver/database/execute_queries.rs @@ -44,7 +44,7 @@ impl Database { pub fn stream_query_results_with_conn<'a>( sql_file: &'a ParsedSqlFile, - request: &'a mut RequestInfo, + request: &'a RequestInfo, db_connection: &'a mut DbConn, ) -> impl Stream + 'a { let source_file = &sql_file.source_path; @@ -175,7 +175,7 @@ async fn extract_req_param_as_json( /// This allows recursive calls. pub fn stream_query_results_boxed<'a>( sql_file: &'a ParsedSqlFile, - request: &'a mut RequestInfo, + request: &'a RequestInfo, db_connection: &'a mut DbConn, ) -> Pin + 'a>> { Box::pin(stream_query_results_with_conn( @@ -187,7 +187,7 @@ pub fn stream_query_results_boxed<'a>( async fn execute_set_variable_query<'a>( db_connection: &'a mut DbConn, - request: &'a mut RequestInfo, + request: &'a RequestInfo, variable: &StmtParam, statement: &StmtWithParams, source_file: &Path, @@ -209,7 +209,7 @@ async fn execute_set_variable_query<'a>( } }; - let (vars, name) = vars_and_name(request, variable)?; + let (mut vars, name) = vars_and_name(request, variable)?; if let Some(value) = value { log::debug!("Setting variable {name} to {value:?}"); @@ -223,7 +223,7 @@ async fn execute_set_variable_query<'a>( async fn execute_set_simple_static<'a>( db_connection: &'a mut DbConn, - request: &'a mut RequestInfo, + request: &'a RequestInfo, variable: &StmtParam, value: &SimpleSelectValue, _source_file: &Path, @@ -241,7 +241,7 @@ async fn execute_set_simple_static<'a>( } }; - let (vars, name) = vars_and_name(request, variable)?; + let (mut vars, name) = vars_and_name(request, variable)?; if let Some(value) = value_str { log::debug!("Setting variable {name} to static value {value:?}"); @@ -254,20 +254,13 @@ async fn execute_set_simple_static<'a>( } fn vars_and_name<'a, 'b>( - request: &'a mut RequestInfo, + request: &'a RequestInfo, variable: &'b StmtParam, -) -> anyhow::Result<(&'a mut HashMap, &'b str)> { +) -> anyhow::Result<(std::cell::RefMut<'a, HashMap>, &'b str)> { match variable { - StmtParam::PostOrGet(name) => { - if request.post_variables.contains_key(name) { - log::warn!("Deprecation warning! Setting the value of ${name}, but there is already a form field named :{name}. This will stop working soon. Please rename the variable, or use :{name} directly if you intended to overwrite the posted form field value."); - Ok((&mut request.post_variables, name)) - } else { - Ok((&mut request.get_variables, name)) - } + StmtParam::PostOrGet(name) | StmtParam::Get(name) | StmtParam::Post(name) => { + Ok((request.set_variables.borrow_mut(), name)) } - StmtParam::Get(name) => Ok((&mut request.get_variables, name)), - StmtParam::Post(name) => Ok((&mut request.post_variables, name)), _ => Err(anyhow!( "Only GET and POST variables can be set, not {variable:?}" )), diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index b7665076..4f5fbd4f 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -569,8 +569,8 @@ async fn run_sql<'a>( ) .await .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; - let mut tmp_req = if let Some(variables) = variables { - let mut tmp_req = request.clone_without_variables(); + let tmp_req = if let Some(variables) = variables { + let tmp_req = request.clone_without_variables(); let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { let context = format!( "run_sql: unable to parse the variables argument (line {}, column {})", @@ -579,7 +579,7 @@ async fn run_sql<'a>( ); anyhow::Error::new(err).context(context) })?; - tmp_req.get_variables = variables; + tmp_req.set_variables.replace(variables); tmp_req } else { request.clone() @@ -596,7 +596,7 @@ async fn run_sql<'a>( let mut results_stream = crate::webserver::database::execute_queries::stream_query_results_boxed( &sql_file, - &mut tmp_req, + &tmp_req, db_connection, ); let mut json_results_bytes = Vec::new(); @@ -691,22 +691,30 @@ async fn variables<'a>( ) -> anyhow::Result { Ok(if let Some(get_or_post) = get_or_post { if get_or_post.eq_ignore_ascii_case("get") { - serde_json::to_string(&request.get_variables)? + serde_json::to_string(&request.url_params)? } else if get_or_post.eq_ignore_ascii_case("post") { serde_json::to_string(&request.post_variables)? + } else if get_or_post.eq_ignore_ascii_case("set") { + serde_json::to_string(&*request.set_variables.borrow())? } else { return Err(anyhow!( - "Expected 'get' or 'post' as the argument to sqlpage.all_variables" + "Expected 'get', 'post', or 'set' as the argument to sqlpage.variables" )); } } else { use serde::{ser::SerializeMap, Serializer}; let mut res = Vec::new(); let mut serializer = serde_json::Serializer::new(&mut res); - let len = request.get_variables.len() + request.post_variables.len(); + let set_vars = request.set_variables.borrow(); + let len = request.url_params.len() + request.post_variables.len() + set_vars.len(); let mut ser = serializer.serialize_map(Some(len))?; - let iter = request.get_variables.iter().chain(&request.post_variables); - for (k, v) in iter { + for (k, v) in &request.url_params { + ser.serialize_entry(k, v)?; + } + for (k, v) in &request.post_variables { + ser.serialize_entry(k, v)?; + } + for (k, v) in &*set_vars { ser.serialize_entry(k, v)?; } ser.end()?; diff --git a/src/webserver/database/syntax_tree.rs b/src/webserver/database/syntax_tree.rs index b63aa738..61d2f321 100644 --- a/src/webserver/database/syntax_tree.rs +++ b/src/webserver/database/syntax_tree.rs @@ -156,24 +156,24 @@ pub(super) async fn extract_req_param<'a>( ) -> anyhow::Result>> { Ok(match param { // sync functions - StmtParam::Get(x) => request.get_variables.get(x).map(SingleOrVec::as_json_str), - StmtParam::Post(x) => request.post_variables.get(x).map(SingleOrVec::as_json_str), + StmtParam::Get(x) => request.url_params.get(x).map(SingleOrVec::as_json_str), + StmtParam::Post(x) => { + if let Some(val) = request.set_variables.borrow().get(x) { + Some(Cow::Owned(val.as_json_str().into_owned())) + } else { + request.post_variables.get(x).map(SingleOrVec::as_json_str) + } + } StmtParam::PostOrGet(x) => { - let post_val = request.post_variables.get(x); - let get_val = request.get_variables.get(x); - if let Some(v) = post_val { - if let Some(get_val) = get_val { - log::warn!( - "Deprecation warning! There is both a URL parameter named '{x}' with value '{get_val}' and a form field named '{x}' with value '{v}'. \ - SQLPage is using the value from the form submission, but this is ambiguous, can lead to unexpected behavior, and will stop working in a future version of SQLPage. \ - To fix this, please rename the URL parameter to something else, and reference the form field with :{x}." - ); - } else { - log::warn!("Deprecation warning! ${x} was used to reference a form field value (a POST variable) instead of a URL parameter. This will stop working soon. Please use :{x} instead."); - } - Some(v.as_json_str()) + if let Some(val) = request.set_variables.borrow().get(x) { + Some(Cow::Owned(val.as_json_str().into_owned())) + } else if let Some(url_val) = request.url_params.get(x) { + Some(url_val.as_json_str()) + } else if let Some(post_val) = request.post_variables.get(x) { + log::warn!("Deprecation warning! ${x} was used to reference a form field value (a POST variable) instead of a URL parameter. This will stop working soon. Please use :{x} instead."); + Some(post_val.as_json_str()) } else { - get_val.map(SingleOrVec::as_json_str) + None } } StmtParam::Error(x) => anyhow::bail!("{x}"), diff --git a/src/webserver/http.rs b/src/webserver/http.rs index 9468e11e..b96b8b59 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -174,7 +174,7 @@ async fn render_sql( .clone() .into_inner(); - let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state), server_timing) + let req_param = extract_request_info(srv_req, Arc::clone(&app_state), server_timing) .await .map_err(|e| anyhow_err_to_actix(e, &app_state))?; log::debug!("Received a request with the following parameters: {req_param:?}"); @@ -185,14 +185,14 @@ async fn render_sql( let source_path: PathBuf = sql_file.source_path.clone(); actix_web::rt::spawn(async move { let request_context = RequestContext { - is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"), + is_embedded: req_param.url_params.contains_key("_sqlpage_embed"), source_path, content_security_policy: ContentSecurityPolicy::with_random_nonce(), server_timing: Arc::clone(&req_param.server_timing), }; let mut conn = None; let database_entries_stream = - stream_query_results_with_conn(&sql_file, &mut req_param, &mut conn); + stream_query_results_with_conn(&sql_file, &req_param, &mut conn); let database_entries_stream = stop_at_first_error(database_entries_stream); let response_with_writer = build_response_header_and_stream( Arc::clone(&app_state), diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs index 8f9cbac1..da556a30 100644 --- a/src/webserver/http_request_info.rs +++ b/src/webserver/http_request_info.rs @@ -17,6 +17,7 @@ use actix_web_httpauth::headers::authorization::Authorization; use actix_web_httpauth::headers::authorization::Basic; use anyhow::anyhow; use anyhow::Context; +use std::cell::RefCell; use std::collections::HashMap; use std::net::IpAddr; use std::rc::Rc; @@ -32,8 +33,9 @@ pub struct RequestInfo { pub method: actix_web::http::Method, pub path: String, pub protocol: String, - pub get_variables: ParamMap, + pub url_params: ParamMap, pub post_variables: ParamMap, + pub set_variables: RefCell, pub uploaded_files: Rc>, pub headers: ParamMap, pub client_ip: Option, @@ -53,8 +55,9 @@ impl RequestInfo { method: self.method.clone(), path: self.path.clone(), protocol: self.protocol.clone(), - get_variables: ParamMap::new(), + url_params: ParamMap::new(), post_variables: ParamMap::new(), + set_variables: RefCell::new(ParamMap::new()), uploaded_files: self.uploaded_files.clone(), headers: self.headers.clone(), client_ip: self.client_ip, @@ -72,9 +75,12 @@ impl RequestInfo { impl Clone for RequestInfo { fn clone(&self) -> Self { let mut clone = self.clone_without_variables(); - clone.get_variables.clone_from(&self.get_variables); + clone.url_params.clone_from(&self.url_params); clone.post_variables.clone_from(&self.post_variables); clone + .set_variables + .replace(self.set_variables.borrow().clone()); + clone } } @@ -116,8 +122,9 @@ pub(crate) async fn extract_request_info( method, path: req.path().to_string(), headers: param_map(headers), - get_variables: param_map(get_variables), + url_params: param_map(get_variables), post_variables: param_map(post_variables), + set_variables: RefCell::new(ParamMap::new()), uploaded_files: Rc::new(HashMap::from_iter(uploaded_files)), client_ip, cookies: param_map(cookies), @@ -295,7 +302,7 @@ mod test { .unwrap(); assert_eq!(request_info.post_variables.len(), 0); assert_eq!(request_info.uploaded_files.len(), 0); - assert_eq!(request_info.get_variables.len(), 0); + assert_eq!(request_info.url_params.len(), 0); } #[actix_web::test] @@ -326,7 +333,7 @@ mod test { ); assert_eq!(request_info.uploaded_files.len(), 0); assert_eq!( - request_info.get_variables, + request_info.url_params, vec![( "my_array".to_string(), SingleOrVec::Vec(vec!["5".to_string()]) @@ -374,8 +381,8 @@ mod test { assert_eq!(request_info.uploaded_files.len(), 1); let my_upload = &request_info.uploaded_files["my_uploaded_file"]; assert_eq!(my_upload.file_name.as_ref().unwrap(), "test.txt"); - assert_eq!(request_info.get_variables.len(), 0); + assert_eq!(request_info.url_params.len(), 0); assert_eq!(std::fs::read(&my_upload.file).unwrap(), b"Hello World"); - assert_eq!(request_info.get_variables.len(), 0); + assert_eq!(request_info.url_params.len(), 0); } } diff --git a/tests/sql_test_files/it_works_immutable_variables.sql b/tests/sql_test_files/it_works_immutable_variables.sql new file mode 100644 index 00000000..0c4db43e --- /dev/null +++ b/tests/sql_test_files/it_works_immutable_variables.sql @@ -0,0 +1,33 @@ +SET x = 'set_value'; +SET set_only = 'only_in_set'; + +SELECT 'text' AS component; + +SELECT CASE + WHEN $x = 'set_value' THEN 'It works !' + WHEN $x = '1' THEN 'FAIL: SET variable should shadow URL param' + ELSE 'FAIL: Unexpected value for $x: ' || COALESCE($x, 'NULL') +END AS contents; + +SELECT CASE + WHEN $set_only = 'only_in_set' THEN 'It works !' + ELSE 'FAIL: SET-only variable not found' +END AS contents; + +SELECT CASE + WHEN json_extract(sqlpage.variables('get'), '$.x') = '1' THEN 'It works !' + ELSE 'FAIL: variables(''get'') should return only URL parameters' +END AS contents; + +SELECT CASE + WHEN json_extract(sqlpage.variables('set'), '$.x') = 'set_value' AND + json_extract(sqlpage.variables('set'), '$.set_only') = 'only_in_set' THEN 'It works !' + ELSE 'FAIL: variables(''set'') should return only SET variables' +END AS contents; + +SELECT CASE + WHEN json_extract(sqlpage.variables(), '$.x') = 'set_value' AND + json_extract(sqlpage.variables(), '$.set_only') = 'only_in_set' THEN 'It works !' + ELSE 'FAIL: variables() should merge all with SET taking precedence' +END AS contents; + From 610ce9f5deba94633b5598a1bd1854ac8fffc4cc Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:15:35 +0100 Subject: [PATCH 02/10] Restore deprecation warning for SET on POST variable names --- src/webserver/database/execute_queries.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/webserver/database/execute_queries.rs b/src/webserver/database/execute_queries.rs index 44d24866..285c3764 100644 --- a/src/webserver/database/execute_queries.rs +++ b/src/webserver/database/execute_queries.rs @@ -258,9 +258,13 @@ fn vars_and_name<'a, 'b>( variable: &'b StmtParam, ) -> anyhow::Result<(std::cell::RefMut<'a, HashMap>, &'b str)> { match variable { - StmtParam::PostOrGet(name) | StmtParam::Get(name) | StmtParam::Post(name) => { + StmtParam::PostOrGet(name) | StmtParam::Get(name) => { + if request.post_variables.contains_key(name) { + log::warn!("Deprecation warning! Setting the value of ${name}, but there is already a form field named :{name}. This will stop working soon. Please rename the variable, or use :{name} directly if you intended to overwrite the posted form field value."); + } Ok((request.set_variables.borrow_mut(), name)) } + StmtParam::Post(name) => Ok((request.set_variables.borrow_mut(), name)), _ => Err(anyhow!( "Only GET and POST variables can be set, not {variable:?}" )), From e9458862c6de5c9c18e586834bd46042076f97b1 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:18:50 +0100 Subject: [PATCH 03/10] Restore deprecation warnings for $var accessing POST variables - Warn when both URL and POST have same variable name - Warn when $var is used for POST-only variable (should use :var) --- src/webserver/database/syntax_tree.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/webserver/database/syntax_tree.rs b/src/webserver/database/syntax_tree.rs index 61d2f321..4d989760 100644 --- a/src/webserver/database/syntax_tree.rs +++ b/src/webserver/database/syntax_tree.rs @@ -167,13 +167,23 @@ pub(super) async fn extract_req_param<'a>( StmtParam::PostOrGet(x) => { if let Some(val) = request.set_variables.borrow().get(x) { Some(Cow::Owned(val.as_json_str().into_owned())) - } else if let Some(url_val) = request.url_params.get(x) { - Some(url_val.as_json_str()) - } else if let Some(post_val) = request.post_variables.get(x) { - log::warn!("Deprecation warning! ${x} was used to reference a form field value (a POST variable) instead of a URL parameter. This will stop working soon. Please use :{x} instead."); - Some(post_val.as_json_str()) } else { - None + let url_val = request.url_params.get(x); + let post_val = request.post_variables.get(x); + if let Some(post_val) = post_val { + if let Some(url_val) = url_val { + log::warn!( + "Deprecation warning! There is both a URL parameter named '{x}' with value '{url_val}' and a form field named '{x}' with value '{post_val}'. \ + SQLPage is using the value from the form submission, but this is ambiguous, can lead to unexpected behavior, and will stop working in a future version of SQLPage. \ + To fix this, please rename the URL parameter to something else, and reference the form field with :{x}." + ); + } else { + log::warn!("Deprecation warning! ${x} was used to reference a form field value (a POST variable) instead of a URL parameter. This will stop working soon. Please use :{x} instead."); + } + Some(post_val.as_json_str()) + } else { + url_val.map(SingleOrVec::as_json_str) + } } } StmtParam::Error(x) => anyhow::bail!("{x}"), From 60f5a05446ff323324bd4bf363dde2313239cdde Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:19:49 +0100 Subject: [PATCH 04/10] Simplify run_sql: always use clone_without_variables No need to branch on whether variables are provided since we clone in both cases anyway. --- src/webserver/database/sqlpage_functions/functions.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 4f5fbd4f..d396bd87 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -569,8 +569,8 @@ async fn run_sql<'a>( ) .await .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; - let tmp_req = if let Some(variables) = variables { - let tmp_req = request.clone_without_variables(); + let tmp_req = request.clone_without_variables(); + if let Some(variables) = variables { let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { let context = format!( "run_sql: unable to parse the variables argument (line {}, column {})", @@ -580,10 +580,8 @@ async fn run_sql<'a>( anyhow::Error::new(err).context(context) })?; tmp_req.set_variables.replace(variables); - tmp_req - } else { - request.clone() - }; + } + // else: inherit set_variables from parent (already empty from clone_without_variables) let max_recursion_depth = app_state.config.max_recursion_depth; if tmp_req.clone_depth > max_recursion_depth { anyhow::bail!("Too many nested inclusions. run_sql can include a file that includes another file, but the depth is limited to {max_recursion_depth} levels. \n\ From 210fe0d8c05c7fc0dfb67c3a7c7793ab5bae8386 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:20:16 +0100 Subject: [PATCH 05/10] Revert "Simplify run_sql: always use clone_without_variables" This reverts commit 60f5a05446ff323324bd4bf363dde2313239cdde. --- src/webserver/database/sqlpage_functions/functions.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index d396bd87..4f5fbd4f 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -569,8 +569,8 @@ async fn run_sql<'a>( ) .await .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; - let tmp_req = request.clone_without_variables(); - if let Some(variables) = variables { + let tmp_req = if let Some(variables) = variables { + let tmp_req = request.clone_without_variables(); let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { let context = format!( "run_sql: unable to parse the variables argument (line {}, column {})", @@ -580,8 +580,10 @@ async fn run_sql<'a>( anyhow::Error::new(err).context(context) })?; tmp_req.set_variables.replace(variables); - } - // else: inherit set_variables from parent (already empty from clone_without_variables) + tmp_req + } else { + request.clone() + }; let max_recursion_depth = app_state.config.max_recursion_depth; if tmp_req.clone_depth > max_recursion_depth { anyhow::bail!("Too many nested inclusions. run_sql can include a file that includes another file, but the depth is limited to {max_recursion_depth} levels. \n\ From a8a59a97e2fe07bc95ae4927a3939672c54f9d93 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 02:31:14 +0100 Subject: [PATCH 06/10] WIP: Add ExecutionContext to separate mutable state from RequestInfo This is a draft refactoring to avoid cloning large immutable data (headers, cookies, body) when creating nested execution contexts in run_sql(). Changes: - RequestInfo now contains only immutable request data - ExecutionContext wraps Rc + mutable execution state - Avoids cloning potentially large strings in nested run_sql() calls Status: NOT COMPILING YET - this is work in progress --- src/webserver/http_request_info.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs index da556a30..730b085d 100644 --- a/src/webserver/http_request_info.rs +++ b/src/webserver/http_request_info.rs @@ -35,19 +35,24 @@ pub struct RequestInfo { pub protocol: String, pub url_params: ParamMap, pub post_variables: ParamMap, - pub set_variables: RefCell, pub uploaded_files: Rc>, pub headers: ParamMap, pub client_ip: Option, pub cookies: ParamMap, pub basic_auth: Option, pub app_state: Arc, - pub clone_depth: u8, pub raw_body: Option>, pub oidc_claims: Option, pub server_timing: Arc, } +#[derive(Debug)] +pub struct ExecutionContext { + pub request: Rc, + pub set_variables: RefCell, + pub clone_depth: u8, +} + impl RequestInfo { #[must_use] pub fn clone_without_variables(&self) -> Self { From abc0a22e412fd70dbf835f6f866f48c156bcf9cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Nov 2025 01:51:34 +0000 Subject: [PATCH 07/10] Refactor: Rename RequestInfo to ExecutionContext Co-authored-by: contact --- src/webserver/database/execute_queries.rs | 24 +++--- .../function_definition_macro.rs | 2 +- .../database/sqlpage_functions/functions.rs | 14 ++-- .../database/sqlpage_functions/mod.rs | 2 +- src/webserver/database/syntax_tree.rs | 14 ++-- src/webserver/http.rs | 13 +-- src/webserver/http_request_info.rs | 82 +++++++++++-------- 7 files changed, 84 insertions(+), 67 deletions(-) diff --git a/src/webserver/database/execute_queries.rs b/src/webserver/database/execute_queries.rs index 285c3764..6b48b463 100644 --- a/src/webserver/database/execute_queries.rs +++ b/src/webserver/database/execute_queries.rs @@ -15,7 +15,7 @@ use super::sql::{ use crate::dynamic_component::parse_dynamic_rows; use crate::utils::add_value_to_map; use crate::webserver::database::sql_to_json::row_to_string; -use crate::webserver::http_request_info::RequestInfo; +use crate::webserver::http_request_info::ExecutionContext; use crate::webserver::single_or_vec::SingleOrVec; use super::syntax_tree::{extract_req_param, StmtParam}; @@ -44,7 +44,7 @@ impl Database { pub fn stream_query_results_with_conn<'a>( sql_file: &'a ParsedSqlFile, - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &'a mut DbConn, ) -> impl Stream + 'a { let source_file = &sql_file.source_path; @@ -131,7 +131,7 @@ pub fn stop_at_first_error( /// Executes the sqlpage pseudo-functions contained in a static simple select async fn exec_static_simple_select( columns: &[(String, SimpleSelectValue)], - req: &RequestInfo, + req: &ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result { let mut map = serde_json::Map::with_capacity(columns.len()); @@ -161,7 +161,7 @@ async fn try_rollback_transaction(db_connection: &mut AnyConnection) { /// Returns `Ok(None)` when NULL should be used as the parameter value. async fn extract_req_param_as_json( param: &StmtParam, - request: &RequestInfo, + request: &ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result { if let Some(val) = extract_req_param(param, request, db_connection).await? { @@ -175,7 +175,7 @@ async fn extract_req_param_as_json( /// This allows recursive calls. pub fn stream_query_results_boxed<'a>( sql_file: &'a ParsedSqlFile, - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &'a mut DbConn, ) -> Pin + 'a>> { Box::pin(stream_query_results_with_conn( @@ -187,7 +187,7 @@ pub fn stream_query_results_boxed<'a>( async fn execute_set_variable_query<'a>( db_connection: &'a mut DbConn, - request: &'a RequestInfo, + request: &'a ExecutionContext, variable: &StmtParam, statement: &StmtWithParams, source_file: &Path, @@ -223,7 +223,7 @@ async fn execute_set_variable_query<'a>( async fn execute_set_simple_static<'a>( db_connection: &'a mut DbConn, - request: &'a RequestInfo, + request: &'a ExecutionContext, variable: &StmtParam, value: &SimpleSelectValue, _source_file: &Path, @@ -254,7 +254,7 @@ async fn execute_set_simple_static<'a>( } fn vars_and_name<'a, 'b>( - request: &'a RequestInfo, + request: &'a ExecutionContext, variable: &'b StmtParam, ) -> anyhow::Result<(std::cell::RefMut<'a, HashMap>, &'b str)> { match variable { @@ -274,7 +274,7 @@ fn vars_and_name<'a, 'b>( async fn take_connection<'a>( db: &'a Database, conn: &'a mut DbConn, - request: &RequestInfo, + request: &ExecutionContext, ) -> anyhow::Result<&'a mut PoolConnection> { if let Some(c) = conn { return Ok(c); @@ -349,7 +349,7 @@ fn clone_anyhow_err(source_file: &Path, err: &anyhow::Error) -> anyhow::Error { async fn bind_parameters<'a>( stmt: &'a StmtWithParams, - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result> { let sql = stmt.query.as_str(); @@ -378,7 +378,7 @@ async fn bind_parameters<'a>( } async fn apply_delayed_functions( - request: &RequestInfo, + request: &ExecutionContext, delayed_functions: &[DelayedFunctionCall], item: &mut DbItem, ) -> anyhow::Result<()> { @@ -399,7 +399,7 @@ async fn apply_delayed_functions( } async fn apply_single_delayed_function( - request: &RequestInfo, + request: &ExecutionContext, db_connection: &mut DbConn, f: &DelayedFunctionCall, row: &mut serde_json::Map, diff --git a/src/webserver/database/sqlpage_functions/function_definition_macro.rs b/src/webserver/database/sqlpage_functions/function_definition_macro.rs index c3a419ae..235b7ffa 100644 --- a/src/webserver/database/sqlpage_functions/function_definition_macro.rs +++ b/src/webserver/database/sqlpage_functions/function_definition_macro.rs @@ -55,7 +55,7 @@ macro_rules! sqlpage_functions { pub(crate) async fn evaluate<'a>( &self, #[allow(unused_variables)] - request: &'a RequestInfo, + request: &'a $crate::webserver::http_request_info::ExecutionContext, db_connection: &mut Option>, params: Vec>> ) -> anyhow::Result>> { diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 4f5fbd4f..8ce28d50 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -1,4 +1,4 @@ -use super::RequestInfo; +use super::{ExecutionContext, RequestInfo}; use crate::webserver::{ database::{ blob_to_data_url::vec_to_data_uri_with_mime, execute_queries::DbConn, @@ -45,7 +45,7 @@ super::function_definition_macro::sqlpage_functions! { read_file_as_data_url((&RequestInfo), file_path: Option>); read_file_as_text((&RequestInfo), file_path: Option>); request_method((&RequestInfo)); - run_sql((&RequestInfo, &mut DbConn), sql_file_path: Option>, variables: Option>); + run_sql((&ExecutionContext, &mut DbConn), sql_file_path: Option>, variables: Option>); uploaded_file_mime_type((&RequestInfo), upload_name: Cow); uploaded_file_path((&RequestInfo), upload_name: Cow); @@ -53,7 +53,7 @@ super::function_definition_macro::sqlpage_functions! { url_encode(raw_text: Option>); user_info((&RequestInfo), claim: Cow); - variables((&RequestInfo), get_or_post: Option>); + variables((&ExecutionContext), get_or_post: Option>); version(); request_body((&RequestInfo)); request_body_base64((&RequestInfo)); @@ -549,7 +549,7 @@ async fn request_method(request: &RequestInfo) -> String { } async fn run_sql<'a>( - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, sql_file_path: Option>, variables: Option>, @@ -570,7 +570,6 @@ async fn run_sql<'a>( .await .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; let tmp_req = if let Some(variables) = variables { - let tmp_req = request.clone_without_variables(); let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { let context = format!( "run_sql: unable to parse the variables argument (line {}, column {})", @@ -579,8 +578,7 @@ async fn run_sql<'a>( ); anyhow::Error::new(err).context(context) })?; - tmp_req.set_variables.replace(variables); - tmp_req + request.fork_with_variables(variables) } else { request.clone() }; @@ -686,7 +684,7 @@ async fn url_encode(raw_text: Option>) -> Option> { /// Returns all variables in the request as a JSON object. async fn variables<'a>( - request: &'a RequestInfo, + request: &'a ExecutionContext, get_or_post: Option>, ) -> anyhow::Result { Ok(if let Some(get_or_post) = get_or_post { diff --git a/src/webserver/database/sqlpage_functions/mod.rs b/src/webserver/database/sqlpage_functions/mod.rs index 7683534c..8734c38e 100644 --- a/src/webserver/database/sqlpage_functions/mod.rs +++ b/src/webserver/database/sqlpage_functions/mod.rs @@ -6,7 +6,7 @@ mod url_parameter_deserializer; use sqlparser::ast::FunctionArg; -use crate::webserver::http_request_info::RequestInfo; +use crate::webserver::http_request_info::{ExecutionContext, RequestInfo}; use super::sql::function_args_to_stmt_params; use super::syntax_tree::SqlPageFunctionCall; diff --git a/src/webserver/database/syntax_tree.rs b/src/webserver/database/syntax_tree.rs index 4d989760..cb25b9f7 100644 --- a/src/webserver/database/syntax_tree.rs +++ b/src/webserver/database/syntax_tree.rs @@ -16,7 +16,7 @@ use std::str::FromStr; use sqlparser::ast::FunctionArg; -use crate::webserver::http_request_info::RequestInfo; +use crate::webserver::http_request_info::ExecutionContext; use crate::webserver::single_or_vec::SingleOrVec; use super::{ @@ -112,7 +112,7 @@ impl SqlPageFunctionCall { pub async fn evaluate<'a>( &self, - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { let mut params = Vec::with_capacity(self.arguments.len()); @@ -151,7 +151,7 @@ impl std::fmt::Display for SqlPageFunctionCall { /// Returns `Ok(None)` when NULL should be used as the parameter value. pub(super) async fn extract_req_param<'a>( param: &StmtParam, - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { Ok(match param { @@ -210,7 +210,7 @@ pub(super) async fn extract_req_param<'a>( async fn concat_params<'a>( args: &[StmtParam], - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { let mut result = String::new(); @@ -225,7 +225,7 @@ async fn concat_params<'a>( async fn coalesce_params<'a>( args: &[StmtParam], - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { for arg in args { @@ -238,7 +238,7 @@ async fn coalesce_params<'a>( async fn json_object_params<'a>( args: &[StmtParam], - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { use serde::{ser::SerializeMap, Serializer}; @@ -276,7 +276,7 @@ async fn json_object_params<'a>( async fn json_array_params<'a>( args: &[StmtParam], - request: &'a RequestInfo, + request: &'a ExecutionContext, db_connection: &mut DbConn, ) -> anyhow::Result>> { use serde::{ser::SerializeSeq, Serializer}; diff --git a/src/webserver/http.rs b/src/webserver/http.rs index b96b8b59..6c27b593 100644 --- a/src/webserver/http.rs +++ b/src/webserver/http.rs @@ -174,25 +174,26 @@ async fn render_sql( .clone() .into_inner(); - let req_param = extract_request_info(srv_req, Arc::clone(&app_state), server_timing) + let exec_ctx = extract_request_info(srv_req, Arc::clone(&app_state), server_timing) .await .map_err(|e| anyhow_err_to_actix(e, &app_state))?; - log::debug!("Received a request with the following parameters: {req_param:?}"); + log::debug!("Received a request with the following parameters: {exec_ctx:?}"); - req_param.server_timing.record("parse_req"); + exec_ctx.request().server_timing.record("parse_req"); let (resp_send, resp_recv) = tokio::sync::oneshot::channel::(); let source_path: PathBuf = sql_file.source_path.clone(); actix_web::rt::spawn(async move { + let request_info = exec_ctx.request(); let request_context = RequestContext { - is_embedded: req_param.url_params.contains_key("_sqlpage_embed"), + is_embedded: request_info.url_params.contains_key("_sqlpage_embed"), source_path, content_security_policy: ContentSecurityPolicy::with_random_nonce(), - server_timing: Arc::clone(&req_param.server_timing), + server_timing: Arc::clone(&request_info.server_timing), }; let mut conn = None; let database_entries_stream = - stream_query_results_with_conn(&sql_file, &req_param, &mut conn); + stream_query_results_with_conn(&sql_file, &exec_ctx, &mut conn); let database_entries_stream = stop_at_first_error(database_entries_stream); let response_with_writer = build_response_header_and_stream( Arc::clone(&app_state), diff --git a/src/webserver/http_request_info.rs b/src/webserver/http_request_info.rs index 730b085d..2d4366d8 100644 --- a/src/webserver/http_request_info.rs +++ b/src/webserver/http_request_info.rs @@ -53,39 +53,56 @@ pub struct ExecutionContext { pub clone_depth: u8, } -impl RequestInfo { +impl ExecutionContext { #[must_use] - pub fn clone_without_variables(&self) -> Self { + pub fn new(request: RequestInfo) -> Self { Self { - method: self.method.clone(), - path: self.path.clone(), - protocol: self.protocol.clone(), - url_params: ParamMap::new(), - post_variables: ParamMap::new(), + request: Rc::new(request), set_variables: RefCell::new(ParamMap::new()), - uploaded_files: self.uploaded_files.clone(), - headers: self.headers.clone(), - client_ip: self.client_ip, - cookies: self.cookies.clone(), - basic_auth: self.basic_auth.clone(), - app_state: self.app_state.clone(), + clone_depth: 0, + } + } + + #[must_use] + pub fn fork(&self) -> Self { + Self { + request: Rc::clone(&self.request), + set_variables: RefCell::new(self.set_variables.borrow().clone()), + clone_depth: self.clone_depth + 1, + } + } + + #[must_use] + pub fn fork_with_variables(&self, variables: ParamMap) -> Self { + Self { + request: Rc::clone(&self.request), + set_variables: RefCell::new(variables), clone_depth: self.clone_depth + 1, - raw_body: self.raw_body.clone(), - oidc_claims: self.oidc_claims.clone(), - server_timing: Arc::clone(&self.server_timing), } } + + pub fn request(&self) -> &RequestInfo { + self.request.as_ref() + } } -impl Clone for RequestInfo { +impl Clone for ExecutionContext { fn clone(&self) -> Self { - let mut clone = self.clone_without_variables(); - clone.url_params.clone_from(&self.url_params); - clone.post_variables.clone_from(&self.post_variables); - clone - .set_variables - .replace(self.set_variables.borrow().clone()); - clone + self.fork() + } +} + +impl std::ops::Deref for ExecutionContext { + type Target = RequestInfo; + + fn deref(&self) -> &Self::Target { + self.request() + } +} + +impl<'a> From<&'a ExecutionContext> for &'a RequestInfo { + fn from(ctx: &'a ExecutionContext) -> Self { + ctx.request() } } @@ -93,7 +110,7 @@ pub(crate) async fn extract_request_info( req: &mut ServiceRequest, app_state: Arc, server_timing: ServerTiming, -) -> anyhow::Result { +) -> anyhow::Result { let (http_req, payload) = req.parts_mut(); let method = http_req.method().clone(); let protocol = http_req.connection_info().scheme().to_string(); @@ -123,24 +140,22 @@ pub(crate) async fn extract_request_info( let oidc_claims: Option = req.extensions().get::().cloned(); - Ok(RequestInfo { + Ok(ExecutionContext::new(RequestInfo { method, path: req.path().to_string(), headers: param_map(headers), url_params: param_map(get_variables), post_variables: param_map(post_variables), - set_variables: RefCell::new(ParamMap::new()), uploaded_files: Rc::new(HashMap::from_iter(uploaded_files)), client_ip, cookies: param_map(cookies), basic_auth, app_state, protocol, - clone_depth: 0, raw_body, oidc_claims, server_timing: Arc::new(server_timing), - }) + })) } async fn extract_post_data( @@ -302,9 +317,10 @@ mod test { let mut service_request = TestRequest::default().to_srv_request(); let app_data = Arc::new(AppState::init(&config).await.unwrap()); let server_timing = ServerTiming::default(); - let request_info = extract_request_info(&mut service_request, app_data, server_timing) + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) .await .unwrap(); + let request_info = request_ctx.request(); assert_eq!(request_info.post_variables.len(), 0); assert_eq!(request_info.uploaded_files.len(), 0); assert_eq!(request_info.url_params.len(), 0); @@ -321,9 +337,10 @@ mod test { .to_srv_request(); let app_data = Arc::new(AppState::init(&config).await.unwrap()); let server_timing = ServerTiming::default(); - let request_info = extract_request_info(&mut service_request, app_data, server_timing) + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) .await .unwrap(); + let request_info = request_ctx.request(); assert_eq!( request_info.post_variables, vec![ @@ -371,9 +388,10 @@ mod test { .to_srv_request(); let app_data = Arc::new(AppState::init(&config).await.unwrap()); let server_timing = ServerTiming::enabled(false); - let request_info = extract_request_info(&mut service_request, app_data, server_timing) + let request_ctx = extract_request_info(&mut service_request, app_data, server_timing) .await .unwrap(); + let request_info = request_ctx.request(); assert_eq!( request_info.post_variables, vec![( From 8165795693e649cd9f38985994c3220e9ccb25fd Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 11:53:45 +0100 Subject: [PATCH 08/10] avoid cloning request --- src/webserver/database/sqlpage_functions/functions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 8ce28d50..7fd38f7b 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -580,7 +580,7 @@ async fn run_sql<'a>( })?; request.fork_with_variables(variables) } else { - request.clone() + request.fork() }; let max_recursion_depth = app_state.config.max_recursion_depth; if tmp_req.clone_depth > max_recursion_depth { From 14a15bc33f3666eb0c7b898bd987e1db51b9d8ce Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 12:05:57 +0100 Subject: [PATCH 09/10] improve run_sql invalid variables error message --- src/webserver/database/sqlpage_functions/functions.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 7fd38f7b..ece488b8 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -570,13 +570,8 @@ async fn run_sql<'a>( .await .with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?; let tmp_req = if let Some(variables) = variables { - let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| { - let context = format!( - "run_sql: unable to parse the variables argument (line {}, column {})", - err.line(), - err.column() - ); - anyhow::Error::new(err).context(context) + let variables: ParamMap = serde_json::from_str(&variables).with_context(|| { + format!("run_sql(\'{sql_file_path}\', \'{variables}\'): the second argument should be a JSON object with string keys and values") })?; request.fork_with_variables(variables) } else { From efbe336b60894e451ba9830c343460314319e84a Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 19 Nov 2025 12:25:47 +0100 Subject: [PATCH 10/10] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51687362..026d4b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - `sqlpage.variables('set')` returns only user-defined SET variables as JSON - `sqlpage.variables()` returns all variables merged together, with SET variables taking precedence - **Deprecation warnings**: Using `$var` when both a URL parameter and POST parameter exist with the same name now shows a warning. In a future version, you'll need to explicitly choose between `$var` (URL) and `:var` (POST). + - Improved performance of `sqlpage.run_sql`. + - On a simple test that just runs 4 run_sql calls, the new version is about 2.7x faster (15,708 req/s vs 5,782 req/s) with lower latency (0.637 ms vs 1.730 ms per request). - add support for postgres range types ## v0.39.1 (2025-11-08)