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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
- Since modals have their own url inside the page, you can now link to a modal from another page, and if you refresh a page while the modal is open, the modal will stay open.
- modals now have an `open` parameter to open the modal automatically when the page is loaded.
- New [download](https://sql-page.com/component.sql?component=download) component to let the user download files. The files may be stored as BLOBs in the database, local files on the server, or may be fetched from a different server.
- **Enhanced BLOB Support**. You can now return binary data (BLOBs) directly to sqlpage, and it will automatically convert them to data URLs. This allows you to use database BLOBs directly wherever a link is expected, including in the new download component.
- supports columns of type `BYTEA` (PostgreSQL), `BLOB` (MySQL, SQLite), `VARBINARY` and `IMAGE` (mssql)
- Automatic detection of common file types based on magic bytes
- This means you can use a BLOB wherever an image url is expected. For instance:
```sql
select 'list' as component;
select username as title, avatar_blob as image_url
from users;
```

## v0.36.1
- Fix regression introduced in v0.36.0: PostgreSQL money values showed as 0.0
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
# DATABASE_URL='postgres://root:Password123!@localhost/sqlpage'
# DATABASE_URL='mssql://root:Password123!@localhost/sqlpage'
# DATABASE_URL='mysql://root:Password123!@localhost/sqlpage'

# Run for instance:
# docker compose up postgres
# and in another terminal:
# DATABASE_URL='db_url' cargo test
services:
web:
build: { context: "." }
Expand Down
49 changes: 49 additions & 0 deletions examples/official-site/extensions-to-sql.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,52 @@ SET post_id = COALESCE($post_id, 0);
-- Prepared statement (SQLite syntax)
SELECT COALESCE(CAST(?1 AS TEXT), 0)
```

# Data types

Each database has its own rich set of data types.
The data modal in SQLPage itself is simpler, mainly composed of text strings and json objects.

### From the user to SQLPage

Form fields and URL parameters may contain arrays. These are converted to JSON strings before processing.

For instance, Loading `users.sql?user[]=Tim&user[]=Tom` will result in a single variable `$user` with the textual value `["Tim", "Tom"]`.

### From SQLPage to the database

SQLPage sends only text strings (`VARCHAR`) and `NULL`s to the database, since these are the only possible variable and function return values.

### From the database to SQLPage

Each row of data returned by a SQL query is converted to a JSON object before being passed to components.

- Each column becomes a key in the json object. If a row has two columns of the same name, they become an array in the json object.
- Each value is converted to the closest JSON value
- all number types map to json numbers, booleans to booleans, and `NULL` to `null`,
- all text types map to json strings
- date and time types map to json strings containing ISO datetime values
- binary values (BLOBs) map to json strings containing [data URLs](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data)

#### Example

The following PostgreSQL query:

```sql
select
1 as one,
'x' as my_array, 'y' as my_array,
now() as today,
'<svg></svg>'::bytea as my_image;
```

will result in the following JSON object being passed to components for rendering

```json
{
"one" : 1,
"my_array" : ["x","y"],
"today":"2025-08-30T06:40:13.894918+00:00",
"my_image":"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="
}
```
5 changes: 4 additions & 1 deletion examples/official-site/extensions-to-sql.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
select 'http_header' as component,
'public, max-age=300, stale-while-revalidate=3600, stale-if-error=86400' as "Cache-Control";

select 'dynamic' as component, properties FROM example WHERE component = 'shell' LIMIT 1;
select 'dynamic' as component, json_patch(json_extract(properties, '$[0]'), json_object(
'title', 'SQLPage - Extensions to SQL'
)) as properties
FROM example WHERE component = 'shell' LIMIT 1;

-- Article by Matthew Larkin
select 'text' as component,
Expand Down
20 changes: 19 additions & 1 deletion examples/official-site/sqlpage/migrations/65_download.sql
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,25 @@ select
'
## Serve an image stored as a BLOB in the database

In PostgreSQL, you can use the [encode(bytes, format)](https://www.postgresql.org/docs/current/functions-binarystring.html#FUNCTION-ENCODE) function to encode the file content as Base64.
### Automatically detect the mime type

If you have a table with a column `content` that contains a BLOB
(depending on the database, the type may be named `BYTEA`, `BLOB`, `VARBINARY`, or `IMAGE`),
you can just return its contents directly, and SQLPage will automatically detect the mime type,
and convert it to a data URL.

```sql
select
''download'' as component,
content as data_url
from document
where id = $doc_id;
```

### Customize the mime type

In PostgreSQL, you can use the [encode(bytes, format)](https://www.postgresql.org/docs/current/functions-binarystring.html#FUNCTION-ENCODE) function to encode the file content as Base64,
and manually create your own data URL.

```sql
select
Expand Down
189 changes: 189 additions & 0 deletions src/webserver/database/blob_to_data_url.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/// Detects MIME type based on file signatures (magic bytes).
/// Returns the most appropriate MIME type for common file formats.
#[must_use]
pub fn detect_mime_type(bytes: &[u8]) -> &'static str {
// PNG: 89 50 4E 47 0D 0A 1A 0A
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return "image/png";
}
// JPEG: FF D8
if bytes.starts_with(b"\xFF\xD8") {
return "image/jpeg";
}
// GIF87a/89a: GIF87a or GIF89a
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return "image/gif";
}
// BMP: 42 4D
if bytes.starts_with(b"BM") {
return "image/bmp";
}
// WebP: RIFF....WEBP
if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
return "image/webp";
}
// PDF: %PDF
if bytes.starts_with(b"%PDF") {
return "application/pdf";
}
// ZIP: 50 4B 03 04
if bytes.starts_with(b"PK\x03\x04") {
// Check for Office document types in ZIP central directory
if bytes.len() >= 50 {
let central_dir = &bytes[30..bytes.len().min(50)];
if central_dir.windows(6).any(|w| w == b"word/") {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
}
if central_dir.windows(3).any(|w| w == b"xl/") {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
if central_dir.windows(4).any(|w| w == b"ppt/") {
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
}
}
return "application/zip";
}

if bytes.starts_with(b"<?xml") {
return "application/xml";
}
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<!DOCTYPE svg") {
return "image/svg+xml";
}
if bytes.starts_with(b"{") || bytes.starts_with(b"[") {
return "application/json";
}

"application/octet-stream"
}

/// Converts binary data to a data URL string.
/// This function is used by both SQL type conversion and file reading functions.
/// Automatically detects common file types based on magic bytes.
#[must_use]
pub fn vec_to_data_uri(bytes: &[u8]) -> String {
let mime_type = detect_mime_type(bytes);
vec_to_data_uri_with_mime(bytes, mime_type)
}

/// Converts binary data to a data URL string with a specific MIME type.
/// This function is used by both SQL type conversion and file reading functions.
#[must_use]
pub fn vec_to_data_uri_with_mime(bytes: &[u8], mime_type: &str) -> String {
let mut data_url = format!("data:{mime_type};base64,");
base64::Engine::encode_string(
&base64::engine::general_purpose::STANDARD,
bytes,
&mut data_url,
);
data_url
}

/// Converts binary data to a data URL JSON value.
/// This is a convenience function for SQL type conversion.
#[must_use]
pub fn vec_to_data_uri_value(bytes: &[u8]) -> serde_json::Value {
serde_json::Value::String(vec_to_data_uri(bytes))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_detect_mime_type() {
// Test empty data
assert_eq!(detect_mime_type(&[]), "application/octet-stream");

// Test PNG
assert_eq!(detect_mime_type(b"\x89PNG\r\n\x1a\n"), "image/png");

// Test JPEG
assert_eq!(detect_mime_type(b"\xFF\xD8\xFF\xE0"), "image/jpeg");

// Test GIF87a
assert_eq!(detect_mime_type(b"GIF87a"), "image/gif");

// Test GIF89a
assert_eq!(detect_mime_type(b"GIF89a"), "image/gif");

// Test BMP
assert_eq!(detect_mime_type(b"BM\x00\x00"), "image/bmp");

// Test PDF
assert_eq!(detect_mime_type(b"%PDF-"), "application/pdf");

// Test SVG
assert_eq!(
detect_mime_type(b"<svg xmlns=\"http://www.w3.org/2000/svg\">"),
"image/svg+xml"
);

// Test XML (non-SVG)
assert_eq!(
detect_mime_type(b"<?xml version=\"1.0\"?><root><data>test</data></root>"),
"application/xml"
);

// Test JSON
assert_eq!(
detect_mime_type(b"{\"key\": \"value\"}"),
"application/json"
);

// Test ZIP
assert_eq!(detect_mime_type(b"PK\x03\x04"), "application/zip");

// Test unknown data
assert_eq!(
detect_mime_type(&[0x00, 0x01, 0x02, 0x03]),
"application/octet-stream"
);
}

#[test]
fn test_vec_to_data_uri() {
// Test with empty bytes
let result = vec_to_data_uri(&[]);
assert_eq!(result, "data:application/octet-stream;base64,");

// Test with simple text
let result = vec_to_data_uri(b"Hello World");
assert_eq!(
result,
"data:application/octet-stream;base64,SGVsbG8gV29ybGQ="
);

// Test with binary data
let binary_data = [0, 1, 2, 255, 254, 253];
let result = vec_to_data_uri(&binary_data);
assert_eq!(result, "data:application/octet-stream;base64,AAEC//79");
}

#[test]
fn test_vec_to_data_uri_with_mime() {
// Test with custom MIME type
let result = vec_to_data_uri_with_mime(b"Hello", "text/plain");
assert_eq!(result, "data:text/plain;base64,SGVsbG8=");

// Test with image MIME type
let result = vec_to_data_uri_with_mime(&[255, 216, 255], "image/jpeg");
assert_eq!(result, "data:image/jpeg;base64,/9j/");

// Test with empty bytes and custom MIME
let result = vec_to_data_uri_with_mime(&[], "application/json");
assert_eq!(result, "data:application/json;base64,");
}

#[test]
fn test_vec_to_data_uri_value() {
// Test that it returns a JSON string value
let result = vec_to_data_uri_value(b"test");
match result {
serde_json::Value::String(s) => {
assert_eq!(s, "data:application/octet-stream;base64,dGVzdA==");
}
_ => panic!("Expected String value"),
}
}
}
1 change: 1 addition & 0 deletions src/webserver/database/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod blob_to_data_url;
mod connect;
mod csv_import;
pub mod execute_queries;
Expand Down
Loading
Loading