From 0e89124079f54a114d5143f56234dbc0b39406d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Nov 2025 23:46:22 +0000 Subject: [PATCH 01/12] feat: Add sqlpage.set_variable function Co-authored-by: contact --- CHANGELOG.md | 3 + examples/official-site/component.sql | 2 +- examples/official-site/examples/layouts.sql | 4 +- examples/official-site/functions.sql | 4 +- .../sqlpage/migrations/70_set_variable.sql | 60 +++++++++++++++++++ .../database/sqlpage_functions/functions.rs | 34 +++++++++++ .../sql_test_files/it_works_set_variable.sql | 9 ++- .../it_works_set_variable_null.sql | 7 +++ .../it_works_set_variable_replace.sql | 7 +++ 9 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 examples/official-site/sqlpage/migrations/70_set_variable.sql create mode 100644 tests/sql_test_files/it_works_set_variable_null.sql create mode 100644 tests/sql_test_files/it_works_set_variable_replace.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 026d4b94..85fcb39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # CHANGELOG.md ## unrelease + - **New Function**: `sqlpage.set_variable(name, value)` + - Returns a URL with the specified variable set to the given value, preserving other existing variables. + - This is a shorthand for `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables('get'), json_object(name, value)))`. - **Variable System Improvements**: URL and POST parameters are now immutable, preventing accidental modification. User-defined variables created with `SET` remain mutable. - **BREAKING**: `$variable` no longer accesses POST parameters. Use `:variable` instead. - **What changed**: Previously, `$x` would return a POST parameter value if no GET parameter named `x` existed. diff --git a/examples/official-site/component.sql b/examples/official-site/component.sql index 05d4c30c..c10943f3 100644 --- a/examples/official-site/component.sql +++ b/examples/official-site/component.sql @@ -149,6 +149,6 @@ select select name as title, icon, - sqlpage.link('component.sql', json_object('component', name)) as link + sqlpage.set_variable('component', name) as link from component order by name; \ No newline at end of file diff --git a/examples/official-site/examples/layouts.sql b/examples/official-site/examples/layouts.sql index 13e81750..1169d771 100644 --- a/examples/official-site/examples/layouts.sql +++ b/examples/official-site/examples/layouts.sql @@ -30,7 +30,7 @@ For more information on how to use layouts, see the [shell component documentati select 'list' as component, 'Available SQLPage shell layouts' as title; select column1 as title, - sqlpage.link('', json_object('layout', lower(column1), 'sidebar', $sidebar)) as link, + sqlpage.set_variable('layout', lower(column1)) as link, $layout = lower(column1) as active, column3 as icon, column2 as description @@ -43,7 +43,7 @@ from (VALUES select 'list' as component, 'Available Menu layouts' as title; select column1 as title, - sqlpage.link('', json_object('layout', $layout, 'sidebar', column1 = 'Sidebar')) as link, + sqlpage.set_variable('sidebar', column1 = 'Sidebar') as link, (column1 = 'Sidebar' AND $sidebar = 1) OR (column1 = 'Horizontal' AND $sidebar = 0) as active, column2 as description, column3 as icon diff --git a/examples/official-site/functions.sql b/examples/official-site/functions.sql index 943bfe16..5b8f4a8d 100644 --- a/examples/official-site/functions.sql +++ b/examples/official-site/functions.sql @@ -11,7 +11,7 @@ FROM example WHERE component = 'shell' LIMIT 1; select 'breadcrumb' as component; select 'SQLPage' as title, '/' as link, 'Home page' as description; select 'Functions' as title, '/functions.sql' as link, 'List of all functions' as description; -select $function as title, sqlpage.link('functions.sql', json_object('function', $function)) as link where $function IS NOT NULL; +select $function as title, sqlpage.set_variable('function', $function) as link where $function IS NOT NULL; select 'text' as component, 'SQLPage built-in functions' as title where $function IS NULL; select ' @@ -60,7 +60,7 @@ select select name as title, icon, - sqlpage.link('functions.sql', json_object('function', name)) as link + sqlpage.set_variable('function', name) as link from sqlpage_functions where $function IS NOT NULL order by name; \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/70_set_variable.sql b/examples/official-site/sqlpage/migrations/70_set_variable.sql new file mode 100644 index 00000000..1a213656 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/70_set_variable.sql @@ -0,0 +1,60 @@ +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'set_variable', + '0.40.0', + 'variable', + 'Returns a URL that is the same as the current page''s URL, but with a variable set to a new value. + +This function is useful when you want to create a link that changes a parameter on the current page, while preserving other parameters. + +It is equivalent to `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables(''get''), json_object(name, value)))`. + +### Example + +Let''s say you have a list of products, and you want to filter them by category. You can use `sqlpage.set_variable` to create links that change the category filter, without losing other potential filters (like a search query or a sort order). + +```sql +select ''button'' as component, ''sm'' as size, ''center'' as justify; +select + category as title, + sqlpage.set_variable(''category'', category) as link, + case when $category = category then ''primary'' else ''secondary'' end as color +from categories; +``` + +### Parameters + - `name` (TEXT): The name of the variable to set. + - `value` (TEXT): The value to set the variable to. If `NULL` is passed, the variable is removed from the URL. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'set_variable', + 1, + 'name', + 'The name of the variable to set.', + 'TEXT' + ), + ( + 'set_variable', + 2, + 'value', + 'The value to set the variable to.', + 'TEXT' + ); diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index ece488b8..47225d37 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -46,6 +46,7 @@ super::function_definition_macro::sqlpage_functions! { read_file_as_text((&RequestInfo), file_path: Option>); request_method((&RequestInfo)); run_sql((&ExecutionContext, &mut DbConn), sql_file_path: Option>, variables: Option>); + set_variable((&ExecutionContext), name: Cow, value: Option>); uploaded_file_mime_type((&RequestInfo), upload_name: Cow); uploaded_file_path((&RequestInfo), upload_name: Cow); @@ -612,6 +613,39 @@ async fn run_sql<'a>( Ok(Some(Cow::Owned(String::from_utf8(json_results_bytes)?))) } +async fn set_variable<'a>( + context: &'a ExecutionContext, + name: Cow<'a, str>, + value: Option>, +) -> anyhow::Result { + let mut params_map = serde_json::Map::with_capacity(context.url_params.len() + 1); + + for (k, v) in &context.url_params { + params_map.insert(k.clone(), serde_json::to_value(v)?); + } + + if let Some(value) = value { + params_map.insert( + name.into_owned(), + serde_json::Value::String(value.into_owned()), + ); + } else { + params_map.remove(&*name); + } + + let json_val = serde_json::Value::Object(params_map); + let encoded: URLParameters = serde_json::from_value(json_val)?; + + let mut url = context.path.clone(); + let encoded_str = encoded.get(); + if !encoded_str.is_empty() { + url.push('?'); + url.push_str(encoded_str); + } + + Ok(url) +} + #[tokio::test] async fn test_hash_password() { let s = hash_password(Some("password".to_string())) diff --git a/tests/sql_test_files/it_works_set_variable.sql b/tests/sql_test_files/it_works_set_variable.sql index e9ac8286..3f79718d 100644 --- a/tests/sql_test_files/it_works_set_variable.sql +++ b/tests/sql_test_files/it_works_set_variable.sql @@ -1,2 +1,7 @@ -set what_does_it_do = 'wo' || 'rks'; -select 'text' as component, 'It ' || $what_does_it_do || ' !' as contents; +set url = sqlpage.set_variable('y', '2'); +set path = sqlpage.path(); +select 'text' as component, + case + when $url = $path || '?x=1&y=2' OR $url = $path || '?y=2&x=1' THEN 'It works !' + else 'error: ' || $url + end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_null.sql b/tests/sql_test_files/it_works_set_variable_null.sql new file mode 100644 index 00000000..471ea6c6 --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable_null.sql @@ -0,0 +1,7 @@ +set url = sqlpage.set_variable('x', null); +set path = sqlpage.path(); +select 'text' as component, + case + when $url = $path THEN 'It works !' + else 'error: ' || $url + end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_replace.sql b/tests/sql_test_files/it_works_set_variable_replace.sql new file mode 100644 index 00000000..9ad99b85 --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable_replace.sql @@ -0,0 +1,7 @@ +set url = sqlpage.set_variable('x', '2'); +set path = sqlpage.path(); +select 'text' as component, + case + when $url = $path || '?x=2' THEN 'It works !' + else 'error: ' || $url + end as contents; From d1e7cfdb8936564dd90609a3c961019077976e1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Nov 2025 00:17:33 +0000 Subject: [PATCH 02/12] Refactor: Fix set_variable serialization and update tests Co-authored-by: contact --- .../migrations/{70_set_variable.sql => 71_set_variable.sql} | 0 src/webserver/database/sqlpage_functions/functions.rs | 2 +- tests/sql_test_files/it_works_set_variable.sql | 5 ++++- 3 files changed, 5 insertions(+), 2 deletions(-) rename examples/official-site/sqlpage/migrations/{70_set_variable.sql => 71_set_variable.sql} (100%) diff --git a/examples/official-site/sqlpage/migrations/70_set_variable.sql b/examples/official-site/sqlpage/migrations/71_set_variable.sql similarity index 100% rename from examples/official-site/sqlpage/migrations/70_set_variable.sql rename to examples/official-site/sqlpage/migrations/71_set_variable.sql diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 47225d37..0eaf4f76 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -634,7 +634,7 @@ async fn set_variable<'a>( } let json_val = serde_json::Value::Object(params_map); - let encoded: URLParameters = serde_json::from_value(json_val)?; + let encoded: URLParameters = serde_json::from_str(&json_val.to_string())?; let mut url = context.path.clone(); let encoded_str = encoded.get(); diff --git a/tests/sql_test_files/it_works_set_variable.sql b/tests/sql_test_files/it_works_set_variable.sql index 3f79718d..24793b1d 100644 --- a/tests/sql_test_files/it_works_set_variable.sql +++ b/tests/sql_test_files/it_works_set_variable.sql @@ -1,7 +1,10 @@ set url = sqlpage.set_variable('y', '2'); set path = sqlpage.path(); +set x = json_extract(sqlpage.variables('get'), '$.x'); + select 'text' as component, case - when $url = $path || '?x=1&y=2' OR $url = $path || '?y=2&x=1' THEN 'It works !' + when $x is not null AND ($url = $path || '?x=' || $x || '&y=2' OR $url = $path || '?y=2&x=' || $x) THEN 'It works !' + when $x is null AND $url = $path || '?y=2' THEN 'It works !' else 'error: ' || $url end as contents; From c1f4c5503b8b545e8b2bef751864c722a86a371a Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 00:59:53 +0100 Subject: [PATCH 03/12] fix tests: no json_extract on mssql --- ...t_works_set_variable.sql => it_works_set_variable_nomssql.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/sql_test_files/{it_works_set_variable.sql => it_works_set_variable_nomssql.sql} (100%) diff --git a/tests/sql_test_files/it_works_set_variable.sql b/tests/sql_test_files/it_works_set_variable_nomssql.sql similarity index 100% rename from tests/sql_test_files/it_works_set_variable.sql rename to tests/sql_test_files/it_works_set_variable_nomssql.sql From a3c33e456de975b6b4ed28cb71837ca1553d9ff5 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 01:11:08 +0100 Subject: [PATCH 04/12] Refactor: Update URLParameters handling in set_variable function - Replaced serde_json::Map with a custom URLParameters struct for better management of URL parameters. - Introduced methods for handling single and vector values in URLParameters. - Updated tests to reflect changes in the set_variable function's behavior. --- .../database/sqlpage_functions/functions.rs | 19 ++-- .../url_parameter_deserializer.rs | 91 ++++++++++++++----- tests/components/mod.rs | 6 +- 3 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 0eaf4f76..eefbbc2f 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -618,26 +618,21 @@ async fn set_variable<'a>( name: Cow<'a, str>, value: Option>, ) -> anyhow::Result { - let mut params_map = serde_json::Map::with_capacity(context.url_params.len() + 1); + let mut params = URLParameters::new(); for (k, v) in &context.url_params { - params_map.insert(k.clone(), serde_json::to_value(v)?); + if k == &name { + continue; + } + params.push_single_or_vec(k, v.clone()); } if let Some(value) = value { - params_map.insert( - name.into_owned(), - serde_json::Value::String(value.into_owned()), - ); - } else { - params_map.remove(&*name); + params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); } - let json_val = serde_json::Value::Object(params_map); - let encoded: URLParameters = serde_json::from_str(&json_val.to_string())?; - let mut url = context.path.clone(); - let encoded_str = encoded.get(); + let encoded_str = params.get(); if !encoded_str.is_empty() { url.push('?'); url.push_str(encoded_str); diff --git a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs index 74d3b3ed..e4eb0049 100644 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs @@ -1,3 +1,4 @@ +use crate::webserver::single_or_vec::SingleOrVec; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Deserializer}; use serde_json::Value; @@ -6,7 +7,17 @@ use std::fmt; pub struct URLParameters(String); +impl Default for URLParameters { + fn default() -> Self { + Self(String::new()) + } +} + impl URLParameters { + pub fn new() -> Self { + Self::default() + } + fn encode_and_push(&mut self, v: &str) { let val: Cow = percent_encode(v.as_bytes(), NON_ALPHANUMERIC).into(); self.0.push_str(&val); @@ -19,6 +30,49 @@ impl URLParameters { self.0.push('='); self.encode_and_push(value); } + + fn push_array_entry(&mut self, key: &str, value: &str) { + if !self.0.is_empty() { + self.0.push('&'); + } + self.encode_and_push(key); + self.0.push_str("[]="); + self.encode_and_push(value); + } + + fn push_array(&mut self, key: &str, values: Vec) { + for val in values { + let val_str = match val { + Value::String(s) => s, + other => other.to_string(), + }; + self.push_array_entry(key, &val_str); + } + } + + pub fn push_single_or_vec(&mut self, key: &str, val: SingleOrVec) { + match val { + SingleOrVec::Single(v) => self.push_kv(key, &v), + SingleOrVec::Vec(v) => { + for s in v { + self.push_array_entry(key, &s); + } + } + } + } + + fn add_from_json(&mut self, key: &str, raw_json_value: &str) { + if let Ok(str_val) = serde_json::from_str::>>(raw_json_value) { + if let Some(str_val) = str_val { + self.push_kv(key, &str_val); + } + } else if let Ok(vec_val) = serde_json::from_str::>(raw_json_value) { + self.push_array(key, vec_val); + } else { + self.push_kv(key, raw_json_value); + } + } + pub fn get(&self) -> &str { &self.0 } @@ -47,31 +101,7 @@ impl<'de> Deserialize<'de> for URLParameters { while let Some((key, value)) = map.next_entry::, Cow>()? { - let value = value.get(); - if let Ok(str_val) = serde_json::from_str::>>(value) { - if let Some(str_val) = str_val { - out.push_kv(&key, &str_val); - } - } else if let Ok(vec_val) = - serde_json::from_str::>(value) - { - for val in vec_val { - if !out.0.is_empty() { - out.0.push('&'); - } - out.encode_and_push(&key); - out.0.push_str("[]"); - out.0.push('='); - - let val = match val { - Value::String(s) => s, - other => other.to_string(), - }; - out.encode_and_push(&val); - } - } else { - out.push_kv(&key, value); - } + out.add_from_json(&key, value.get()); } Ok(out) @@ -136,3 +166,14 @@ fn test_url_parameters_deserializer_issue_879() { "name=John%20Doe%20%26%20Son%27s&items[]=1&items[]=item%202%20%26%203&items[]=true&special%5Fchar=%25%26%3D%2B%20" ); } + +#[test] +fn test_push_single_or_vec() { + let mut params = URLParameters(String::new()); + params.push_single_or_vec("k", SingleOrVec::Single("v".to_string())); + assert_eq!(params.get(), "k=v"); + + let mut params = URLParameters(String::new()); + params.push_single_or_vec("arr", SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()])); + assert_eq!(params.get(), "arr[]=a&arr[]=b"); +} diff --git a/tests/components/mod.rs b/tests/components/mod.rs index b2d44546..4c6364d6 100644 --- a/tests/components/mod.rs +++ b/tests/components/mod.rs @@ -5,12 +5,8 @@ use crate::common::get_request_to; #[actix_web::test] async fn test_overwrite_variable() -> actix_web::Result<()> { - let req = get_request_to("/tests/sql_test_files/it_works_set_variable.sql") + let req = get_request_to("/tests/sql_test_files/it_works_set_variable_nomssql.sql") .await? - .set_form(std::collections::HashMap::<&str, &str>::from_iter([( - "what_does_it_do", - "does not overwrite variables", - )])) .to_srv_request(); let resp = main_handler(req).await?; From 0786df0bf06f7eb04ad3097c3044ace9573ce7bb Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 10:50:55 +0100 Subject: [PATCH 05/12] cargo fmt --- .../database/sqlpage_functions/url_parameter_deserializer.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs index e4eb0049..bf8160d4 100644 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs @@ -174,6 +174,9 @@ fn test_push_single_or_vec() { assert_eq!(params.get(), "k=v"); let mut params = URLParameters(String::new()); - params.push_single_or_vec("arr", SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()])); + params.push_single_or_vec( + "arr", + SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]), + ); assert_eq!(params.get(), "arr[]=a&arr[]=b"); } From 0d44e3378824f019a6d32538d79d01bd3eb23d84 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 10:56:53 +0100 Subject: [PATCH 06/12] clippy --- .../sqlpage_functions/url_parameter_deserializer.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs index bf8160d4..cefcde50 100644 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs @@ -5,14 +5,9 @@ use serde_json::Value; use std::borrow::Cow; use std::fmt; +#[derive(Default)] pub struct URLParameters(String); -impl Default for URLParameters { - fn default() -> Self { - Self(String::new()) - } -} - impl URLParameters { pub fn new() -> Self { Self::default() From fd469a9a30cd86db05bac29e212a34f340a92323 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 11:23:42 +0100 Subject: [PATCH 07/12] retsore set var test --- tests/sql_test_files/it_works_set_variable.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/sql_test_files/it_works_set_variable.sql diff --git a/tests/sql_test_files/it_works_set_variable.sql b/tests/sql_test_files/it_works_set_variable.sql new file mode 100644 index 00000000..e9ac8286 --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable.sql @@ -0,0 +1,2 @@ +set what_does_it_do = 'wo' || 'rks'; +select 'text' as component, 'It ' || $what_does_it_do || ' !' as contents; From cc27af60a722e08ff9bb03ce658f10b7863b9634 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 11:28:14 +0100 Subject: [PATCH 08/12] remove redundant test --- tests/components/mod.rs | 21 --------------------- tests/mod.rs | 1 - 2 files changed, 22 deletions(-) delete mode 100644 tests/components/mod.rs diff --git a/tests/components/mod.rs b/tests/components/mod.rs deleted file mode 100644 index 4c6364d6..00000000 --- a/tests/components/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -use actix_web::{http::StatusCode, test}; -use sqlpage::webserver::http::main_handler; - -use crate::common::get_request_to; - -#[actix_web::test] -async fn test_overwrite_variable() -> actix_web::Result<()> { - let req = get_request_to("/tests/sql_test_files/it_works_set_variable_nomssql.sql") - .await? - .to_srv_request(); - let resp = main_handler(req).await?; - - assert_eq!(resp.status(), StatusCode::OK); - let body = test::read_body(resp).await; - let body_str = String::from_utf8(body.to_vec()).unwrap(); - assert!( - body_str.contains("It works !"), - "{body_str}\nexpected to contain: It works !" - ); - Ok(()) -} diff --git a/tests/mod.rs b/tests/mod.rs index aeaf5dc9..78672178 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,6 +1,5 @@ mod basic; mod common; -mod components; mod core; mod data_formats; mod errors; From a59a391446999dee42c168ab75dac2af1648839e Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 11:28:52 +0100 Subject: [PATCH 09/12] ensure set_variable only takes into account GET variables, not SET --- tests/sql_test_files/it_works_set_variable_null.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sql_test_files/it_works_set_variable_null.sql b/tests/sql_test_files/it_works_set_variable_null.sql index 471ea6c6..3d2f8ac6 100644 --- a/tests/sql_test_files/it_works_set_variable_null.sql +++ b/tests/sql_test_files/it_works_set_variable_null.sql @@ -1,5 +1,5 @@ -set url = sqlpage.set_variable('x', null); set path = sqlpage.path(); +set url = sqlpage.set_variable('x', null); select 'text' as component, case when $url = $path THEN 'It works !' From 7a65f880b57e6da2a3b3876cce563a50e24cee79 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 11:46:15 +0100 Subject: [PATCH 10/12] factor url parameter setting code --- .../database/sqlpage_functions/functions.rs | 12 ++---------- .../url_parameter_deserializer.rs | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index eefbbc2f..f8d04bc5 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -377,11 +377,7 @@ async fn link<'a>( let encoded = serde_json::from_str::(¶meters).with_context(|| { format!("link: invalid URL parameters: not a valid json object:\n{parameters}") })?; - let encoded_str = encoded.get(); - if !encoded_str.is_empty() { - url.push('?'); - url.push_str(encoded_str); - } + encoded.append_to_url(&mut url); } if let Some(hash) = hash { url.push('#'); @@ -632,11 +628,7 @@ async fn set_variable<'a>( } let mut url = context.path.clone(); - let encoded_str = params.get(); - if !encoded_str.is_empty() { - url.push('?'); - url.push_str(encoded_str); - } + params.append_to_url(&mut url); Ok(url) } diff --git a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs index cefcde50..c6d54180 100644 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ b/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs @@ -68,8 +68,11 @@ impl URLParameters { } } - pub fn get(&self) -> &str { - &self.0 + pub fn append_to_url(&self, url: &mut String) { + if !self.0.is_empty() { + url.push('?'); + url.push_str(&self.0); + } } } @@ -107,6 +110,12 @@ impl<'de> Deserialize<'de> for URLParameters { } } +impl std::fmt::Display for URLParameters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + #[test] fn test_url_parameters_deserializer() { use serde_json::json; @@ -166,12 +175,12 @@ fn test_url_parameters_deserializer_issue_879() { fn test_push_single_or_vec() { let mut params = URLParameters(String::new()); params.push_single_or_vec("k", SingleOrVec::Single("v".to_string())); - assert_eq!(params.get(), "k=v"); + assert_eq!(params.to_string(), "k=v"); let mut params = URLParameters(String::new()); params.push_single_or_vec( "arr", SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]), ); - assert_eq!(params.get(), "arr[]=a&arr[]=b"); + assert_eq!(params.to_string(), "arr[]=a&arr[]=b"); } From 544771d1174e64f5f77cb55ed50185b0b6acba7b Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 11:46:21 +0100 Subject: [PATCH 11/12] v0.40 --- CHANGELOG.md | 8 +++----- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fcb39d..7bd70d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG.md -## unrelease +## 0.40.0 (unreleased) - **New Function**: `sqlpage.set_variable(name, value)` - Returns a URL with the specified variable set to the given value, preserving other existing variables. - This is a shorthand for `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables('get'), json_object(name, value)))`. @@ -9,15 +9,13 @@ - **What changed**: Previously, `$x` would return a POST parameter value if no GET parameter named `x` existed. - **Fix**: Replace `$x` with `:x` when you need to access form field values. - **Example**: Change `SELECT $username` to `SELECT :username` when reading form submissions. - - **BREAKING**: `SET $name` no longer overwrites GET (URL) parameters when a URL parameter with the same name exists. + - **BREAKING**: `SET $name` no longer makes GET (URL) parameters inaccessible when a URL parameter with the same name exists. - **What changed**: `SET $name = 'value'` would previously overwrite the URL parameter `$name`. Now it creates an independent SET variable that shadows the URL parameter. - **Fix**: This is generally the desired behavior. If you need to access the original URL parameter after setting a variable with the same name, extract it from the JSON returned by `sqlpage.variables('get')`. - **Example**: If your URL is `page.sql?name=john`, and you do `SET $name = 'modified'`, then: - `$name` will be `'modified'` (the SET variable) - The original URL parameter is still preserved and accessible: - - PostgreSQL: `sqlpage.variables('get')->>'name'` returns `'john'` - - SQLite: `json_extract(sqlpage.variables('get'), '$.name')` returns `'john'` - - MySQL: `JSON_UNQUOTE(JSON_EXTRACT(sqlpage.variables('get'), '$.name'))` returns `'john'` + - `sqlpage.variables('get')->>'name'` returns `'john'` - **New behavior**: Variable lookup now follows this precedence: - `$variable` checks SET variables first, then URL parameters - `:variable` checks SET variables first, then POST parameters diff --git a/Cargo.lock b/Cargo.lock index de81defc..a8b0326d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "sqlpage" -version = "0.39.1" +version = "0.40.0" dependencies = [ "actix-multipart", "actix-rt", diff --git a/Cargo.toml b/Cargo.toml index 37fd1380..8cfd5ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlpage" -version = "0.39.1" +version = "0.40.0" edition = "2021" description = "Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components." keywords = ["web", "sql", "framework"] From f62cd704d04e6d08567abd3afdf494818be0b912 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Mon, 24 Nov 2025 12:37:10 +0100 Subject: [PATCH 12/12] sqlpage.set_variable links to "?" when no parameter is present - Renamed URLParameters module for clarity and removed the deprecated url_parameter_deserializer. - Updated the set_variable function to return parameters directly instead of appending to a URL. - Adjusted related function calls to reflect changes in URL parameter management. --- CHANGELOG.md | 1 + .../database/sqlpage_functions/functions.rs | 9 ++-- .../database/sqlpage_functions/mod.rs | 2 +- ...eter_deserializer.rs => url_parameters.rs} | 49 +++++++++++++------ .../it_works_set_variable_func.sql | 7 +++ .../it_works_set_variable_func_null.sql | 6 +++ .../it_works_set_variable_func_replace.sql | 6 +++ .../it_works_set_variable_nomssql.sql | 10 ---- .../it_works_set_variable_null.sql | 7 --- .../it_works_set_variable_replace.sql | 7 --- 10 files changed, 57 insertions(+), 47 deletions(-) rename src/webserver/database/sqlpage_functions/{url_parameter_deserializer.rs => url_parameters.rs} (80%) create mode 100644 tests/sql_test_files/it_works_set_variable_func.sql create mode 100644 tests/sql_test_files/it_works_set_variable_func_null.sql create mode 100644 tests/sql_test_files/it_works_set_variable_func_replace.sql delete mode 100644 tests/sql_test_files/it_works_set_variable_nomssql.sql delete mode 100644 tests/sql_test_files/it_works_set_variable_null.sql delete mode 100644 tests/sql_test_files/it_works_set_variable_replace.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd70d5a..9c4353a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## 0.40.0 (unreleased) + - Fixed a bug in `sqlpage.link`: a link with no path (link to the current page) and no url parameter now works as expected. It used to keep the existing url parameters instead of removing them. `sqlpage.link('', '{}')` now returns `'?'` instead of the empty string. - **New Function**: `sqlpage.set_variable(name, value)` - Returns a URL with the specified variable set to the given value, preserving other existing variables. - This is a shorthand for `sqlpage.link(sqlpage.path(), json_patch(sqlpage.variables('get'), json_object(name, value)))`. diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index f8d04bc5..450a85b8 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -2,7 +2,7 @@ use super::{ExecutionContext, RequestInfo}; use crate::webserver::{ database::{ blob_to_data_url::vec_to_data_uri_with_mime, execute_queries::DbConn, - sqlpage_functions::url_parameter_deserializer::URLParameters, + sqlpage_functions::url_parameters::URLParameters, }, http_client::make_http_client, request_variables::ParamMap, @@ -377,7 +377,7 @@ async fn link<'a>( let encoded = serde_json::from_str::(¶meters).with_context(|| { format!("link: invalid URL parameters: not a valid json object:\n{parameters}") })?; - encoded.append_to_url(&mut url); + encoded.append_to_path(&mut url); } if let Some(hash) = hash { url.push('#'); @@ -627,10 +627,7 @@ async fn set_variable<'a>( params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); } - let mut url = context.path.clone(); - params.append_to_url(&mut url); - - Ok(url) + Ok(params.with_empty_path()) } #[tokio::test] diff --git a/src/webserver/database/sqlpage_functions/mod.rs b/src/webserver/database/sqlpage_functions/mod.rs index 8734c38e..27dc6c07 100644 --- a/src/webserver/database/sqlpage_functions/mod.rs +++ b/src/webserver/database/sqlpage_functions/mod.rs @@ -2,7 +2,7 @@ mod function_definition_macro; mod function_traits; pub(super) mod functions; mod http_fetch_request; -mod url_parameter_deserializer; +mod url_parameters; use sqlparser::ast::FunctionArg; diff --git a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs b/src/webserver/database/sqlpage_functions/url_parameters.rs similarity index 80% rename from src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs rename to src/webserver/database/sqlpage_functions/url_parameters.rs index c6d54180..1e30cae3 100644 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ b/src/webserver/database/sqlpage_functions/url_parameters.rs @@ -10,26 +10,28 @@ pub struct URLParameters(String); impl URLParameters { pub fn new() -> Self { - Self::default() + Self(String::new()) } fn encode_and_push(&mut self, v: &str) { let val: Cow = percent_encode(v.as_bytes(), NON_ALPHANUMERIC).into(); self.0.push_str(&val); } + + fn start_new_pair(&mut self) { + let char = if self.0.is_empty() { '?' } else { '&' }; + self.0.push(char); + } + fn push_kv(&mut self, key: &str, value: &str) { - if !self.0.is_empty() { - self.0.push('&'); - } + self.start_new_pair(); self.encode_and_push(key); self.0.push('='); self.encode_and_push(value); } fn push_array_entry(&mut self, key: &str, value: &str) { - if !self.0.is_empty() { - self.0.push('&'); - } + self.start_new_pair(); self.encode_and_push(key); self.0.push_str("[]="); self.encode_and_push(value); @@ -68,9 +70,18 @@ impl URLParameters { } } - pub fn append_to_url(&self, url: &mut String) { - if !self.0.is_empty() { - url.push('?'); + pub fn with_empty_path(self) -> String { + if self.0.is_empty() { + "?".to_string() // Link to the current page without parameters + } else { + self.0 // Link to the current page with specific parameters + } + } + + pub fn append_to_path(self, url: &mut String) { + if url.is_empty() { + *url = self.with_empty_path(); + } else { url.push_str(&self.0); } } @@ -116,6 +127,12 @@ impl std::fmt::Display for URLParameters { } } +impl From for String { + fn from(value: URLParameters) -> Self { + value.0 + } +} + #[test] fn test_url_parameters_deserializer() { use serde_json::json; @@ -128,7 +145,7 @@ fn test_url_parameters_deserializer() { let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); assert_eq!( url_parameters.0, - "x=hello%20world&num=123&arr[]=1&arr[]=2&arr[]=3" + "?x=hello%20world&num=123&arr[]=1&arr[]=2&arr[]=3" ); } @@ -141,7 +158,7 @@ fn test_url_parameters_null() { }); let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); - assert_eq!(url_parameters.0, "x=hello"); + assert_eq!(url_parameters.0, "?x=hello"); } #[test] @@ -152,7 +169,7 @@ fn test_url_parameters_deserializer_special_chars() { }); let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); - assert_eq!(url_parameters.0, "chars[]=%0A&chars[]=%20&chars[]=%22"); + assert_eq!(url_parameters.0, "?chars[]=%0A&chars[]=%20&chars[]=%22"); } #[test] @@ -167,7 +184,7 @@ fn test_url_parameters_deserializer_issue_879() { let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); assert_eq!( url_parameters.0, - "name=John%20Doe%20%26%20Son%27s&items[]=1&items[]=item%202%20%26%203&items[]=true&special%5Fchar=%25%26%3D%2B%20" + "?name=John%20Doe%20%26%20Son%27s&items[]=1&items[]=item%202%20%26%203&items[]=true&special%5Fchar=%25%26%3D%2B%20" ); } @@ -175,12 +192,12 @@ fn test_url_parameters_deserializer_issue_879() { fn test_push_single_or_vec() { let mut params = URLParameters(String::new()); params.push_single_or_vec("k", SingleOrVec::Single("v".to_string())); - assert_eq!(params.to_string(), "k=v"); + assert_eq!(params.to_string(), "?k=v"); let mut params = URLParameters(String::new()); params.push_single_or_vec( "arr", SingleOrVec::Vec(vec!["a".to_string(), "b".to_string()]), ); - assert_eq!(params.to_string(), "arr[]=a&arr[]=b"); + assert_eq!(params.to_string(), "?arr[]=a&arr[]=b"); } diff --git a/tests/sql_test_files/it_works_set_variable_func.sql b/tests/sql_test_files/it_works_set_variable_func.sql new file mode 100644 index 00000000..b3b2771a --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable_func.sql @@ -0,0 +1,7 @@ +set url = sqlpage.set_variable('y', '2'); + +select 'text' as component, + case $url + when '?x=1&y=2' THEN 'It works !' + else 'It failed ! Expected ?x=1&y=2 but got ' || coalesce($url, 'NULL') + end as contents; \ No newline at end of file diff --git a/tests/sql_test_files/it_works_set_variable_func_null.sql b/tests/sql_test_files/it_works_set_variable_func_null.sql new file mode 100644 index 00000000..21800dc4 --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable_func_null.sql @@ -0,0 +1,6 @@ +set url = sqlpage.set_variable('x', null); +select 'text' as component, + case $url + when '?' THEN 'It works !' + else 'It failed ! Expected "?" but got ' || coalesce('"' || $url || '"', 'NULL') + end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_func_replace.sql b/tests/sql_test_files/it_works_set_variable_func_replace.sql new file mode 100644 index 00000000..6d71a44b --- /dev/null +++ b/tests/sql_test_files/it_works_set_variable_func_replace.sql @@ -0,0 +1,6 @@ +set url = sqlpage.set_variable('x', '2'); +select 'text' as component, + case $url + when '?x=2' THEN 'It works !' + else 'It failed ! Expected ?x=2 but got ' || $url + end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_nomssql.sql b/tests/sql_test_files/it_works_set_variable_nomssql.sql deleted file mode 100644 index 24793b1d..00000000 --- a/tests/sql_test_files/it_works_set_variable_nomssql.sql +++ /dev/null @@ -1,10 +0,0 @@ -set url = sqlpage.set_variable('y', '2'); -set path = sqlpage.path(); -set x = json_extract(sqlpage.variables('get'), '$.x'); - -select 'text' as component, - case - when $x is not null AND ($url = $path || '?x=' || $x || '&y=2' OR $url = $path || '?y=2&x=' || $x) THEN 'It works !' - when $x is null AND $url = $path || '?y=2' THEN 'It works !' - else 'error: ' || $url - end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_null.sql b/tests/sql_test_files/it_works_set_variable_null.sql deleted file mode 100644 index 3d2f8ac6..00000000 --- a/tests/sql_test_files/it_works_set_variable_null.sql +++ /dev/null @@ -1,7 +0,0 @@ -set path = sqlpage.path(); -set url = sqlpage.set_variable('x', null); -select 'text' as component, - case - when $url = $path THEN 'It works !' - else 'error: ' || $url - end as contents; diff --git a/tests/sql_test_files/it_works_set_variable_replace.sql b/tests/sql_test_files/it_works_set_variable_replace.sql deleted file mode 100644 index 9ad99b85..00000000 --- a/tests/sql_test_files/it_works_set_variable_replace.sql +++ /dev/null @@ -1,7 +0,0 @@ -set url = sqlpage.set_variable('x', '2'); -set path = sqlpage.path(); -select 'text' as component, - case - when $url = $path || '?x=2' THEN 'It works !' - else 'error: ' || $url - end as contents;