Skip to content

Commit 0746480

Browse files
authored
Merge branch 'main' into fix/config-list-env-1383
2 parents 9130ed8 + ae69528 commit 0746480

18 files changed

Lines changed: 207 additions & 28 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## unreleased
44

5+
- Removed unnecessary `CAST` around request variables:
6+
- On PostgreSQL, MySQL, SQL Server and DuckDB, variables are now sent without a text cast and the database infers the type from context, which keeps generated SQL readable (`WHERE id = $1` instead of `WHERE id = CAST($1 AS TEXT)`) and fixes cases where the cast was harmful.
7+
- On SQL Server, this fixes `nvarchar` comparisons with non-ASCII characters that were previously mangled by `CAST(... AS VARCHAR)`, and fixes `CONTAINS` and `EXEC` with variables.
8+
- On MySQL/MariaDB, this fixes `LIMIT`/`OFFSET` with variables.
9+
- The cast is retained on SQLite and on ODBC connections to PostgreSQL, SQLite, Oracle, Snowflake and other databases where it is needed for correct comparisons.
510
- AWS Lambda builds and documentation now use the supported Amazon Linux 2023 custom runtime instead of the end-of-life Amazon Linux 2 runtime. Release artifacts include the configuration directory required on Lambda's read-only filesystem.
611
- Added a `toast` component with plain-text or Markdown content, icons, colors, six screen placements, configurable auto-dismiss timing, optional manual dismissal, URL-fragment triggers, and automatic stacking of queued notifications.
712
- `sqlpage.send_mail` now supports rich email bodies. Use `body_html` for a caller-provided HTML alternative, or `body_md` to render Markdown as HTML. Messages retain a plain-text alternative; `body` may be omitted when `body_md` is used, and `body_md` and `body_html` cannot be combined.
@@ -15,6 +20,7 @@
1520
- Screen readers now announce the title of the modal component instead of an unnamed dialog.
1621
- `sqlpage.request_body` and `sqlpage.request_body_base64` now return NULL when the request has no body. A body that cannot be read, such as one exceeding the payload limit, is now reported as an error instead of being silently replaced with an empty body.
1722
- List-valued configuration options, including OIDC paths and trusted audiences, can now be set through environment variables as space-separated lists.
23+
- Datagrid rows with an icon or image no longer display an unnecessary en-dash placeholder, and an explicitly empty description remains empty.
1824
- Charts can display reference lines. A row with a `yline` is drawn as a line across the chart at that value of the y axis, with the row's `label` and `color` for its text and its color. Reference lines are rows, so a chart can have as many of them as the query returns. A line follows its axis, so on a `horizontal` bar chart a `yline` is drawn down the chart rather than across it. They are not added to the total of a `stacked` chart, and are not filled in an `area` chart.
1925

2026
## v0.45
27.7 KB
Loading
27.7 KB
Loading

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ This means `SET` variables always take precedence over request parameters when u
143143
Only a single textual value (**string or `NULL`**) is stored.
144144
`SET id = 1` will store the string `'1'`, not the number `1`.
145145

146+
Variables are always sent to the database as text.
147+
On SQLite, and on ODBC connections to PostgreSQL, SQLite, Oracle, Snowflake, or other databases, SQLPage wraps variables in an explicit cast to text, because their parameter type handling would otherwise make comparisons unpredictable.
148+
On PostgreSQL, the variable is passed as text, and comparing it to a non-text column requires an explicit cast.
149+
On MySQL, Microsoft SQL Server, and DuckDB, the database converts the variable to the type expected by the surrounding expression.
146150
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`.
147151

148152
Complex structures can be stored as json strings.

sqlpage/templates/datagrid.handlebars

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
{{/if}}
4343
{{#if description}}
4444
{{description}}
45+
{{else if (eq description "")}}
46+
{{else if icon}}
47+
{{else if image_url}}
4548
{{else}}
4649
4750
{{/if}}

src/webserver/database/sql.rs

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ mod tests {
432432
};
433433
assert_eq!(query.bindings.len(), 1);
434434
assert!(query.computed_columns.is_empty());
435-
assert!(query.sql.contains("upper(CAST($1 AS TEXT))"));
435+
assert!(query.sql.contains("upper($1)"));
436436
}
437437

438438
#[test]
@@ -519,10 +519,7 @@ mod tests {
519519
else {
520520
panic!("expected database query");
521521
};
522-
assert_eq!(
523-
query.sql,
524-
"WITH c AS (SELECT CAST(? AS CHAR) AS x) SELECT CAST(? AS CHAR) AS y FROM c"
525-
);
522+
assert_eq!(query.sql, "WITH c AS (SELECT ? AS x) SELECT ? AS y FROM c");
526523
assert_eq!(query.bindings.as_ref(), [variable("a"), variable("b")]);
527524
}
528525

@@ -630,7 +627,7 @@ mod tests {
630627
let statement = parse_sql(
631628
&database,
632629
&MySqlDialect {},
633-
"select '@SQLPAGE_TEMP1' as value from t where id = $id",
630+
"select '@SQLPAGE_TEMP1' where id = $id",
634631
)
635632
.unwrap()
636633
.next()
@@ -742,7 +739,7 @@ mod tests {
742739
"select coalesce(upper(sqlpage.url_encode($prefix)), sqlpage.url_encode(value)) as result from t"
743740
),
744741
DatabaseQuery {
745-
sql: "SELECT value AS \"__sqlpage_input_0\", upper(CAST($1 AS TEXT)) AS \"__sqlpage_input_1\" FROM t".into(),
742+
sql: "SELECT value AS \"__sqlpage_input_0\", upper($1) AS \"__sqlpage_input_1\" FROM t".into(),
746743
bindings: Box::new([call(SqlPageFunctionName::url_encode, [variable("prefix")])]),
747744
row_input_json: Box::new([false, false]),
748745
computed_columns: Box::new([OutputColumn {
@@ -764,8 +761,7 @@ mod tests {
764761
"select sqlpage.url_encode(value) as encoded from t where sqlpage.url_encode($expected) = 'x'"
765762
),
766763
DatabaseQuery {
767-
sql: "SELECT value AS \"__sqlpage_input_0\" FROM t WHERE CAST($1 AS TEXT) = 'x'"
768-
.into(),
764+
sql: "SELECT value AS \"__sqlpage_input_0\" FROM t WHERE $1 = 'x'".into(),
769765
bindings: Box::new([call(
770766
SqlPageFunctionName::url_encode,
771767
[variable("expected")]
@@ -804,4 +800,62 @@ mod tests {
804800
}
805801
);
806802
}
803+
804+
fn sql_for_dbinfo(info: &DbInfo, sql: &str) -> String {
805+
match parse_sql(info, &PostgreSqlDialect {}, sql).unwrap().next() {
806+
Some(FileStatement::Query(Query {
807+
body: QueryBody::Database(q),
808+
..
809+
})) => q.sql,
810+
other => panic!("Expected database query for `{sql}`\nGot: {other:?}"),
811+
}
812+
}
813+
814+
fn sql_for(db: SupportedDatabase, sql: &str) -> String {
815+
sql_for_dbinfo(&database(db), sql)
816+
}
817+
818+
fn odbc_sql_for(db: SupportedDatabase, sql: &str) -> String {
819+
sql_for_dbinfo(
820+
&DbInfo {
821+
dbms_name: db.display_name().to_owned(),
822+
database_type: db,
823+
kind: AnyKind::Odbc,
824+
},
825+
sql,
826+
)
827+
}
828+
829+
#[test]
830+
fn variables_keep_cast_only_where_typing_is_unpredictable() {
831+
use SupportedDatabase::*;
832+
let src = "SELECT $a";
833+
assert_eq!(sql_for(Sqlite, src), "SELECT CAST(?1 AS TEXT)");
834+
assert_eq!(sql_for(Oracle, src), "SELECT CAST(? AS VARCHAR(4000))");
835+
assert_eq!(sql_for(Snowflake, src), "SELECT CAST(? AS VARCHAR)");
836+
assert_eq!(sql_for(Generic, src), "SELECT CAST(? AS VARCHAR)");
837+
assert_eq!(sql_for(Postgres, src), "SELECT $1");
838+
assert_eq!(sql_for(MySql, src), "SELECT ?");
839+
assert_eq!(sql_for(Mssql, src), "SELECT @p1");
840+
assert_eq!(sql_for(Duckdb, src), "SELECT ?");
841+
}
842+
843+
#[test]
844+
fn odbc_cast_follows_database() {
845+
use SupportedDatabase::*;
846+
for db in [Postgres, Sqlite] {
847+
assert_eq!(odbc_sql_for(db, "select $a"), "SELECT CAST(? AS TEXT)");
848+
}
849+
for db in [MySql, Mssql, Duckdb] {
850+
assert_eq!(odbc_sql_for(db, "select $a"), "SELECT ?");
851+
}
852+
}
853+
854+
#[test]
855+
fn limit_uses_bare_parameter() {
856+
assert_eq!(
857+
sql_for(SupportedDatabase::Postgres, "select value from t limit $n"),
858+
"SELECT value FROM t LIMIT $1"
859+
);
860+
}
807861
}

src/webserver/database/sql/rewrite.rs

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use crate::webserver::database::sqlpage_expr::{
4141
};
4242
use crate::webserver::database::sqlpage_functions::functions::SqlPageFunctionName;
4343
use crate::webserver::database::{DbInfo, SupportedDatabase};
44+
use sqlx::any::AnyKind;
4445

4546
const SQLPAGE_INPUT_PREFIX: &str = "__sqlpage_input_";
4647

@@ -620,7 +621,7 @@ impl QueryRewriter<'_> {
620621
PlaceholderStyle::Numbered { prefix } => format!("{prefix}{}", sequence + 1),
621622
PlaceholderStyle::Positional { .. } => format!("${}", sequence + 1),
622623
};
623-
cast_placeholder(placeholder, self.database.database_type)
624+
cast_placeholder(placeholder, self.database)
624625
}
625626

626627
fn add_row_input(&mut self, mut expression: SqlExpr) -> anyhow::Result<RowInputId> {
@@ -1035,18 +1036,44 @@ fn variable_source(prefix: char) -> VariableSource {
10351036
}
10361037
}
10371038

1038-
/// Wraps a generated placeholder in the backend-specific text cast expected
1039-
/// by `SQLPage`'s string-valued binding interface.
1040-
fn cast_placeholder(placeholder: String, database: SupportedDatabase) -> SqlExpr {
1041-
let data_type = match database {
1042-
SupportedDatabase::MySql => DataType::Char(None),
1043-
SupportedDatabase::Mssql => DataType::Varchar(Some(CharacterLength::Max)),
1044-
SupportedDatabase::Postgres | SupportedDatabase::Sqlite => DataType::Text,
1045-
SupportedDatabase::Oracle => DataType::Varchar(Some(CharacterLength::IntegerLength {
1046-
length: 4000,
1047-
unit: None,
1048-
})),
1049-
_ => DataType::Varchar(None),
1039+
/// Wraps a generated placeholder in the backend-specific text cast when the
1040+
/// database cannot reliably infer that the parameter is a string.
1041+
///
1042+
/// `SQLPage` always binds parameters as strings. Native `PostgreSQL` (which
1043+
/// pins the parameter type to `TEXT` when preparing the statement), `MySQL`
1044+
/// and `SQL Server` (which convert the bound string to the type expected by
1045+
/// the surrounding expression) do not need the cast, and it can even be
1046+
/// harmful: on `SQL Server` the parameter is bound as `NVARCHAR(MAX)`, and
1047+
/// casting it to a narrow `VARCHAR` mangles non-ASCII values. `SQLite`
1048+
/// needs it to keep text affinity in comparisons with numbers.
1049+
///
1050+
/// Through ODBC, the decision follows the database behind the driver, since
1051+
/// `SQLPage` knows it from the driver's reported name:
1052+
/// - `PostgreSQL` keeps the cast: `psqlodbc` provides no parameter type
1053+
/// information, and the server then fails on context-free parameters
1054+
/// (`could not determine data type of parameter`).
1055+
/// - `SQLite` keeps it for the same affinity reasons as native connections.
1056+
/// - `MySQL`, `SQL Server` and `DuckDB` drop it, like their native
1057+
/// counterparts: the former two convert the string at execution time, and
1058+
/// `DuckDB` defaults untyped parameters to `VARCHAR`.
1059+
/// - `Oracle`, `Snowflake` and unknown databases keep it conservatively.
1060+
fn cast_placeholder(placeholder: String, database: &DbInfo) -> SqlExpr {
1061+
let data_type = match database.kind {
1062+
AnyKind::Sqlite => DataType::Text,
1063+
AnyKind::Postgres | AnyKind::MySql | AnyKind::Mssql => {
1064+
return SqlExpr::value(Value::Placeholder(placeholder));
1065+
}
1066+
AnyKind::Odbc => match database.database_type {
1067+
SupportedDatabase::Postgres | SupportedDatabase::Sqlite => DataType::Text,
1068+
SupportedDatabase::Oracle => DataType::Varchar(Some(CharacterLength::IntegerLength {
1069+
length: 4000,
1070+
unit: None,
1071+
})),
1072+
SupportedDatabase::MySql | SupportedDatabase::Mssql | SupportedDatabase::Duckdb => {
1073+
return SqlExpr::value(Value::Placeholder(placeholder));
1074+
}
1075+
_ => DataType::Varchar(None),
1076+
},
10501077
};
10511078
SqlExpr::Cast {
10521079
expr: Box::new(SqlExpr::value(Value::Placeholder(placeholder))),
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SELECT 'datagrid' AS component;
2+
SELECT 'Facebook' AS title, 'brand-facebook' AS icon;
3+
SELECT 'Empty' AS title, '' AS description;
4+
SELECT 'Missing' AS title;

tests/core/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ async fn test_concurrent_requests() {
4040
}
4141
}
4242

43+
#[actix_web::test]
44+
async fn test_datagrid_description_presence_controls_placeholder() {
45+
let resp = req_path("/tests/components/datagrid_icon_only.sql")
46+
.await
47+
.unwrap();
48+
assert_eq!(resp.status(), StatusCode::OK);
49+
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
50+
assert!(body.contains("Facebook"), "{body}");
51+
assert!(body.contains("Empty"), "{body}");
52+
assert!(body.contains("Missing"), "{body}");
53+
assert!(body.contains("<svg"), "{body}");
54+
assert_eq!(body.matches('–').count(), 1, "{body}");
55+
}
56+
4357
#[actix_web::test]
4458
async fn test_routing_with_db_fs() {
4559
let mut config = test_config();

tests/sql_test_files/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,13 @@ and the rest of the file name. Files may include `nosqlite`, `nomssql`,
1515
Files that only validate data-processing functions should live here. They must
1616
return rows with an `actual` column plus either `expected` (exact match) or
1717
`expected_contains` (substring match). Tests in this directory are fetched as
18-
JSON and validated row by row.
18+
JSON and validated row by row.
19+
20+
### `data/database-specific/`
21+
22+
Files that only work on a single database engine (because they use
23+
engine-specific SQL syntax) live in a subdirectory named after that database
24+
(`sqlite`, `postgres`, `mysql`, `mssql`, `oracle`, `duckdb`, `snowflake`,
25+
`generic`). They are run by a separate test, only when the current database
26+
matches. Unlike the other directories, their file names do not need `_no...`
27+
suffixes to exclude incompatible backends.

0 commit comments

Comments
 (0)