Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# CHANGELOG.md

## v0.46.1

- Fixed a regression introduced in v0.46 that could replace a variable with `NULL` while building a value that also used database expressions and `sqlpage.*` functions. For example, this API request could lose `john.doe` and produce a URL ending at `https://api.example.com/`:

```sql
SET user_id = 'john.doe';
SET api_request = json_object(
'timeout_ms', CAST(sqlpage.environment_variable('API_TIMEOUT') AS INTEGER),
'url', concat(sqlpage.environment_variable('API_URL'), '/', $user_id)
);
```

SQLPage now keeps the variable value, producing `https://api.example.com/john.doe` as expected.

## v0.46

- Removed unnecessary `CAST` around request variables:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "sqlpage"
version = "0.46.0"
version = "0.46.1"
edition = "2024"
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"]
Expand Down
12 changes: 6 additions & 6 deletions examples/official-site/extensions-to-sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ To be static and simple, a statement must satisfy all of the following:

- No `FROM`, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, `LIMIT`/`FETCH`, `WITH`, `DISTINCT`, `TOP`, windowing, locks, or other clauses.
- Each selected item is of the form `value AS alias`.
- Each `value` is either:
- a literal (single-quoted string, number, boolean, or `NULL`), or
- a variable (like `$name`, `:message`)
- Each `value` can be evaluated without a database row. This includes:
- literals (single-quoted strings, numbers, booleans, or `NULL`),
- variables (like `$name` or `:message`),
- `sqlpage.*` functions whose arguments can also be evaluated without a database row,
- and combinations of those values using `||`, `concat`, `coalesce`, or JSON constructors.

That’s it. If any part is more complex, it is not a static simple select and will be sent to the database.

Expand All @@ -53,14 +55,12 @@ That’s it. If any part is more complex, it is not a static simple select and w
```sql
SELECT 'text' AS component, 'Hello' AS contents;
SELECT 'text' AS component, $name AS contents;
SELECT 'text' AS component, 'Hello ' || $name AS contents;
```

#### Examples that are NOT static (sent to the database)

```sql
-- Has string concatenation
select 'from' as component, 'handle_form.sql?id=' || $id as action;

-- Has WHERE
select 'text' as component, $alert_message as contents where $should_alert;

Expand Down
18 changes: 9 additions & 9 deletions src/webserver/database/execute_queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use tracing::Instrument;
use super::csv_import::run_csv_import;
use super::error_highlighting::{display_stmt_db_error, display_stmt_error, is_positioned_error};
use super::sql::{
DatabaseQuery, FileStatement, OutputColumn, Query, QueryBody, SingleRowQuery, SourceSpan,
SqlFile,
DatabaseQuery, FileStatement, OutputColumn, Query, QueryBody, SourceSpan, SqlFile,
StaticSimpleSelect,
};
use super::sqlpage_expr::{NoInputs, RowExpr, RowInputs};
use crate::dynamic_component::parse_dynamic_rows;
Expand Down Expand Up @@ -201,8 +201,8 @@ pub fn stream_query_results_with_conn<'a>(
run_csv_import(connection, csv_import, request).await.with_context(|| format!("Failed to import the CSV file {:?} into the table {:?}", csv_import.uploaded_file, csv_import.table_name))?;
},
FileStatement::Query(statement) => match &statement.body {
QueryBody::SingleRow(query) => {
let row = execute_single_row(query, request, db_connection)
QueryBody::StaticSimpleSelect(query) => {
let row = execute_static_simple_select(query, request, db_connection)
.await
.map_err(|error| with_stmt_position(source_file, statement.source_span, error))?;
for item in parse_dynamic_rows(DbItem::Row(row)) {
Expand Down Expand Up @@ -330,8 +330,8 @@ pub fn stop_at_first_error(
.take_until(error_rx)
}

async fn execute_single_row(
query: &SingleRowQuery,
async fn execute_static_simple_select(
query: &StaticSimpleSelect,
req: &ExecutionContext,
db_connection: &mut DbConn,
) -> anyhow::Result<Value> {
Expand Down Expand Up @@ -397,11 +397,11 @@ async fn execute_scalar_query<'a>(
source_file: &Path,
) -> anyhow::Result<Option<String>> {
let QueryBody::Database(database_query) = &statement.body else {
let QueryBody::SingleRow(single_row) = &statement.body else {
let QueryBody::StaticSimpleSelect(static_select) = &statement.body else {
unreachable!()
};
ensure_scalar_column_count(single_row.columns.len())?;
let row = execute_single_row(single_row, request, db_connection).await?;
ensure_scalar_column_count(static_select.columns.len())?;
let row = execute_static_simple_select(static_select, request, db_connection).await?;
return scalar_value_from_row(DbItem::Row(row));
};
let query = bind_query(database_query, request, db_connection).await?;
Expand Down
165 changes: 79 additions & 86 deletions src/webserver/database/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ use sqlparser::parser::{Parser, ParserError};
use sqlparser::tokenizer::Token::{self, EOF, SemiColon};
use sqlparser::tokenizer::{Location, Span, TokenWithSpan, Tokenizer};

#[cfg(test)]
use super::SupportedDatabase;
use super::csv_import::extract_csv_copy_statement;
use super::{Database, DbInfo, SupportedDatabase};
use super::{Database, DbInfo};
use crate::AppState;
use crate::file_cache::AsyncFromStrWithState;
use crate::webserver::database::error_highlighting::quote_source_with_highlight;
Expand All @@ -43,7 +45,7 @@ mod statement;
pub(super) use statement::SourceLocation;
pub use statement::SqlFile;
pub(super) use statement::{
DatabaseQuery, FileStatement, OutputColumn, Query, QueryBody, SingleRowQuery, SourceSpan,
DatabaseQuery, FileStatement, OutputColumn, Query, QueryBody, SourceSpan, StaticSimpleSelect,
VariableName,
};

Expand Down Expand Up @@ -211,83 +213,6 @@ fn syntax_error(error: ParserError, parser: &Parser<'_>, sql: &str) -> FileState
FileStatement::Error(anyhow::Error::from(error).context(message))
}

const SQLPAGE_FUNCTION_NAMESPACE: &str = "sqlpage";

pub(super) fn is_sqlpage_func(parts: &[ObjectNamePart]) -> bool {
matches!(
parts,
[
ObjectNamePart::Identifier(Ident {
value,
quote_style: None,
..
}),
ObjectNamePart::Identifier(Ident { quote_style: None, .. })
] if value.eq_ignore_ascii_case(SQLPAGE_FUNCTION_NAMESPACE)
)
}

pub(super) fn extract_json_columns(
statement: &Statement,
database: SupportedDatabase,
) -> Vec<String> {
if matches!(
database,
SupportedDatabase::Postgres | SupportedDatabase::Mssql
) {
return Vec::new();
}
let Statement::Query(query) = statement else {
return Vec::new();
};
let SetExpr::Select(select) = query.body.as_ref() else {
return Vec::new();
};
select
.projection
.iter()
.filter_map(|item| match item {
SelectItem::ExprWithAlias { expr, alias } if is_json_expression(expr) => {
Some(alias.value.clone())
}
_ => None,
})
.collect()
}

pub(super) fn is_json_expression(expression: &Expr) -> bool {
match expression {
Expr::Function(function) => {
let [ObjectNamePart::Identifier(name)] = function.name.0.as_slice() else {
return false;
};
[
"json_object",
"json_array",
"json_build_object",
"json_build_array",
"to_json",
"to_jsonb",
"json_agg",
"jsonb_agg",
"json_arrayagg",
"json_objectagg",
"json_group_array",
"json_group_object",
"json",
"jsonb",
]
.iter()
.any(|candidate| name.value.eq_ignore_ascii_case(candidate))
}
Expr::Cast { data_type, .. } => matches!(
data_type,
sqlparser::ast::DataType::JSON | sqlparser::ast::DataType::JSONB
),
_ => false,
}
}

fn expression_to_query(expression: Expr) -> Statement {
if let Expr::Subquery(query) = expression {
return Statement::Query(query);
Expand Down Expand Up @@ -551,6 +476,35 @@ mod tests {
);
}

#[test]
fn numbered_bindings_keep_source_projection_order() {
let query = rewrite_database(
"select $a as a, sqlpage.url_encode(upper(col || sqlpage.url_encode($b))) as b, $c as c from t",
);
assert_eq!(
query.bindings.as_ref(),
[
variable("a"),
call(SqlPageFunctionName::url_encode, [variable("b")]),
variable("c"),
]
);
}

#[test]
fn numbered_bindings_keep_source_argument_order() {
let query = rewrite_database(
"select coalesce(upper(sqlpage.url_encode($a)), sqlpage.url_encode(upper(sqlpage.url_encode($b)))) from t",
);
assert_eq!(
query.bindings.as_ref(),
[
call(SqlPageFunctionName::url_encode, [variable("a")]),
call(SqlPageFunctionName::url_encode, [variable("b")]),
]
);
}

#[test]
fn database_cannot_order_by_computed_column() {
let FileStatement::Error(error) =
Expand Down Expand Up @@ -668,9 +622,9 @@ mod tests {
}

#[test]
fn standalone_projection_has_no_database_query() {
fn static_simple_select_has_no_database_query() {
let FileStatement::Query(Query {
body: QueryBody::SingleRow(query),
body: QueryBody::StaticSimpleSelect(query),
..
}) = one("select sqlpage.url_encode('a b') as value")
else {
Expand All @@ -683,7 +637,7 @@ mod tests {
fn concat_operator_uses_backend_null_behavior_in_sqlpage_expressions() {
for database_type in [SupportedDatabase::Oracle, SupportedDatabase::Mssql] {
let FileStatement::Query(Query {
body: QueryBody::SingleRow(query),
body: QueryBody::StaticSimpleSelect(query),
..
}) = one_for(database_type, "select '/' || null as path")
else {
Expand Down Expand Up @@ -723,7 +677,7 @@ mod tests {
#[test]
fn unquoted_sqlpage_names_are_case_insensitive() {
let FileStatement::Query(Query {
body: QueryBody::SingleRow(query),
body: QueryBody::StaticSimpleSelect(query),
..
}) = one("select SQLPAGE.URL_ENCODE('a b') as value")
else {
Expand All @@ -739,21 +693,60 @@ mod tests {
"select coalesce(upper(sqlpage.url_encode($prefix)), sqlpage.url_encode(value)) as result from t"
),
DatabaseQuery {
sql: "SELECT value AS \"__sqlpage_input_0\", upper($1) AS \"__sqlpage_input_1\" FROM t".into(),
sql: "SELECT upper($1) AS \"__sqlpage_input_0\", value AS \"__sqlpage_input_1\" FROM t".into(),
bindings: Box::new([call(SqlPageFunctionName::url_encode, [variable("prefix")])]),
row_input_json: Box::new([false, false]),
computed_columns: Box::new([OutputColumn {
name: "result".into(),
value: coalesce([
row(1),
call(SqlPageFunctionName::url_encode, [row(0)]),
row(0),
call(SqlPageFunctionName::url_encode, [row(1)]),
]),
}]),
json_columns: Box::new([]),
}
);
}

#[test]
fn database_fragment_promoted_to_row_input_keeps_source_variables() {
assert_eq!(
rewrite_database("select concat(1 + 1, sqlpage.request_method(), $x) as result from t"),
DatabaseQuery {
sql: "SELECT 1 + 1 AS \"__sqlpage_input_0\" FROM t".into(),
bindings: Box::new([]),
row_input_json: Box::new([false]),
computed_columns: Box::new([OutputColumn {
name: "result".into(),
value: SqlPageExpr::Concat {
arguments: Box::new([
row(0),
call(SqlPageFunctionName::request_method, []),
variable("x"),
]),
null_behavior: ConcatNullBehavior::IgnoreNull,
},
}]),
json_columns: Box::new([]),
}
);
}

#[test]
fn private_row_input_json_flags_follow_row_input_ids() {
let query = rewrite_database(
"select concat(to_json(value), sqlpage.url_encode(other)) as result from t",
);
assert_eq!(query.row_input_json.as_ref(), [true, false]);
let SqlPageExpr::Concat { arguments, .. } = &query.computed_columns[0].value else {
panic!("expected a concatenated per-row expression");
};
assert_eq!(
arguments.as_ref(),
[row(0), call(SqlPageFunctionName::url_encode, [row(1)])]
);
}

#[test]
fn predicate_call_is_standalone_while_projection_call_is_per_row() {
assert_eq!(
Expand Down
Loading