diff --git a/CHANGELOG.md b/CHANGELOG.md index 026d4b94..9c4353a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,22 @@ # CHANGELOG.md -## unrelease +## 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)))`. - **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. - **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"] 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/72_set_variable.sql b/examples/official-site/sqlpage/migrations/72_set_variable.sql new file mode 100644 index 00000000..1a213656 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/72_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..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, @@ -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); @@ -376,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_path(&mut url); } if let Some(hash) = hash { url.push('#'); @@ -612,6 +609,27 @@ 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 = URLParameters::new(); + + for (k, v) in &context.url_params { + if k == &name { + continue; + } + params.push_single_or_vec(k, v.clone()); + } + + if let Some(value) = value { + params.push_single_or_vec(&name, SingleOrVec::Single(value.into_owned())); + } + + Ok(params.with_empty_path()) +} + #[tokio::test] async fn test_hash_password() { let s = hash_password(Some("password".to_string())) 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_parameter_deserializer.rs deleted file mode 100644 index 74d3b3ed..00000000 --- a/src/webserver/database/sqlpage_functions/url_parameter_deserializer.rs +++ /dev/null @@ -1,138 +0,0 @@ -use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; -use serde::{Deserialize, Deserializer}; -use serde_json::Value; -use std::borrow::Cow; -use std::fmt; - -pub struct URLParameters(String); - -impl URLParameters { - 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 push_kv(&mut self, key: &str, value: &str) { - if !self.0.is_empty() { - self.0.push('&'); - } - self.encode_and_push(key); - self.0.push('='); - self.encode_and_push(value); - } - pub fn get(&self) -> &str { - &self.0 - } -} - -impl<'de> Deserialize<'de> for URLParameters { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - // Visit an object and append keys and values to the string - struct URLParametersVisitor; - - impl<'de> serde::de::Visitor<'de> for URLParametersVisitor { - type Value = URLParameters; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a sequence") - } - - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut out = URLParameters(String::new()); - 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); - } - } - - Ok(out) - } - } - - deserializer.deserialize_map(URLParametersVisitor) - } -} - -#[test] -fn test_url_parameters_deserializer() { - use serde_json::json; - let json = json!({ - "x": "hello world", - "num": 123, - "arr": [1, 2, 3], - }); - - 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" - ); -} - -#[test] -fn test_url_parameters_null() { - use serde_json::json; - let json = json!({ - "null_should_be_omitted": null, - "x": "hello", - }); - - let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); - assert_eq!(url_parameters.0, "x=hello"); -} - -#[test] -fn test_url_parameters_deserializer_special_chars() { - use serde_json::json; - let json = json!({ - "chars": ["\n", " ", "\""], - }); - - let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); - assert_eq!(url_parameters.0, "chars[]=%0A&chars[]=%20&chars[]=%22"); -} - -#[test] -fn test_url_parameters_deserializer_issue_879() { - use serde_json::json; - let json = json!({ - "name": "John Doe & Son's", - "items": [1, "item 2 & 3", true], - "special_char": "%&=+ ", - }); - - 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" - ); -} diff --git a/src/webserver/database/sqlpage_functions/url_parameters.rs b/src/webserver/database/sqlpage_functions/url_parameters.rs new file mode 100644 index 00000000..1e30cae3 --- /dev/null +++ b/src/webserver/database/sqlpage_functions/url_parameters.rs @@ -0,0 +1,203 @@ +use crate::webserver::single_or_vec::SingleOrVec; +use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; +use std::borrow::Cow; +use std::fmt; + +#[derive(Default)] +pub struct URLParameters(String); + +impl URLParameters { + pub fn new() -> Self { + 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) { + 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) { + self.start_new_pair(); + 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 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); + } + } +} + +impl<'de> Deserialize<'de> for URLParameters { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Visit an object and append keys and values to the string + struct URLParametersVisitor; + + impl<'de> serde::de::Visitor<'de> for URLParametersVisitor { + type Value = URLParameters; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a sequence") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut out = URLParameters(String::new()); + while let Some((key, value)) = + map.next_entry::, Cow>()? + { + out.add_from_json(&key, value.get()); + } + + Ok(out) + } + } + + deserializer.deserialize_map(URLParametersVisitor) + } +} + +impl std::fmt::Display for URLParameters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for String { + fn from(value: URLParameters) -> Self { + value.0 + } +} + +#[test] +fn test_url_parameters_deserializer() { + use serde_json::json; + let json = json!({ + "x": "hello world", + "num": 123, + "arr": [1, 2, 3], + }); + + 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" + ); +} + +#[test] +fn test_url_parameters_null() { + use serde_json::json; + let json = json!({ + "null_should_be_omitted": null, + "x": "hello", + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!(url_parameters.0, "?x=hello"); +} + +#[test] +fn test_url_parameters_deserializer_special_chars() { + use serde_json::json; + let json = json!({ + "chars": ["\n", " ", "\""], + }); + + let url_parameters: URLParameters = serde_json::from_value(json).unwrap(); + assert_eq!(url_parameters.0, "?chars[]=%0A&chars[]=%20&chars[]=%22"); +} + +#[test] +fn test_url_parameters_deserializer_issue_879() { + use serde_json::json; + let json = json!({ + "name": "John Doe & Son's", + "items": [1, "item 2 & 3", true], + "special_char": "%&=+ ", + }); + + 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" + ); +} + +#[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.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"); +} diff --git a/tests/components/mod.rs b/tests/components/mod.rs deleted file mode 100644 index b2d44546..00000000 --- a/tests/components/mod.rs +++ /dev/null @@ -1,25 +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.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?; - - 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; 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;