Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
8 changes: 4 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub mod webserver;

use crate::app_config::AppConfig;
use crate::filesystem::FileSystem;
use crate::webserver::database::ParsedSqlFile;
use crate::webserver::database::SqlFile;
use crate::webserver::oidc::OidcState;
use file_cache::FileCache;
use std::path::{Path, PathBuf};
Expand All @@ -106,7 +106,7 @@ pub const DEFAULT_404_FILE: &str = "default_404.sql";
pub struct AppState {
pub db: Database,
all_templates: AllTemplates,
sql_file_cache: FileCache<ParsedSqlFile>,
sql_file_cache: FileCache<SqlFile>,
file_system: FileSystem,
config: AppConfig,
pub oidc_state: Option<Arc<OidcState>>,
Expand All @@ -124,11 +124,11 @@ impl AppState {
let file_system = FileSystem::init(&config.web_root, &db).await;
sql_file_cache.add_static(
PathBuf::from("index.sql"),
ParsedSqlFile::new(&db, include_str!("index.sql"), Path::new("index.sql")),
SqlFile::new(&db, include_str!("index.sql"), Path::new("index.sql")),
);
sql_file_cache.add_static(
PathBuf::from(DEFAULT_404_FILE),
ParsedSqlFile::new(
SqlFile::new(
&db,
include_str!("default_404.sql"),
Path::new(DEFAULT_404_FILE),
Expand Down
9 changes: 5 additions & 4 deletions src/webserver/database/error_highlighting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{
path::{Path, PathBuf},
};

use super::sql::{SourceSpan, StmtWithParams};
use super::sql::SourceSpan;

#[derive(Debug)]
struct NiceDatabaseError {
Expand Down Expand Up @@ -114,14 +114,15 @@ pub fn display_db_error(
#[must_use]
pub fn display_stmt_db_error(
source_file: &Path,
stmt: &StmtWithParams,
query: &str,
query_position: SourceSpan,
db_err: sqlx::error::Error,
) -> anyhow::Error {
anyhow::Error::new(NiceDatabaseError {
source_file: source_file.to_path_buf(),
db_err,
query: stmt.query.clone(),
query_position: Some(stmt.query_position),
query: query.to_owned(),
query_position: Some(query_position),
})
}

Expand Down
Loading
Loading