Skip to content

Commit b11e7bf

Browse files
authored
Make URL and POST parameters immutable (#1109)
* 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) * Restore deprecation warning for SET on POST variable names * 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) * Simplify run_sql: always use clone_without_variables No need to branch on whether variables are provided since we clone in both cases anyway. * Revert "Simplify run_sql: always use clone_without_variables" This reverts commit 60f5a05. * Fix cross-database test compatibility for immutable variables Renamed test to run only on SQLite since json_extract() is SQLite-specific. Other databases (PostgreSQL, MySQL, MSSQL) have different JSON functions. * Fix test to work across all databases without json_extract PostgreSQL doesn't have json_extract, so compare the full JSON string instead. * Document variable system improvements in CHANGELOG * Make CHANGELOG more explicit about breaking changes with examples * Fix CHANGELOG: SET overwrites GET parameters, not POST * Add database-specific examples for accessing original URL parameters
1 parent 7cbf503 commit b11e7bf

File tree

9 files changed

+167
-58
lines changed

9 files changed

+167
-58
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
# CHANGELOG.md
22

33
## unrelease
4+
- **Variable System Improvements**: URL and POST parameters are now immutable, preventing accidental modification. User-defined variables created with `SET` remain mutable.
5+
- **BREAKING**: `$variable` no longer accesses POST parameters. Use `:variable` instead.
6+
- **What changed**: Previously, `$x` would return a POST parameter value if no GET parameter named `x` existed.
7+
- **Fix**: Replace `$x` with `:x` when you need to access form field values.
8+
- **Example**: Change `SELECT $username` to `SELECT :username` when reading form submissions.
9+
- **BREAKING**: `SET $name` no longer overwrites GET (URL) parameters when a URL parameter with the same name exists.
10+
- **What changed**: `SET $name = 'value'` would previously overwrite the URL parameter `$name`. Now it creates an independent SET variable that shadows the URL parameter.
11+
- **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')`.
12+
- **Example**: If your URL is `page.sql?name=john`, and you do `SET $name = 'modified'`, then:
13+
- `$name` will be `'modified'` (the SET variable)
14+
- The original URL parameter is still preserved and accessible:
15+
- PostgreSQL: `sqlpage.variables('get')->>'name'` returns `'john'`
16+
- SQLite: `json_extract(sqlpage.variables('get'), '$.name')` returns `'john'`
17+
- MySQL: `JSON_UNQUOTE(JSON_EXTRACT(sqlpage.variables('get'), '$.name'))` returns `'john'`
18+
- **New behavior**: Variable lookup now follows this precedence:
19+
- `$variable` checks SET variables first, then URL parameters
20+
- `:variable` checks SET variables first, then POST parameters
21+
- SET variables always shadow URL/POST parameters with the same name
22+
- **New sqlpage.variables() filters**:
23+
- `sqlpage.variables('get')` returns only URL parameters as JSON
24+
- `sqlpage.variables('post')` returns only POST parameters as JSON
25+
- `sqlpage.variables('set')` returns only user-defined SET variables as JSON
26+
- `sqlpage.variables()` returns all variables merged together, with SET variables taking precedence
27+
- **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).
428
- add support for postgres range types
529

630
## v0.39.1 (2025-11-08)

examples/official-site/extensions-to-sql.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,15 @@ SELECT (select 1) AS one;
7575
## Variables
7676

7777
SQLPage communicates information about incoming HTTP requests to your SQL code through prepared statement variables.
78-
You can use
79-
- `$var` to reference a GET variable (an URL parameter),
80-
- `:var` to reference a POST variable (a value filled by an user in a form field),
81-
- `set var = ...` to set the value of `$var`.
78+
79+
### Variable Types and Mutability
80+
81+
There are two types of variables in SQLPage:
82+
83+
1. **Request parameters** (immutable): URL parameters and form data from the HTTP request
84+
2. **User-defined variables** (mutable): Variables created with the `SET` command
85+
86+
Request parameters cannot be modified after the request is received. This ensures the original request data remains intact throughout request processing.
8287

8388
### POST parameters
8489

@@ -111,20 +116,30 @@ When a URL parameter is not set, its value is `NULL`.
111116

112117
### The SET command
113118

114-
`SET` stores a value in SQLPage (not in the database). Only strings and `NULL` are stored.
119+
`SET` creates or updates a user-defined variable in SQLPage (not in the database). Only strings and `NULL` are stored.
115120

116121
```sql
117122
-- Give a default value to a variable
118123
SET post_id = COALESCE($post_id, 0);
124+
125+
-- User-defined variables shadow URL parameters with the same name
126+
SET my_var = 'custom value'; -- This value takes precedence over ?my_var=...
119127
```
120128

129+
**Variable Lookup Precedence:**
130+
- `$var`: checks user-defined variables first, then URL parameters
131+
- `:var`: checks user-defined variables first, then POST parameters
132+
133+
This means `SET` variables always take precedence over request parameters when using `$var` or `:var` syntax.
134+
135+
**How SET works:**
121136
- If the right-hand side is purely literals/variables, SQLPage computes it directly. See the section about *static simple select* above.
122137
- 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.
123138

124139
Only a single textual value (**string or `NULL`**) is stored.
125-
`set id = 1` will store the string `'1'`, not the number `1`.
140+
`SET id = 1` will store the string `'1'`, not the number `1`.
126141

127-
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`.
142+
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`.
128143

129144
Complex structures can be stored as json strings.
130145

examples/official-site/sqlpage/migrations/20_variables_function.sql

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,27 @@ VALUES (
99
'variables',
1010
'0.15.0',
1111
'variable',
12-
'Returns a JSON string containing all variables passed as URL parameters or posted through a form.
12+
'Returns a JSON string containing variables from the HTTP request and user-defined variables.
1313
1414
The database''s json handling functions can then be used to process the data.
1515
16+
## Variable Types
17+
18+
SQLPage distinguishes between three types of variables:
19+
20+
- **GET variables**: URL parameters from the query string (immutable)
21+
- **POST variables**: Form data from POST requests (immutable)
22+
- **SET variables**: User-defined variables created with the `SET` command (mutable)
23+
24+
## Usage
25+
26+
- `sqlpage.variables()` - returns all variables (GET, POST, and SET combined, with SET variables taking precedence)
27+
- `sqlpage.variables(''get'')` - returns only URL parameters
28+
- `sqlpage.variables(''post'')` - returns only POST form data
29+
- `sqlpage.variables(''set'')` - returns only user-defined variables created with `SET`
30+
31+
When a SET variable has the same name as a GET or POST variable, the SET variable takes precedence in the combined result.
32+
1633
## Example: a form with a variable number of fields
1734
1835
### Making a form based on questions in a database table
@@ -95,6 +112,6 @@ VALUES (
95112
'variables',
96113
1,
97114
'method',
98-
'Optional. The HTTP request method (GET or POST). Must be a literal string. When not provided, all variables are returned.',
115+
'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.',
99116
'TEXT'
100117
);

src/webserver/database/execute_queries.rs

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ impl Database {
4444

4545
pub fn stream_query_results_with_conn<'a>(
4646
sql_file: &'a ParsedSqlFile,
47-
request: &'a mut RequestInfo,
47+
request: &'a RequestInfo,
4848
db_connection: &'a mut DbConn,
4949
) -> impl Stream<Item = DbItem> + 'a {
5050
let source_file = &sql_file.source_path;
@@ -175,7 +175,7 @@ async fn extract_req_param_as_json(
175175
/// This allows recursive calls.
176176
pub fn stream_query_results_boxed<'a>(
177177
sql_file: &'a ParsedSqlFile,
178-
request: &'a mut RequestInfo,
178+
request: &'a RequestInfo,
179179
db_connection: &'a mut DbConn,
180180
) -> Pin<Box<dyn Stream<Item = DbItem> + 'a>> {
181181
Box::pin(stream_query_results_with_conn(
@@ -187,7 +187,7 @@ pub fn stream_query_results_boxed<'a>(
187187

188188
async fn execute_set_variable_query<'a>(
189189
db_connection: &'a mut DbConn,
190-
request: &'a mut RequestInfo,
190+
request: &'a RequestInfo,
191191
variable: &StmtParam,
192192
statement: &StmtWithParams,
193193
source_file: &Path,
@@ -209,7 +209,7 @@ async fn execute_set_variable_query<'a>(
209209
}
210210
};
211211

212-
let (vars, name) = vars_and_name(request, variable)?;
212+
let (mut vars, name) = vars_and_name(request, variable)?;
213213

214214
if let Some(value) = value {
215215
log::debug!("Setting variable {name} to {value:?}");
@@ -223,7 +223,7 @@ async fn execute_set_variable_query<'a>(
223223

224224
async fn execute_set_simple_static<'a>(
225225
db_connection: &'a mut DbConn,
226-
request: &'a mut RequestInfo,
226+
request: &'a RequestInfo,
227227
variable: &StmtParam,
228228
value: &SimpleSelectValue,
229229
_source_file: &Path,
@@ -241,7 +241,7 @@ async fn execute_set_simple_static<'a>(
241241
}
242242
};
243243

244-
let (vars, name) = vars_and_name(request, variable)?;
244+
let (mut vars, name) = vars_and_name(request, variable)?;
245245

246246
if let Some(value) = value_str {
247247
log::debug!("Setting variable {name} to static value {value:?}");
@@ -254,20 +254,17 @@ async fn execute_set_simple_static<'a>(
254254
}
255255

256256
fn vars_and_name<'a, 'b>(
257-
request: &'a mut RequestInfo,
257+
request: &'a RequestInfo,
258258
variable: &'b StmtParam,
259-
) -> anyhow::Result<(&'a mut HashMap<String, SingleOrVec>, &'b str)> {
259+
) -> anyhow::Result<(std::cell::RefMut<'a, HashMap<String, SingleOrVec>>, &'b str)> {
260260
match variable {
261-
StmtParam::PostOrGet(name) => {
261+
StmtParam::PostOrGet(name) | StmtParam::Get(name) => {
262262
if request.post_variables.contains_key(name) {
263263
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.");
264-
Ok((&mut request.post_variables, name))
265-
} else {
266-
Ok((&mut request.get_variables, name))
267264
}
265+
Ok((request.set_variables.borrow_mut(), name))
268266
}
269-
StmtParam::Get(name) => Ok((&mut request.get_variables, name)),
270-
StmtParam::Post(name) => Ok((&mut request.post_variables, name)),
267+
StmtParam::Post(name) => Ok((request.set_variables.borrow_mut(), name)),
271268
_ => Err(anyhow!(
272269
"Only GET and POST variables can be set, not {variable:?}"
273270
)),

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -569,8 +569,8 @@ async fn run_sql<'a>(
569569
)
570570
.await
571571
.with_context(|| format!("run_sql: invalid path {sql_file_path:?}"))?;
572-
let mut tmp_req = if let Some(variables) = variables {
573-
let mut tmp_req = request.clone_without_variables();
572+
let tmp_req = if let Some(variables) = variables {
573+
let tmp_req = request.clone_without_variables();
574574
let variables: ParamMap = serde_json::from_str(&variables).map_err(|err| {
575575
let context = format!(
576576
"run_sql: unable to parse the variables argument (line {}, column {})",
@@ -579,7 +579,7 @@ async fn run_sql<'a>(
579579
);
580580
anyhow::Error::new(err).context(context)
581581
})?;
582-
tmp_req.get_variables = variables;
582+
tmp_req.set_variables.replace(variables);
583583
tmp_req
584584
} else {
585585
request.clone()
@@ -596,7 +596,7 @@ async fn run_sql<'a>(
596596
let mut results_stream =
597597
crate::webserver::database::execute_queries::stream_query_results_boxed(
598598
&sql_file,
599-
&mut tmp_req,
599+
&tmp_req,
600600
db_connection,
601601
);
602602
let mut json_results_bytes = Vec::new();
@@ -691,22 +691,30 @@ async fn variables<'a>(
691691
) -> anyhow::Result<String> {
692692
Ok(if let Some(get_or_post) = get_or_post {
693693
if get_or_post.eq_ignore_ascii_case("get") {
694-
serde_json::to_string(&request.get_variables)?
694+
serde_json::to_string(&request.url_params)?
695695
} else if get_or_post.eq_ignore_ascii_case("post") {
696696
serde_json::to_string(&request.post_variables)?
697+
} else if get_or_post.eq_ignore_ascii_case("set") {
698+
serde_json::to_string(&*request.set_variables.borrow())?
697699
} else {
698700
return Err(anyhow!(
699-
"Expected 'get' or 'post' as the argument to sqlpage.all_variables"
701+
"Expected 'get', 'post', or 'set' as the argument to sqlpage.variables"
700702
));
701703
}
702704
} else {
703705
use serde::{ser::SerializeMap, Serializer};
704706
let mut res = Vec::new();
705707
let mut serializer = serde_json::Serializer::new(&mut res);
706-
let len = request.get_variables.len() + request.post_variables.len();
708+
let set_vars = request.set_variables.borrow();
709+
let len = request.url_params.len() + request.post_variables.len() + set_vars.len();
707710
let mut ser = serializer.serialize_map(Some(len))?;
708-
let iter = request.get_variables.iter().chain(&request.post_variables);
709-
for (k, v) in iter {
711+
for (k, v) in &request.url_params {
712+
ser.serialize_entry(k, v)?;
713+
}
714+
for (k, v) in &request.post_variables {
715+
ser.serialize_entry(k, v)?;
716+
}
717+
for (k, v) in &*set_vars {
710718
ser.serialize_entry(k, v)?;
711719
}
712720
ser.end()?;

src/webserver/database/syntax_tree.rs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -156,24 +156,34 @@ pub(super) async fn extract_req_param<'a>(
156156
) -> anyhow::Result<Option<Cow<'a, str>>> {
157157
Ok(match param {
158158
// sync functions
159-
StmtParam::Get(x) => request.get_variables.get(x).map(SingleOrVec::as_json_str),
160-
StmtParam::Post(x) => request.post_variables.get(x).map(SingleOrVec::as_json_str),
159+
StmtParam::Get(x) => request.url_params.get(x).map(SingleOrVec::as_json_str),
160+
StmtParam::Post(x) => {
161+
if let Some(val) = request.set_variables.borrow().get(x) {
162+
Some(Cow::Owned(val.as_json_str().into_owned()))
163+
} else {
164+
request.post_variables.get(x).map(SingleOrVec::as_json_str)
165+
}
166+
}
161167
StmtParam::PostOrGet(x) => {
162-
let post_val = request.post_variables.get(x);
163-
let get_val = request.get_variables.get(x);
164-
if let Some(v) = post_val {
165-
if let Some(get_val) = get_val {
166-
log::warn!(
167-
"Deprecation warning! There is both a URL parameter named '{x}' with value '{get_val}' and a form field named '{x}' with value '{v}'. \
168-
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. \
169-
To fix this, please rename the URL parameter to something else, and reference the form field with :{x}."
170-
);
168+
if let Some(val) = request.set_variables.borrow().get(x) {
169+
Some(Cow::Owned(val.as_json_str().into_owned()))
170+
} else {
171+
let url_val = request.url_params.get(x);
172+
let post_val = request.post_variables.get(x);
173+
if let Some(post_val) = post_val {
174+
if let Some(url_val) = url_val {
175+
log::warn!(
176+
"Deprecation warning! There is both a URL parameter named '{x}' with value '{url_val}' and a form field named '{x}' with value '{post_val}'. \
177+
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. \
178+
To fix this, please rename the URL parameter to something else, and reference the form field with :{x}."
179+
);
180+
} else {
181+
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.");
182+
}
183+
Some(post_val.as_json_str())
171184
} else {
172-
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.");
185+
url_val.map(SingleOrVec::as_json_str)
173186
}
174-
Some(v.as_json_str())
175-
} else {
176-
get_val.map(SingleOrVec::as_json_str)
177187
}
178188
}
179189
StmtParam::Error(x) => anyhow::bail!("{x}"),

src/webserver/http.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ async fn render_sql(
174174
.clone()
175175
.into_inner();
176176

177-
let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state), server_timing)
177+
let req_param = extract_request_info(srv_req, Arc::clone(&app_state), server_timing)
178178
.await
179179
.map_err(|e| anyhow_err_to_actix(e, &app_state))?;
180180
log::debug!("Received a request with the following parameters: {req_param:?}");
@@ -185,14 +185,14 @@ async fn render_sql(
185185
let source_path: PathBuf = sql_file.source_path.clone();
186186
actix_web::rt::spawn(async move {
187187
let request_context = RequestContext {
188-
is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"),
188+
is_embedded: req_param.url_params.contains_key("_sqlpage_embed"),
189189
source_path,
190190
content_security_policy: ContentSecurityPolicy::with_random_nonce(),
191191
server_timing: Arc::clone(&req_param.server_timing),
192192
};
193193
let mut conn = None;
194194
let database_entries_stream =
195-
stream_query_results_with_conn(&sql_file, &mut req_param, &mut conn);
195+
stream_query_results_with_conn(&sql_file, &req_param, &mut conn);
196196
let database_entries_stream = stop_at_first_error(database_entries_stream);
197197
let response_with_writer = build_response_header_and_stream(
198198
Arc::clone(&app_state),

0 commit comments

Comments
 (0)