Skip to content

Commit 3298a83

Browse files
authored
Merge pull request #494 from lovasoa/sqlpage.link
new sqlpage.link function
2 parents 9c47ec9 + 47e419b commit 3298a83

File tree

22 files changed

+341
-55
lines changed

22 files changed

+341
-55
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@
2727
- Updated apexcharts.js to [v3.50.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v3.50.0)
2828
- Improve truncation of long page titles
2929
- ![screenshot long title](https://github.com/lovasoa/SQLpage/assets/552629/9859023e-c706-47b3-aa9e-1c613046fdfa)
30+
- new function: [`sqlpage.link`](https://sql.ophir.dev/functions.sql?function=link#function) to easily create links with parameters between pages. For instance, you can now use
31+
```sql
32+
select 'list' as component;
33+
select
34+
product_name as title,
35+
sqlpage.link('product.sql', json_object('product', product_name)) as link
36+
from products;
37+
```
38+
- Before, you would usually build the link manually with `CONCAT('/product.sql?product=', product_name)`, which would fail if the product name contained special characters like '&'. The new `sqlpage.link` function takes care of encoding the parameters correctly.
39+
- Calls to `json_object` are now accepted as arguments to SQLPage functions. This allows you to pass complex data structures to functions such as `sqlpage.fetch`, `sqlpage.run_sql`, and `sqlpage.link`.
3040

3141
## 0.24.0 (2024-06-23)
3242
- in the form component, searchable `select` fields now support more than 50 options. They used to display only the first 50 options.

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM --platform=$BUILDPLATFORM rust:1.78-slim as builder
1+
FROM --platform=$BUILDPLATFORM rust:1.79-slim as builder
22
WORKDIR /usr/src/sqlpage
33
ARG TARGETARCH
44
ARG BUILDARCH

examples/CRUD - Authentication/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ SET $_username = (
2121
```sql
2222
SELECT
2323
'redirect' AS component,
24-
'/login.sql?path=' || $_curpath AS link
24+
sqlpage.link('/login.sql', json_object('path', $_curpath)) AS link
2525
WHERE $_username IS NULL AND $_session_required;
2626
```
2727
4. The login page renders the login form, accepts the user credentials, and redirects to create_session.sql, passing the login credentials as POST variables.

examples/CRUD - Authentication/www/create_session.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
SELECT
44
'authentication' AS component,
5-
'login.sql?' || ifnull('path=' || $path, '') || '&error=1' AS link,
5+
'login.sql?' || ifnull('path=' || sqlpage.url_encode($path), '') || '&error=1' AS link,
66
:password AS password,
77
(SELECT password_hash
88
FROM accounts

examples/charts, computations and custom components/index.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ SELECT 'hero' as component,
1111
'Tap Tempo' as title,
1212
'Tap Tempo is a tool to **measure a tempo in bpm** by clicking a button in rythm.' as description_md,
1313
'drums by Nana Yaw Otoo.jpg' as image,
14-
'taptempo.sql?session=' || random() as link,
14+
sqlpage.link('taptempo.sql', json_object('session', random())) as link,
1515
'Start tapping !' as link_text;
1616

1717
SELECT 'text' as component,

examples/charts, computations and custom components/taptempo.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ SELECT 'big_button' as component,
88
(SELECT bpm || ' bpm' FROM tap_bpm WHERE tapping_session = $session ORDER BY day DESC LIMIT 1),
99
'Tap'
1010
) AS text,
11-
'taptempo.sql?session=' || $session as link;
11+
sqlpage.link('taptempo.sql', json_object('session', $session)) as link;
1212

1313
SELECT 'chart' as component, 'BPM over time' as title, 'area' as type, 'indigo' as color, 0 AS ymin, 200 AS ymax, 'BPM' as ytitle;
1414
SELECT * FROM (
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
INSERT INTO games(id)
22
VALUES(random())
33
RETURNING
4-
'http_header' as component,
5-
'game.sql?id='||id as "Location";
6-
7-
SELECT 'text' as component, 'redirecting to game...' as contents;
4+
'redirect' as component,
5+
CONCAT('game.sql?id=', id) as link;

examples/corporate-conundrum/game.sql

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@ INSERT INTO players(name, game_id)
55
SELECT $Player as name,
66
$id::integer as game_id
77
WHERE $Player IS NOT NULL;
8+
89
SELECT 'list' as component,
910
'Players' as title;
1011
SELECT name as title,
11-
'next-question.sql?game_id=' || game_id || '&player=' || name as link
12+
sqlpage.link(
13+
'next-question.sql',
14+
json_object(
15+
'game_id', game_id,
16+
'player', name
17+
)
18+
) as link
1219
FROM players
1320
WHERE game_id = $id::integer;
1421
---------------------------
Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
1-
SELECT 'http_header' as component,
2-
COALESCE(
3-
(
4-
SELECT 'question.sql?game_id=' || $game_id || '&question_id=' || game_questions.question_id || '&player=' || $player as "Location"
5-
FROM game_questions
6-
WHERE game_id = $game_id::integer
7-
AND NOT EXISTS (
8-
-- This will filter out questions that have already been answered by the player
9-
SELECT 1
10-
FROM answers
11-
WHERE answers.game_id = game_questions.game_id
12-
AND answers.player_name = $player
13-
AND answers.question_id = game_questions.question_id
14-
)
15-
ORDER BY game_order
16-
LIMIT 1
17-
),
18-
'game-over.sql?game_id=' || $game_id
19-
) as 'Location';
20-
SELECT 'text' as component,
21-
'redirecting to next question...' as contents;
1+
-- We need to redirect the user to the next question in the game if there is one, or to the game over page if there are no more questions.
2+
with next_question as (
3+
SELECT
4+
'question.sql' as page,
5+
json_object(
6+
'game_id', $game_id,
7+
'question_id', game_questions.question_id,
8+
'player', $player
9+
) as params
10+
FROM game_questions
11+
WHERE game_id = $game_id::integer
12+
AND NOT EXISTS (
13+
-- This will filter out questions that have already been answered by the player
14+
SELECT 1
15+
FROM answers
16+
WHERE answers.game_id = game_questions.game_id
17+
AND answers.player_name = $player
18+
AND answers.question_id = game_questions.question_id
19+
)
20+
ORDER BY game_order
21+
LIMIT 1
22+
),
23+
next_page as (
24+
SELECT * FROM next_question
25+
UNION ALL
26+
SELECT 'game-over.sql' as page, json_object('game_id', $game_id) as params
27+
WHERE NOT EXISTS (SELECT 1 FROM next_question)
28+
)
29+
SELECT 'redirect' as component,
30+
sqlpage.link(page, params) as link
31+
FROM next_page
32+
LIMIT 1;

examples/corporate-conundrum/question.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ select * FROM sqlpage_shell;
33
SELECT 'form' as component,
44
question_text as title,
55
'Submit your answer' as validate,
6-
'wait.sql?game_id='|| $game_id ||'&question_id=' || $question_id ||'&player=' || $player as action
6+
sqlpage.link('wait.sql', json_object('game_id', $game_id, 'question_id', $question_id, 'player', $player)) as action
77
FROM questions
88
where id = $question_id::integer;
99

examples/corporate-conundrum/wait.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
-- Redirect to the next question when all players have answered
2+
set page_params = json_object('game_id', $game_id, 'player', $player);
23
select CASE
34
(SELECT count(*) FROM answers WHERE question_id = $question_id AND game_id = $game_id::integer)
45
WHEN (SELECT count(*) FROM players WHERE game_id = $game_id::integer)
5-
THEN '0; next-question.sql?game_id=' || $game_id || '&player=' || $player
6+
THEN '0; ' || sqlpage.link('next-question.sql', $page_params)
67
ELSE 3
78
END as refresh,
89
sqlpage_shell.*
910
FROM sqlpage_shell;
11+
1012
-- Insert the answer into the answers table
1113
INSERT INTO answers(game_id, player_name, question_id, answer_value)
1214
SELECT $game_id::integer as game_id,

examples/official-site/blog.sql

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ SELECT 'list' AS component,
1010
SELECT title,
1111
description,
1212
icon,
13-
CASE
14-
WHEN external_url IS NOT NULL
15-
THEN external_url
16-
ELSE
17-
'?post=' || title
18-
END AS link
13+
sqlpage.link(
14+
COALESCE(external_url, ''),
15+
CASE WHEN external_url IS NULL THEN json_object('post', title) ELSE NULL END
16+
) AS link
1917
FROM blog_posts
2018
ORDER BY created_at DESC;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
INSERT INTO
2+
sqlpage_functions (
3+
"name",
4+
"introduced_in_version",
5+
"icon",
6+
"description_md"
7+
)
8+
VALUES
9+
(
10+
'link',
11+
'0.25.0',
12+
'link',
13+
'Returns the URL of a SQLPage file with the given parameters.
14+
15+
### Example
16+
17+
Let''s say you have a database of products, and you want the main page (`index.sql`) to link to the page of each product (`product.sql`) with the product name as a parameter.
18+
19+
In `index.sql`, you can use the `link` function to generate the URL of the product page for each product:
20+
21+
```sql
22+
select ''list'' as component;
23+
select
24+
name as title,
25+
sqlpage.link(''product.sql'', json_object(''product_name'', name)) as link;
26+
```
27+
28+
Using `sqlpage.link` is better than manually constructing the URL with `CONCAT(''product.sql?product_name='', name)`, because it ensures that the URL is properly encoded.
29+
The former works when the product name contains special characters like `&`, while the latter would break the URL.
30+
31+
In `product.sql`, you can then use `$product_name` to get the name of the product from the URL parameter:
32+
33+
```sql
34+
select ''text'' as component;
35+
select CONCAT(''Product: '', $product_name) as contents;
36+
```
37+
38+
### Parameters
39+
- `file` (TEXT): The name of the SQLPage file to link to.
40+
- `parameters` (JSON): The parameters to pass to the linked file.
41+
- `fragment` (TEXT): An optional fragment (hash) to append to the URL. This is useful for linking to a specific section of a page. For instance if `product.sql` contains `select ''text'' as component, ''product_description'' as id;`, you can link to the product description section with `sqlpage.link(''product.sql'', json_object(''product_name'', name), ''product_description'')`.
42+
'
43+
);
44+
45+
INSERT INTO
46+
sqlpage_function_parameters (
47+
"function",
48+
"index",
49+
"name",
50+
"description_md",
51+
"type"
52+
)
53+
VALUES
54+
(
55+
'link',
56+
1,
57+
'file',
58+
'The path of the SQLPage file to link to, relative to the current file.',
59+
'TEXT'
60+
),
61+
(
62+
'link',
63+
2,
64+
'parameters',
65+
'A JSON object with the parameters to pass to the linked file.',
66+
'JSON'
67+
),
68+
(
69+
'link',
70+
3,
71+
'fragment',
72+
'An optional fragment (hash) to append to the URL to link to a specific section of the target page.',
73+
'TEXT'
74+
);

lambda.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM rust:1.78-alpine as builder
1+
FROM rust:1.79-alpine as builder
22
RUN rustup component add clippy rustfmt
33
RUN apk add --no-cache musl-dev zip
44
WORKDIR /usr/src/sqlpage

src/webserver/database/sql.rs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,14 +587,26 @@ fn expr_to_stmt_param(arg: &mut Expr) -> Option<StmtParam> {
587587
..
588588
}),
589589
..
590-
}) if func_name_parts.len() == 1
591-
&& func_name_parts[0].value.eq_ignore_ascii_case("concat") =>
592-
{
593-
let mut concat_args = Vec::with_capacity(args.len());
594-
for arg in args {
595-
concat_args.push(function_arg_to_stmt_param(arg)?);
590+
}) if func_name_parts.len() == 1 => {
591+
let func_name = func_name_parts[0].value.as_str();
592+
if func_name.eq_ignore_ascii_case("concat") {
593+
let mut concat_args = Vec::with_capacity(args.len());
594+
for arg in args {
595+
concat_args.push(function_arg_to_stmt_param(arg)?);
596+
}
597+
Some(StmtParam::Concat(concat_args))
598+
} else if func_name.eq_ignore_ascii_case("json_object")
599+
|| func_name.eq_ignore_ascii_case("json_build_object")
600+
{
601+
let mut json_obj_args = Vec::with_capacity(args.len());
602+
for arg in args {
603+
json_obj_args.push(function_arg_to_stmt_param(arg)?);
604+
}
605+
Some(StmtParam::JsonObject(json_obj_args))
606+
} else {
607+
log::warn!("SQLPage cannot emulate the following function: {func_name}");
608+
None
596609
}
597-
Some(StmtParam::Concat(concat_args))
598610
}
599611
_ => {
600612
log::warn!("Unsupported function argument: {arg}");

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use super::RequestInfo;
22
use crate::webserver::{
3-
database::execute_queries::DbConn, http::SingleOrVec, request_variables::ParamMap,
3+
database::{
4+
execute_queries::DbConn, sqlpage_functions::url_parameter_deserializer::URLParameters,
5+
},
6+
http::SingleOrVec,
7+
request_variables::ParamMap,
48
ErrorWithStatus,
59
};
610
use anyhow::{anyhow, Context};
@@ -23,6 +27,8 @@ super::function_definition_macro::sqlpage_functions! {
2327
hash_password(password: Option<String>);
2428
header((&RequestInfo), name: Cow<str>);
2529

30+
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
31+
2632
path((&RequestInfo));
2733
persist_uploaded_file((&RequestInfo), field_name: Cow<str>, folder: Option<Cow<str>>, allowed_extensions: Option<Cow<str>>);
2834
protocol((&RequestInfo));
@@ -188,6 +194,29 @@ async fn header<'a>(request: &'a RequestInfo, name: Cow<'a, str>) -> Option<Cow<
188194
request.headers.get(&*name).map(SingleOrVec::as_json_str)
189195
}
190196

197+
/// Builds a URL from a file name and a JSON object conatining URL parameters.
198+
/// For instance, if the file is "index.sql" and the parameters are {"x": "hello world"},
199+
/// the result will be "index.sql?x=hello%20world".
200+
async fn link<'a>(
201+
file: Cow<'a, str>,
202+
parameters: Option<Cow<'a, str>>,
203+
hash: Option<Cow<'a, str>>,
204+
) -> anyhow::Result<String> {
205+
let mut url = file.into_owned();
206+
if let Some(parameters) = parameters {
207+
url.push('?');
208+
let encoded = serde_json::from_str::<URLParameters>(&parameters).with_context(|| {
209+
format!("link: invalid URL parameters: not a valid json object:\n{parameters}")
210+
})?;
211+
url.push_str(encoded.get());
212+
}
213+
if let Some(hash) = hash {
214+
url.push('#');
215+
url.push_str(&hash);
216+
}
217+
Ok(url)
218+
}
219+
191220
/// Returns the path component of the URL of the current request.
192221
async fn path(request: &RequestInfo) -> &str {
193222
&request.path

src/webserver/database/sqlpage_functions/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod function_definition_macro;
22
mod function_traits;
33
pub(super) mod functions;
44
mod http_fetch_request;
5+
mod url_parameter_deserializer;
56

67
use sqlparser::ast::FunctionArg;
78

0 commit comments

Comments
 (0)