Skip to content

Commit 184440f

Browse files
authored
Merge pull request #253 from lovasoa/run-sql
sqlpage.run_sql
2 parents 9abc9f8 + 943e77d commit 184440f

17 files changed

+512
-78
lines changed

CHANGELOG.md

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

33
## 0.20.0 (unreleased)
44

5+
- **file inclusion**. This is a long awaited feature that allows you to include the contents of one file in another. This is useful to factorize common parts of your website, such as the header, or the authentication logic. There is a new [`sqlpage.run_sql`](https://sql.ophir.dev/functions.sql?function=run_sql#function) function that runs a given SQL file and returns its result as a JSON array. Combined with the existing [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component, this allows you to include the content of a file in another, like this:
6+
```sql
7+
select 'dynamic' as component, sqlpage.run_sql('header.sql') as properties;
8+
```
9+
- **more powerful *dynamic* component**: the [`dynamic`](https://sql.ophir.dev/documentation.sql?component=dynamic#component) component can now be used to generate the special *header* components too, such as the `redirect`, `cookie`, `authentication`, `http_header` and `json` components. The *shell* component used to be allowed in dynamic components, but only if they were not nested (a dynamic component inside another one). This limitation is now lifted. This is particularly useful in combination with the new file inclusion feature, to factorize common parts of your website. There used to be a limited to how deeply nested dynamic components could be, but this limitation is now lifted too.
510
- Add an `id` attribute to form fields in the [form](https://sql.ophir.dev/documentation.sql?component=form#component) component. This allows you to easily reference form fields in custom javascript code.
611
- New [`rss`](https://sql.ophir.dev/documentation.sql?component=rss#component) component to create RSS feeds, including **podcast feeds**. You can now create and manage your podcast feed entirely in SQL, and distribute it to all podcast directories such as Apple Podcasts, Spotify, and Google Podcasts.
712
- Better error handling in template rendering. Many template helpers now display a more precise error message when they fail to execute. This makes it easier to debug errors when you [develop your own custom components](https://sql.ophir.dev/custom_components.sql).
13+
- better error messages when an error occurs when defining a variable with `SET`. SQLPage now displays the query that caused the error, and the name of the variable that was being defined.
814
- Updated SQL parser to [v0.44](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0440-2024-03-02)
915
- support [EXECUTE ... USING](https://www.postgresql.org/docs/current/plpgsql-statements.html#PLPGSQL-STATEMENTS-EXECUTING-DYN) in PostgreSQL
1016
- support `INSERT INTO ... SELECT ... RETURNING`, which allows you to insert data into a table, and easily pass values from the inserted row to a SQLPage component. [postgres docs](https://www.postgresql.org/docs/current/dml-returning.html), [mysql docs](https://mariadb.com/kb/en/insertreturning/), [sqlite docs](https://sqlite.org/lang_returning.html)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
INSERT INTO sqlpage_functions (
2+
"name",
3+
"introduced_in_version",
4+
"icon",
5+
"description_md"
6+
)
7+
VALUES (
8+
'run_sql',
9+
'0.20.0',
10+
'login',
11+
'Executes another SQL file and returns its result as a JSON array.
12+
13+
### Example
14+
15+
#### Include a common header in all your pages
16+
17+
It is common to want to run the same SQL queries at the beginning of all your pages,
18+
to check if an user is logged in, render a header, etc.
19+
You can create a file called `common_header.sql`, and use the `dynamic` component with the `run_sql` function to include it in all your pages.
20+
21+
```sql
22+
select ''dynamic'' as component, sqlpage.run_sql(''common_header.sql'') as properties;
23+
```
24+
25+
#### Notes
26+
27+
- **recursion**: you can use `run_sql` to include a file that itself includes another file, and so on. However, be careful to avoid infinite loops. SQLPage will throw an error if the inclusion depth is superior to 8.
28+
- **security**: be careful when using `run_sql` to include files. Never use `run_sql` with a user-provided parameter. Never run a file uploaded by a user, or a file that is not under your control.
29+
- **variables**: the included file will have access to the same variables (URL parameters, POST variables, etc.)
30+
as the calling file. It will not have access to uploaded files.
31+
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.
32+
'
33+
);
34+
INSERT INTO sqlpage_function_parameters (
35+
"function",
36+
"index",
37+
"name",
38+
"description_md",
39+
"type"
40+
)
41+
VALUES (
42+
'run_sql',
43+
1,
44+
'file',
45+
'Path to the SQL file to execute, can be absolute, or relative to the web root (the root folder of your website sql files).
46+
In-database files, from the sqlpage_files(path, contents, last_modified) table are supported.',
47+
'TEXT'
48+
);

src/dynamic_component.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
use anyhow::{self, Context as _};
2+
use serde_json::Value as JsonValue;
3+
4+
use crate::webserver::database::DbItem;
5+
6+
pub fn parse_dynamic_rows(row: DbItem) -> impl Iterator<Item = DbItem> {
7+
DynamicComponentIterator {
8+
stack: vec![],
9+
db_item: Some(row),
10+
}
11+
}
12+
13+
struct DynamicComponentIterator {
14+
stack: Vec<anyhow::Result<JsonValue>>,
15+
db_item: Option<DbItem>,
16+
}
17+
18+
impl Iterator for DynamicComponentIterator {
19+
type Item = DbItem;
20+
21+
fn next(&mut self) -> Option<Self::Item> {
22+
if let Some(db_item) = self.db_item.take() {
23+
if let DbItem::Row(mut row) = db_item {
24+
if let Some(properties) = extract_dynamic_properties(&mut row) {
25+
self.stack = dynamic_properties_to_vec(properties);
26+
} else {
27+
// Most common case: just a regular row. We allocated nothing.
28+
return Some(DbItem::Row(row));
29+
}
30+
} else {
31+
return Some(db_item);
32+
}
33+
}
34+
expand_dynamic_stack(&mut self.stack);
35+
self.stack.pop().map(|result| match result {
36+
Ok(row) => DbItem::Row(row),
37+
Err(err) => DbItem::Error(err),
38+
})
39+
}
40+
}
41+
42+
fn expand_dynamic_stack(stack: &mut Vec<anyhow::Result<JsonValue>>) {
43+
while let Some(Ok(mut next)) = stack.pop() {
44+
if let Some(properties) = extract_dynamic_properties(&mut next) {
45+
stack.extend(dynamic_properties_to_vec(properties));
46+
} else {
47+
stack.push(Ok(next));
48+
return;
49+
}
50+
}
51+
}
52+
53+
/// if row.component == 'dynamic', return Some(row.properties), otherwise return None
54+
#[inline]
55+
fn extract_dynamic_properties(data: &mut JsonValue) -> Option<JsonValue> {
56+
let component = data.get("component").and_then(|v| v.as_str());
57+
if component == Some("dynamic") {
58+
let properties = data.get_mut("properties").map(JsonValue::take);
59+
Some(properties.unwrap_or_default())
60+
} else {
61+
None
62+
}
63+
}
64+
65+
/// reverse the order of the vec returned by `dynamic_properties_to_result_vec`,
66+
/// and wrap each element in a Result
67+
fn dynamic_properties_to_vec(properties_obj: JsonValue) -> Vec<anyhow::Result<JsonValue>> {
68+
dynamic_properties_to_result_vec(properties_obj).map_or_else(
69+
|err| vec![Err(err)],
70+
|vec| vec.into_iter().rev().map(Ok).collect::<Vec<_>>(),
71+
)
72+
}
73+
74+
/// if properties is a string, parse it as JSON and return a vec with the parsed value
75+
/// if properties is an array, return it as is
76+
/// if properties is an object, return it as a single element vec
77+
/// otherwise, return an error
78+
fn dynamic_properties_to_result_vec(
79+
mut properties_obj: JsonValue,
80+
) -> anyhow::Result<Vec<JsonValue>> {
81+
if let JsonValue::String(s) = properties_obj {
82+
properties_obj = serde_json::from_str::<JsonValue>(&s).with_context(|| {
83+
format!(
84+
"Unable to parse the 'properties' property of the dynamic component as JSON.\n\
85+
Invalid json: {s}"
86+
)
87+
})?;
88+
}
89+
match properties_obj {
90+
obj @ JsonValue::Object(_) => Ok(vec![obj]),
91+
JsonValue::Array(values) => Ok(values),
92+
other => anyhow::bail!(
93+
"Dynamic component expected properties of type array or object, got {other} instead."
94+
),
95+
}
96+
}
97+
98+
#[cfg(test)]
99+
mod tests {
100+
use super::*;
101+
102+
#[test]
103+
fn test_dynamic_properties_to_result_vec() {
104+
let mut properties = JsonValue::String(r#"{"a": 1}"#.to_string());
105+
assert_eq!(
106+
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
107+
vec![JsonValue::Object(
108+
serde_json::from_str(r#"{"a": 1}"#).unwrap()
109+
)]
110+
);
111+
112+
properties = JsonValue::Array(vec![JsonValue::String(r#"{"a": 1}"#.to_string())]);
113+
assert_eq!(
114+
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
115+
vec![JsonValue::String(r#"{"a": 1}"#.to_string())]
116+
);
117+
118+
properties = JsonValue::Object(serde_json::from_str(r#"{"a": 1}"#).unwrap());
119+
assert_eq!(
120+
dynamic_properties_to_result_vec(properties.clone()).unwrap(),
121+
vec![JsonValue::Object(
122+
serde_json::from_str(r#"{"a": 1}"#).unwrap()
123+
)]
124+
);
125+
126+
properties = JsonValue::Null;
127+
assert!(dynamic_properties_to_result_vec(properties).is_err());
128+
}
129+
130+
#[test]
131+
fn test_dynamic_properties_to_vec() {
132+
let properties = JsonValue::String(r#"{"a": 1}"#.to_string());
133+
assert_eq!(
134+
dynamic_properties_to_vec(properties.clone())
135+
.first()
136+
.unwrap()
137+
.as_ref()
138+
.unwrap(),
139+
&serde_json::json!({"a": 1})
140+
);
141+
}
142+
143+
#[test]
144+
fn test_parse_dynamic_rows() {
145+
let row = DbItem::Row(serde_json::json!({
146+
"component": "dynamic",
147+
"properties": [
148+
{"a": 1},
149+
{"component": "dynamic", "properties": {"nested": 2}},
150+
]
151+
}));
152+
let iter = parse_dynamic_rows(row)
153+
.map(|item| match item {
154+
DbItem::Row(row) => row,
155+
x => panic!("Expected a row, got {x:?}"),
156+
})
157+
.collect::<Vec<_>>();
158+
assert_eq!(
159+
iter,
160+
vec![
161+
serde_json::json!({"a": 1}),
162+
serde_json::json!({"nested": 2}),
163+
]
164+
);
165+
}
166+
}

src/file_cache.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use anyhow::Context;
55
use async_trait::async_trait;
66
use chrono::{DateTime, TimeZone, Utc};
77
use std::collections::HashMap;
8-
use std::path::PathBuf;
8+
use std::path::{Path, PathBuf};
99
use std::sync::atomic::{
1010
AtomicU64,
1111
Ordering::{Acquire, Release},
@@ -97,7 +97,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
9797

9898
/// Gets a file from the cache, or loads it from the file system if it's not there
9999
/// This is a privileged operation; it should not be used for user-provided paths
100-
pub async fn get(&self, app_state: &AppState, path: &PathBuf) -> anyhow::Result<Arc<T>> {
100+
pub async fn get(&self, app_state: &AppState, path: &Path) -> anyhow::Result<Arc<T>> {
101101
self.get_with_privilege(app_state, path, true).await
102102
}
103103

@@ -107,7 +107,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
107107
pub async fn get_with_privilege(
108108
&self,
109109
app_state: &AppState,
110-
path: &PathBuf,
110+
path: &Path,
111111
privileged: bool,
112112
) -> anyhow::Result<Arc<T>> {
113113
log::trace!("Attempting to get from cache {:?}", path);
@@ -164,7 +164,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
164164
Ok(value) => {
165165
let new_val = Arc::clone(&value.content);
166166
log::trace!("Writing to cache {:?}", path);
167-
self.cache.write().await.insert(path.clone(), value);
167+
self.cache.write().await.insert(PathBuf::from(path), value);
168168
log::trace!("Done writing to cache {:?}", path);
169169
log::trace!("{:?} loaded in cache", path);
170170
Ok(new_val)

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
extern crate core;
55

66
pub mod app_config;
7+
pub mod dynamic_component;
78
pub mod file_cache;
89
pub mod filesystem;
910
pub mod render;

src/render.rs

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,12 @@ pub struct RenderContext<W: std::io::Write> {
274274
pub writer: W,
275275
current_component: Option<SplitTemplateRenderer>,
276276
shell_renderer: SplitTemplateRenderer,
277-
recursion_depth: usize,
278277
current_statement: usize,
279278
}
280279

281280
const DEFAULT_COMPONENT: &str = "table";
282281
const PAGE_SHELL_COMPONENT: &str = "shell";
283282
const FRAGMENT_SHELL_COMPONENT: &str = "shell-empty";
284-
const DYNAMIC_COMPONENT: &str = "dynamic";
285-
const MAX_RECURSION_DEPTH: usize = 256;
286283

287284
impl<W: std::io::Write> RenderContext<W> {
288285
pub async fn new(
@@ -293,12 +290,7 @@ impl<W: std::io::Write> RenderContext<W> {
293290
) -> anyhow::Result<RenderContext<W>> {
294291
log::debug!("Creating the shell component for the page");
295292

296-
let mut initial_rows =
297-
if get_object_str(&initial_row, "component") == Some(DYNAMIC_COMPONENT) {
298-
Self::extract_dynamic_properties(&initial_row)?
299-
} else {
300-
vec![Cow::Borrowed(&initial_row)]
301-
};
293+
let mut initial_rows = vec![Cow::Borrowed(&initial_row)];
302294

303295
if !initial_rows
304296
.first()
@@ -336,7 +328,6 @@ impl<W: std::io::Write> RenderContext<W> {
336328
writer,
337329
current_component: None,
338330
shell_renderer,
339-
recursion_depth: 0,
340331
current_statement: 1,
341332
};
342333

@@ -367,11 +358,6 @@ impl<W: std::io::Write> RenderContext<W> {
367358
let new_component = get_object_str(data, "component");
368359
let current_component = self.current_component().await?.name();
369360
match (current_component, new_component) {
370-
(_current_component, Some(DYNAMIC_COMPONENT)) => {
371-
self.render_dynamic(data).await.with_context(|| {
372-
format!("Unable to render dynamic component with properties {data}")
373-
})?;
374-
}
375361
(
376362
_,
377363
Some(
@@ -398,41 +384,6 @@ impl<W: std::io::Write> RenderContext<W> {
398384
Ok(())
399385
}
400386

401-
fn extract_dynamic_properties(data: &Value) -> anyhow::Result<Vec<Cow<'_, JsonValue>>> {
402-
let properties_key = "properties";
403-
let properties_obj = data
404-
.get(properties_key)
405-
.with_context(|| format!("Missing '{properties_key}' key."))?;
406-
Ok(match properties_obj {
407-
Value::String(s) => match serde_json::from_str::<JsonValue>(s)
408-
.with_context(|| "parsing json properties")?
409-
{
410-
Value::Array(values) => values.into_iter().map(Cow::Owned).collect(),
411-
obj @ Value::Object(_) => vec![Cow::Owned(obj)],
412-
other => bail!(
413-
"Expected properties string to parse as array or object, got {other} instead."
414-
),
415-
},
416-
obj @ Value::Object(_) => vec![Cow::Borrowed(obj)],
417-
Value::Array(values) => values.iter().map(Cow::Borrowed).collect(),
418-
other => bail!("Expected properties of type array or object, got {other} instead."),
419-
})
420-
}
421-
422-
async fn render_dynamic(&mut self, data: &Value) -> anyhow::Result<()> {
423-
anyhow::ensure!(
424-
self.recursion_depth <= MAX_RECURSION_DEPTH,
425-
"Maximum recursion depth exceeded in the dynamic component."
426-
);
427-
for dynamic_row_obj in Self::extract_dynamic_properties(data)? {
428-
self.recursion_depth += 1;
429-
let res = self.handle_row(&dynamic_row_obj).await;
430-
self.recursion_depth -= 1;
431-
res?;
432-
}
433-
Ok(())
434-
}
435-
436387
#[allow(clippy::unused_async)]
437388
pub async fn finish_query(&mut self) -> anyhow::Result<()> {
438389
log::debug!("-> Query {} finished", self.current_statement);

0 commit comments

Comments
 (0)