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
58 changes: 58 additions & 0 deletions examples/official-site/sqlpage/migrations/66_log_component.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
INSERT INTO component(name, icon, description) VALUES
('log', 'logs', 'A Component to log a message to the Servers STDOUT or Log file on page load');

INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'log', * FROM (VALUES
-- top level
('message', 'The message that needs to be logged', 'TEXT', TRUE, FALSE),
('priority', 'The priority which the message should be logged with. Possible values are [''trace'', ''debug'', ''info'', ''warn'', ''error''] and are not case sensitive. If this value is missing or not matching any possible values, the default priority will be ''info''.', 'TEXT', TRUE, TRUE)
) x;

INSERT INTO example(component, description) VALUES
('log', '
### Hello World
Log a simple ''Hello, World!'' message on page load.
```sql
SELECT ''log'' as component,
''Hello, World!'' as message
```
Output example:
```
[2025-09-12T08:33:48.228Z INFO sqlpage::log from file "index.sql" in statement 3] Hello, World!
```
### Priority
Change the priority to error.
```sql
SELECT ''log'' as component,
''This is a error message'' as message,
''error'' as priority
```
Output example:
```
[2025-09-12T08:33:48.228Z ERROR sqlpage::log from file "index.sql" in header] This is a error message
```
### Retrieve user data
```sql
set username = ''user'' -- (retrieve username from somewhere)
select ''log'' as component,
''403 - failed for '' || coalesce($username, ''None'') as output,
''error'' as priority;
```
Output example:
```
[2025-09-12T08:33:48.228Z ERROR sqlpage::log from file "403.sql" in statement 7] 403 - failed for user
```
')
75 changes: 60 additions & 15 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ use serde::Serialize;
use serde_json::{json, Value};
use std::borrow::Cow;
use std::convert::TryFrom;
use std::fmt::Write as _;
use std::io::Write;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;

pub enum PageContext {
Expand Down Expand Up @@ -119,6 +122,7 @@ impl HeaderContext {
Some(HeaderComponent::Cookie) => self.add_cookie(&data).map(PageContext::Header),
Some(HeaderComponent::Authentication) => self.authentication(data).await,
Some(HeaderComponent::Download) => self.download(&data),
Some(HeaderComponent::Log) => self.log(&data),
None => self.start_body(data).await,
}
}
Expand Down Expand Up @@ -360,6 +364,11 @@ impl HeaderContext {
))
}

fn log(self, data: &JsonValue) -> anyhow::Result<PageContext> {
handle_log_component(&self.request_context.source_path, Option::None, data)?;
Ok(PageContext::Header(self))
}

async fn start_body(self, data: JsonValue) -> anyhow::Result<PageContext> {
let html_renderer =
HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data)
Expand Down Expand Up @@ -721,27 +730,43 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
component.starts_with(PAGE_SHELL_COMPONENT)
}

async fn handle_component(
&mut self,
component_name: &str,
data: &JsonValue,
) -> anyhow::Result<()> {
if Self::is_shell_component(component_name) {
bail!("There cannot be more than a single shell per page. You are trying to open the {} component, but a shell component is already opened for the current page. You can fix this by removing the extra shell component, or by moving this component to the top of the SQL file, before any other component that displays data.", component_name);
}

if component_name == "log" {
return handle_log_component(
&self.request_context.source_path,
Some(self.current_statement),
data,
);
}

match self.open_component_with_data(component_name, &data).await {
Ok(_) => Ok(()),
Err(err) => match HeaderComponent::try_from(component_name) {
Ok(_) => bail!("The {component_name} component cannot be used after data has already been sent to the client's browser. \n\
This component must be used before any other component. \n\
To fix this, either move the call to the '{component_name}' component to the top of the SQL file, \n\
or create a new SQL file where '{component_name}' is the first component."),
Err(()) => Err(err),
},
}
}

pub async fn handle_row(&mut self, data: &JsonValue) -> anyhow::Result<()> {
let new_component = get_object_str(data, "component");
let current_component = self
.current_component
.as_ref()
.map(SplitTemplateRenderer::name);
if let Some(comp_str) = new_component {
if Self::is_shell_component(comp_str) {
bail!("There cannot be more than a single shell per page. You are trying to open the {} component, but a shell component is already opened for the current page. You can fix this by removing the extra shell component, or by moving this component to the top of the SQL file, before any other component that displays data.", comp_str);
}

match self.open_component_with_data(comp_str, &data).await {
Ok(_) => (),
Err(err) => match HeaderComponent::try_from(comp_str) {
Ok(_) => bail!("The {comp_str} component cannot be used after data has already been sent to the client's browser. \n\
This component must be used before any other component. \n\
To fix this, either move the call to the '{comp_str}' component to the top of the SQL file, \n\
or create a new SQL file where '{comp_str}' is the first component."),
Err(()) => return Err(err),
},
}
if let Some(component_name) = new_component {
self.handle_component(component_name, data).await?;
} else if current_component.is_none() {
self.open_component_with_data(DEFAULT_COMPONENT, &JsonValue::Null)
.await?;
Expand Down Expand Up @@ -885,6 +910,24 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
}
}

fn handle_log_component(
source_path: &Path,
current_statement: Option<usize>,
data: &JsonValue,
) -> anyhow::Result<()> {
let priority = get_object_str(data, "priority").unwrap_or("info");
let log_level = log::Level::from_str(priority).with_context(|| "Invalid log priority value")?;

let mut target = format!("sqlpage::log from \"{}\"", source_path.display());
if let Some(current_statement) = current_statement {
write!(&mut target, " statement {current_statement}")?;
}

let message = get_object_str(data, "message").context("log: missing property 'message'")?;
log::log!(target: &target, log_level, "{message}");
Ok(())
}

pub(super) fn get_backtrace_as_strings(error: &anyhow::Error) -> Vec<String> {
let mut backtrace = vec![];
let mut source = error.source();
Expand Down Expand Up @@ -1108,6 +1151,7 @@ enum HeaderComponent {
Cookie,
Authentication,
Download,
Log,
}

impl TryFrom<&str> for HeaderComponent {
Expand All @@ -1122,6 +1166,7 @@ impl TryFrom<&str> for HeaderComponent {
"cookie" => Ok(Self::Cookie),
"authentication" => Ok(Self::Authentication),
"download" => Ok(Self::Download),
"log" => Ok(Self::Log),
_ => Err(()),
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/webserver/database/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use std::str::FromStr;
#[derive(Default)]
pub struct ParsedSqlFile {
pub(super) statements: Vec<ParsedStatement>,
pub(super) source_path: PathBuf,
pub source_path: PathBuf,
}

impl ParsedSqlFile {
Expand Down
4 changes: 4 additions & 0 deletions src/webserver/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ use tokio::sync::mpsc;
#[derive(Clone)]
pub struct RequestContext {
pub is_embedded: bool,
pub source_path: PathBuf,
pub content_security_policy: ContentSecurityPolicy,
}

Expand Down Expand Up @@ -147,6 +148,7 @@ async fn build_response_header_and_stream<S: Stream<Item = DbItem>>(
Ok(ResponseWithWriter::FinishedResponse { http_response })
}

#[allow(clippy::large_enum_variant)]
enum ResponseWithWriter<S> {
RenderStream {
http_response: HttpResponse,
Expand Down Expand Up @@ -174,9 +176,11 @@ async fn render_sql(
log::debug!("Received a request with the following parameters: {req_param:?}");

let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();
let source_path: PathBuf = sql_file.source_path.clone();
actix_web::rt::spawn(async move {
let request_context = RequestContext {
is_embedded: req_param.get_variables.contains_key("_sqlpage_embed"),
source_path,
content_security_policy: ContentSecurityPolicy::with_random_nonce(),
};
let mut conn = None;
Expand Down
6 changes: 6 additions & 0 deletions tests/sql_test_files/it_works_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
select 'log' as component,
'Hello, World!' as message,
'info' as priority;

select 'text' as component,
'It works !' as contents;
Loading