Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1ab77de
Make SQLPage function evaluation deterministic
lovasoa Jul 8, 2026
5a15e18
Fix nested request params in SQLPage functions
lovasoa Jul 10, 2026
3f030aa
Clarify SQL preprocessing pipeline
lovasoa Jul 10, 2026
9b4970d
Preserve NULL semantics for constant SQLPage functions
lovasoa Jul 10, 2026
02bd98d
Merge remote-tracking branch 'origin/lazy-fn-eval-in-set' into lazy-f…
lovasoa Jul 10, 2026
1818056
Retain delayed evaluation for non-concat projections
lovasoa Jul 10, 2026
6ec2670
Fix delayed concat detection
lovasoa Jul 10, 2026
7b72bd5
Preserve session state for delayed run_sql
lovasoa Jul 10, 2026
e075945
Refactor SQL parameter handling into unified expression tree
lovasoa Jul 15, 2026
947b189
Add tests for mixed database and row expression rewriting
lovasoa Jul 15, 2026
c181c21
Add regressions for computed projection ordering
lovasoa Jul 15, 2026
106aafc
Preserve computed projection input positions
lovasoa Jul 15, 2026
8427d4c
Reject ordinal ordering with computed projections
lovasoa Jul 15, 2026
5c45103
Preserve backend scalar subquery semantics
lovasoa Jul 15, 2026
3b5fcca
Make no-row SQL tests portable
lovasoa Jul 15, 2026
709153b
Validate SET columns before computed expressions
lovasoa Jul 15, 2026
556c62a
Match CONCAT null semantics to the database
lovasoa Jul 15, 2026
11cc16a
Clarify scalar SET row semantics
lovasoa Jul 15, 2026
c84eb68
Keep private inputs after wildcard projections
lovasoa Jul 15, 2026
365ea0f
fix tests on mssql
lovasoa Jul 15, 2026
8525453
improve error messages
lovasoa Jul 15, 2026
8a35fa1
Match concat operator null semantics
lovasoa Jul 15, 2026
b0145c6
Reject grouping by computed projections
lovasoa Jul 15, 2026
a175fd4
Avoid dropping shared filesystem test tables
lovasoa Jul 15, 2026
bf27e3e
Restore Oracle container health check
lovasoa Jul 15, 2026
f4052d3
Order positional bindings by rendered SQL
lovasoa Jul 15, 2026
c2d2731
quotes
lovasoa Jul 15, 2026
b60d834
Reject computed aliases in database clauses
lovasoa Jul 15, 2026
614c630
Handle parentheses around computed projections
lovasoa Jul 15, 2026
5731544
Update AGENTS.md
lovasoa Jul 15, 2026
2276b31
Merge branch 'main' into lazy-fn-eval-in-set
lovasoa Jul 15, 2026
f5fddf8
oracle syntax
lovasoa Jul 15, 2026
7be6082
fix tests on oracle odbc
lovasoa Jul 16, 2026
5449033
Comments
lovasoa Jul 16, 2026
099366e
Validate WHERE clause for invalid references
lovasoa Jul 16, 2026
91160ff
Clarify per-row function evaluation
lovasoa Jul 16, 2026
ca8d94b
fix bug in test error formatting
lovasoa Jul 16, 2026
04a94dd
fix mysql incompatibility in tests
lovasoa Jul 16, 2026
06459c4
fmt
lovasoa Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## unreleased

- **SQLPage function evaluation is now lazier.** A `sqlpage.*` call that is the whole value of a selected column now runs after the database query, once for each returned row. If the query returns no rows, the function is not called. Other SQLPage function calls still run before the query when their arguments do not depend on database columns. `SET x = (SELECT ...)` now has explicit scalar-query semantics: zero rows set `x` to `NULL`, one row with one column sets the value, and multiple rows or multiple columns return a clear SQLPage error. Migration notes:
- `SELECT sqlpage.fetch('https://api.example.com') AS body FROM many_rows` will now make one HTTP request per row; use `SET body = sqlpage.fetch(...)` first for one request per page.
- `SELECT sqlpage.exec('command') AS result FROM many_rows` will now run once per row; use `SET` first for once-per-page execution
- `SELECT sqlpage.random_string(8) AS token FROM many_rows` now produces one token per row instead of one token reused across rows
- `SET result = (SELECT sqlpage.fetch($url) WHERE NOT $cached)` now works as expected and does not re-fetch a cached result. Apps relying on sqlite-specific `SET x = (SELECT y FROM t)` first-row behavior should add `LIMIT 1` and select exactly one column.
- **Access logs now go to stdout.** SQLPage now writes the single per-request completion log line to stdout with the target `sqlpage::access`, matching common application-server and container logging conventions. Diagnostic logs, warnings, and internal errors still go to stderr. If your `LOG_LEVEL` or `RUST_LOG` filter is scoped to a specific old target such as `sqlpage::webserver::http=info`, add `sqlpage::access=info` so request-completion logs are still emitted. If your log pipeline only collects stderr, update it to collect stdout too.

## v0.44.1
Expand Down
20 changes: 14 additions & 6 deletions examples/official-site/functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ select '
In addition to normal SQL functions supported by your database,
SQLPage provides a few special functions to help you extract data from user requests.

These functions are special, because they are not executed inside your database,
but by SQLPage itself before sending the query to your database.
Thus, they require all the parameters to be known at the time the query is sent to your database.
Function parameters cannot reference columns from the rest of your query.
The only case when you can call a SQLPage function with a parameter that is not a constant is when it appears at the top level of a `SELECT` statement.
For example, `SELECT sqlpage.url_encode(url) FROM t` is allowed because SQLPage can execute `SELECT url FROM t` and then apply the `url_encode` function to each value.
These functions are special because they are not database functions.
SQLPage evaluates them itself, either before or after the database query.

When a SQLPage function call is the whole value of a selected column, SQLPage first lets the database decide which rows exist.
It selects the function arguments as hidden columns, then calls the SQLPage function once for each returned row.
For example, `SELECT sqlpage.url_encode(url) AS encoded FROM t` runs the database query first, then applies `url_encode` to every returned `url` value.
If the query returns no row, the function is not called.
This also applies inside scalar `SET` subqueries, for instance `SET body = (SELECT sqlpage.fetch(url) FROM cache_misses WHERE enabled)`.

In other positions, SQLPage functions run before the query, but only when their arguments can be evaluated without reading database columns.
For instance, `WHERE sqlpage.cookie(''x'') = ''1''` can run before the query, but `WHERE sqlpage.fetch(url) IS NOT NULL` cannot because `url` is a database column.

If a SQLPage function is expensive or has side effects and should run only once, store its result with `SET` first and reuse the variable.
If a function should run only when a database row exists, put it as a standalone selected column in that row-producing query.

For more information about how SQLPage functions are evaluated, and data types in SQLPage, read [the SQLPage data model documentation](/extensions-to-sql).
' as contents_md where $function IS NULL;
Expand Down
10 changes: 7 additions & 3 deletions examples/official-site/sqlpage/migrations/08_functions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ VALUES (
'arrows-shuffle',
'Returns a cryptographically secure random string of the given length.

When used as a standalone selected column in a query that returns several rows, `random_string` runs once per returned row.
Use `SET token = sqlpage.random_string(32)` first if you want one token reused later in the page.

### Example

Generate a random string of 32 characters and use it as a session ID stored in a cookie:
Expand Down Expand Up @@ -414,9 +417,10 @@ from json_each(sqlpage.exec(''curl'', ''https://jsonplaceholder.typicode.com/use
This means that the SQLPage server will not be blocked while the command is running, it will be able to serve other requests, but it will not be able to serve the current request until the command has finished.
You should generally avoid long running commands.
- If the program name is NULL, the result will be NULL.
- If any argument is NULL, it will be passed to the command as an empty string.
- If the command exits with a non-zero exit code, the function will raise an error.
- Arbitrary SQL operations are not allowed as sqlpage function arguments. Use `SET` to assign the result of a SQL query to a variable, and then use that variable as an argument to `sqlpage.exec`.
- If any argument is NULL, it will be passed to the command as an empty string.
- If the command exits with a non-zero exit code, the function will raise an error.
- Arbitrary SQL operations are not allowed as sqlpage function arguments. Use `SET` to assign the result of a SQL query to a variable, and then use that variable as an argument to `sqlpage.exec`.
- When `sqlpage.exec(...)` is a standalone selected column, the command runs once per returned row. Use `SET command_result = sqlpage.exec(...)` first if the command should run only once for the page.
'
);
INSERT INTO sqlpage_function_parameters (
Expand Down
2 changes: 2 additions & 0 deletions examples/official-site/sqlpage/migrations/38_run_sql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ for help with manipulating the json array returned by `run_sql`.
- **variables**: the included file will have access to the same variables (URL parameters, POST variables, etc.)
as the calling file.
If the included file changes the value of a variable or creates a new variable, the change will not be visible in the calling file.
- **per-row execution**: when `sqlpage.run_sql(...)` is a standalone selected column in a query that returns several rows, the included file runs once per returned row.
Use `SET included = sqlpage.run_sql(...)` first if the included file should run only once for the page.
### Parameters
Expand Down
14 changes: 14 additions & 0 deletions examples/official-site/sqlpage/migrations/40_fetch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ set api_value = sqlpage.fetch($target_url); -- no http request made if the field
update my_table set field = $api_value where id = 1 and $api_value is not null; -- update the field only if it was not present before
```

You can also make the HTTP request depend on whether a database query returns a row.
When `fetch` is a standalone selected column, SQLPage runs it only for rows returned by the database:

```sql
set api_value = (
select sqlpage.fetch(url)
from cache_misses
where key = $key
);
```

If `cache_misses` has no matching row, no HTTP request is made and `$api_value` is set to `NULL`.
If the query returns more than one row or more than one column, SQLPage returns a clear scalar `SET` error.

## Advanced usage

If you need to handle errors or inspect the response headers or the status code,
Expand Down
4 changes: 4 additions & 0 deletions examples/official-site/sqlpage/migrations/72_set_variable.sql
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ select
from categories;
```
When `sqlpage.set_variable(...)` is used as a standalone selected column in a query that returns several rows, it runs once per returned row.
This is useful for generating one link per row, as in the example above.
If you need a single link reused later in the page, store it with `SET` first.
### 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.
Expand Down
192 changes: 131 additions & 61 deletions src/webserver/database/execute_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ use super::sql::{
use crate::dynamic_component::parse_dynamic_rows;
use crate::utils::add_value_to_map;
use crate::webserver::ErrorWithStatus;
use crate::webserver::database::sql_to_json::row_to_string;
use crate::webserver::http_request_info::ExecutionContext;
use crate::webserver::request_variables::SetVariablesMap;
use crate::webserver::single_or_vec::SingleOrVec;
Expand Down Expand Up @@ -135,6 +134,29 @@ fn create_db_query_span(
(span, operation_name)
}

fn create_query_metrics<'a>(
request: &'a ExecutionContext,
source_file: &Path,
statement: &StmtWithParams,
query: &StatementWithParams<'_>,
) -> (tracing::Span, DbQueryMetricsContext<'a>) {
let db_system_name = request.app_state.db.info.database_type.otel_name();
let (query_span, operation_name) = create_db_query_span(
query.sql,
source_file,
statement.query_position.start.line,
db_system_name,
);
let query_metrics = DbQueryMetricsContext::new(
query_span.clone(),
operation_name,
db_system_name,
&request.app_state.telemetry_metrics,
);
record_query_params(&query_metrics.span, &query.param_values);
(query_span, query_metrics)
}

impl Database {
pub(crate) async fn prepare_with(
&self,
Expand Down Expand Up @@ -170,20 +192,7 @@ pub fn stream_query_results_with_conn<'a>(
request.server_timing.record("bind_params");
let connection = take_connection(&request.app_state.db, db_connection, request).await?;
log::trace!("Executing query {:?}", query.sql);
let db_system_name = request.app_state.db.info.database_type.otel_name();
let (query_span, operation_name) = create_db_query_span(
query.sql,
source_file,
stmt.query_position.start.line,
db_system_name,
);
let mut query_metrics = DbQueryMetricsContext::new(
query_span.clone(),
operation_name,
db_system_name,
&request.app_state.telemetry_metrics,
);
record_query_params(&query_metrics.span, &query.param_values);
let (query_span, mut query_metrics) = create_query_metrics(request, source_file, stmt, &query);
let mut stream = connection.fetch_many(query);
let mut error = None;
let mut returned_rows: i64 = 0;
Expand Down Expand Up @@ -215,8 +224,7 @@ pub fn stream_query_results_with_conn<'a>(
}
drop(stream);
if let Some(error) = error {
query_metrics.record_error(returned_rows, &error);
try_rollback_transaction(connection).await;
let error = record_error_and_rollback(connection, &query_metrics, returned_rows, error).await;
yield DbItem::Error(error);
} else {
query_metrics.record_success(returned_rows);
Expand Down Expand Up @@ -348,58 +356,120 @@ async fn execute_set_variable_query<'a>(
statement: &StmtWithParams,
source_file: &Path,
) -> anyhow::Result<()> {
let value = execute_scalar_query(db_connection, request, statement, source_file).await?;

let (mut vars, name) = vars_and_name(request, variable)?;

log::debug!("Setting variable {name} to {value:?}");
vars.insert(name.to_owned(), value.map(SingleOrVec::Single));

Ok(())
}

async fn execute_scalar_query<'a>(
db_connection: &'a mut DbConn,
request: &'a ExecutionContext,
statement: &StmtWithParams,
source_file: &Path,
) -> anyhow::Result<Option<String>> {
let query = bind_parameters(statement, request, db_connection).await?;
let connection = take_connection(&request.app_state.db, db_connection, request).await?;
log::debug!(
"Executing query to set the {variable:?} variable: {:?}",
query.sql
);

let db_system_name = request.app_state.db.info.database_type.otel_name();
let (query_span, operation_name) = create_db_query_span(
query.sql,
source_file,
statement.query_position.start.line,
db_system_name,
);
let mut query_metrics = DbQueryMetricsContext::new(
query_span.clone(),
operation_name,
db_system_name,
&request.app_state.telemetry_metrics,
);
record_query_params(&query_metrics.span, &query.param_values);
let start_time = std::time::Instant::now();
let value = match connection
.fetch_optional(query)
.instrument(query_span.clone())
.await
{
Ok(Some(row)) => {
query_metrics.add_duration(start_time.elapsed());
query_metrics.record_success(1_i64);
row_to_string(&row)
}
Ok(None) => {
query_metrics.add_duration(start_time.elapsed());
query_metrics.record_success(0_i64);
None
log::debug!("Executing scalar query: {:?}", query.sql);
let (query_span, mut query_metrics) =
create_query_metrics(request, source_file, statement, &query);

let mut stream = connection.fetch_many(query);
let mut scalar_row = None;
let mut returned_rows: i64 = 0;
let mut error = None;
loop {
let start_next = std::time::Instant::now();
let next_elem = stream.next().instrument(query_span.clone()).await;
query_metrics.add_duration(start_next.elapsed());
let Some(elem) = next_elem else { break };

match parse_single_sql_result(source_file, statement, elem) {
row @ DbItem::Row(_) => {
returned_rows += 1;
if scalar_row.is_some() {
error = Some(anyhow!(
"SET scalar query returned more than one row. A SET subquery must return zero or one row."
));
break;
}
scalar_row = Some(row);
}
DbItem::FinishedQuery => {}
DbItem::Error(err) => {
error = Some(err);
break;
}
}
Err(e) => {
query_metrics.add_duration(start_time.elapsed());
try_rollback_transaction(connection).await;
let err = display_stmt_db_error(source_file, statement, e);
query_metrics.record_error(0_i64, &err);
return Err(err);
}
drop(stream);

let value = if let Some(error) = error {
return Err(
record_error_and_rollback(connection, &query_metrics, returned_rows, error).await,
);
} else if let Some(mut row) = scalar_row {
apply_json_columns(&mut row, &statement.json_columns);
if let Err(error) = apply_delayed_functions(request, &statement.delayed_functions, &mut row)
.instrument(query_span.clone())
.await

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse page connection for scalar delayed functions

Here the scalar SET path reuses apply_delayed_functions, but that helper always starts with let mut db_conn = None, so a delayed sqlpage.run_sql in SET x = sqlpage.run_sql(...) runs its included file on a fresh pooled connection even though the scalar query stream has already been dropped. Pages that create temp tables or start a transaction before the SET now lose that session state (the old pre-query FunctionCall path used the request db_connection), and the docs recommend SET for one-shot run_sql; this path should pass the existing connection or avoid delaying SET functions that need it.

Useful? React with 👍 / 👎.

{
return Err(record_error_and_rollback(
connection,
&query_metrics,
returned_rows,
error,
)
.await);
}
scalar_value_from_row(row)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back when scalar SET shape validation fails

When scalar_value_from_row rejects the row, for example with SET x = (SELECT 1 AS a, 2 AS b), this ? returns before record_error_and_rollback, unlike the DB, multiple-row, and delayed-function error paths above. If the page has already run BEGIN on the request connection, this newly introduced scalar-shape error leaves that transaction open on the pooled connection and records no query error; route this error through the same rollback path before returning.

Useful? React with 👍 / 👎.

} else {
None
};

let (mut vars, name) = vars_and_name(request, variable)?;
query_metrics.record_success(returned_rows);
Ok(value)
}

log::debug!("Setting variable {name} to {value:?}");
vars.insert(name.to_owned(), value.map(SingleOrVec::Single));
async fn record_error_and_rollback(
connection: &mut AnyConnection,
query_metrics: &DbQueryMetricsContext<'_>,
returned_rows: i64,
error: anyhow::Error,
) -> anyhow::Error {
query_metrics.record_error(returned_rows, &error);
try_rollback_transaction(connection).await;
error
}

Ok(())
fn scalar_value_from_row(item: DbItem) -> anyhow::Result<Option<String>> {
let DbItem::Row(Value::Object(row)) = item else {
anyhow::bail!("SET scalar query did not return a row object");
};
match row.len() {
0 => anyhow::bail!(
"SET scalar query returned no columns. A SET subquery must select exactly one column."
),
1 => Ok(row
.into_iter()
.next()
.and_then(|(_, v)| json_to_scalar_string(v))),
_ => anyhow::bail!(
"SET scalar query returned more than one column. A SET subquery must select exactly one column."
),
}
}

fn json_to_scalar_string(value: Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s),
other => Some(other.to_string()),
}
}

async fn execute_set_simple_static<'a>(
Expand Down
Loading
Loading