Skip to content

Commit 15d1c85

Browse files
committed
feat: Enhanced BLOB support across all database backends
- Add comprehensive BLOB support for all supported databases: * PostgreSQL: BYTEA columns with data URL conversion * MySQL/MariaDB: BLOB columns with data URL conversion * MSSQL: VARBINARY, BIGVARBINARY, BINARY, IMAGE columns * SQLite: BLOB columns with data URL conversion - Create shared data URL conversion functions to eliminate code duplication - Add comprehensive tests for all database types - Update CHANGELOG.md with detailed feature description - All blob data is now consistently converted to data URLs with base64 encoding - Cross-database compatibility ensures identical blob behavior across all backends - Comprehensive testing validates functionality across PostgreSQL, MySQL, MariaDB, MSSQL, and SQLite
1 parent 15c0d24 commit 15d1c85

File tree

4 files changed

+104
-20
lines changed

4 files changed

+104
-20
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
- 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.
1212
- modals now have an `open` parameter to open the modal automatically when the page is loaded.
1313
- 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.
14+
- **Enhanced BLOB Support**: Comprehensive improvements to binary data handling across all supported databases:
15+
- **PostgreSQL**: Full support for `BYTEA` columns with automatic conversion to data URLs
16+
- **MySQL/MariaDB**: Full support for `BLOB` columns with automatic conversion to data URLs
17+
- **MSSQL**: Extended support for `VARBINARY`, `BIGVARBINARY`, `BINARY`, and `IMAGE` columns with automatic conversion to data URLs
18+
- **SQLite**: Full support for `BLOB` columns with automatic conversion to data URLs
19+
- **Unified API**: All blob data is now consistently converted to data URLs with base64 encoding, eliminating code duplication
20+
- **Cross-Database Compatibility**: Blob functionality now works identically across all supported database backends
21+
- **Comprehensive Testing**: Added blob tests for all database types ensuring reliable functionality
1422

1523
## v0.36.1
1624
- Fix regression introduced in v0.36.0: PostgreSQL money values showed as 0.0

docker-compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
# DATABASE_URL='postgres://root:Password123!@localhost/sqlpage'
55
# DATABASE_URL='mssql://root:Password123!@localhost/sqlpage'
66
# DATABASE_URL='mysql://root:Password123!@localhost/sqlpage'
7+
8+
# Run for instance:
9+
# docker compose up postgres
10+
# and in another terminal:
11+
# DATABASE_URL='db_url' cargo test
712
services:
813
web:
914
build: { context: "." }

src/webserver/database/sql_to_json.rs

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ pub fn sql_nonnull_to_json<'r>(mut get_ref: impl FnMut() -> sqlx::any::AnyValueR
9696
decode_raw::<f64>(raw_value).into()
9797
}
9898
"JSON" | "JSON[]" | "JSONB" | "JSONB[]" => decode_raw::<Value>(raw_value),
99+
"BLOB" | "BYTEA" | "FILESTREAM" | "VARBINARY" | "BIGVARBINARY" | "BINARY" | "IMAGE" => vec_to_data_uri_value(decode_raw::<Vec<u8>>(raw_value)),
99100
// Deserialize as a string by default
100101
_ => decode_raw::<String>(raw_value).into(),
101102
}
@@ -111,6 +112,30 @@ pub fn row_to_string(row: &AnyRow) -> Option<String> {
111112
}
112113
}
113114

115+
/// Converts binary data to a data URL string.
116+
/// This function is used by both SQL type conversion and file reading functions.
117+
pub fn vec_to_data_uri(bytes: Vec<u8>) -> String {
118+
vec_to_data_uri_with_mime(bytes, "application/octet-stream")
119+
}
120+
121+
/// Converts binary data to a data URL string with a specific MIME type.
122+
/// This function is used by both SQL type conversion and file reading functions.
123+
pub fn vec_to_data_uri_with_mime(bytes: Vec<u8>, mime_type: &str) -> String {
124+
let mut data_url = format!("data:{};base64,", mime_type);
125+
base64::Engine::encode_string(
126+
&base64::engine::general_purpose::STANDARD,
127+
&bytes,
128+
&mut data_url,
129+
);
130+
data_url
131+
}
132+
133+
/// Converts binary data to a data URL JSON value.
134+
/// This is a convenience function for SQL type conversion.
135+
pub fn vec_to_data_uri_value(bytes: Vec<u8>) -> Value {
136+
Value::String(vec_to_data_uri(bytes))
137+
}
138+
114139
#[cfg(test)]
115140
mod tests {
116141
use crate::app_config::tests::test_database_url;
@@ -171,7 +196,7 @@ mod tests {
171196
};
172197
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
173198
let row = sqlx::query(
174-
"SELECT
199+
"SELECT
175200
42::INT2 as small_int,
176201
42::INT4 as integer,
177202
42::INT8 as big_int,
@@ -189,7 +214,8 @@ mod tests {
189214
'{\"key\": \"value\"}'::JSONB as jsonb,
190215
age('2024-03-14'::timestamp, '2024-01-01'::timestamp) as age_interval,
191216
justify_interval(interval '1 year 2 months 3 days') as justified_interval,
192-
1234.56::MONEY as money_val",
217+
1234.56::MONEY as money_val,
218+
'\\x68656c6c6f20776f726c64'::BYTEA as blob_data",
193219
)
194220
.fetch_one(&mut c)
195221
.await?;
@@ -214,7 +240,8 @@ mod tests {
214240
"jsonb": {"key": "value"},
215241
"age_interval": "2 mons 13 days",
216242
"justified_interval": "1 year 2 mons 3 days",
217-
"money_val": "$1,234.56"
243+
"money_val": "$1,234.56",
244+
"blob_data": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ="
218245
}),
219246
);
220247
Ok(())
@@ -235,7 +262,8 @@ mod tests {
235262
'2024-03-14 13:14:15+02:00'::TIMESTAMPTZ as timestamptz,
236263
INTERVAL '-01:02:03' as time_interval,
237264
'{\"key\": \"value\"}'::JSON as json,
238-
1234.56::MONEY as money_val
265+
1234.56::MONEY as money_val,
266+
'\\x74657374'::BYTEA as blob_data
239267
where $1",
240268
)
241269
.bind(true)
@@ -250,7 +278,8 @@ mod tests {
250278
"timestamptz": "2024-03-14T11:14:15+00:00",
251279
"time_interval": "-01:02:03",
252280
"json": {"key": "value"},
253-
"money_val": "" // TODO: fix this bug: https://github.com/sqlpage/SQLPage/issues/983
281+
"money_val": "", // TODO: fix this bug: https://github.com/sqlpage/SQLPage/issues/983
282+
"blob_data": "data:application/octet-stream;base64,dGVzdA=="
254283
}),
255284
);
256285
Ok(())
@@ -287,9 +316,10 @@ mod tests {
287316
year_val YEAR,
288317
char_val CHAR(10),
289318
varchar_val VARCHAR(50),
290-
text_val TEXT
291-
) AS
292-
SELECT
319+
text_val TEXT,
320+
blob_val BLOB
321+
) AS
322+
SELECT
293323
127 as tiny_int,
294324
32767 as small_int,
295325
8388607 as medium_int,
@@ -311,7 +341,8 @@ mod tests {
311341
2024 as year_val,
312342
'CHAR' as char_val,
313343
'VARCHAR' as varchar_val,
314-
'TEXT' as text_val",
344+
'TEXT' as text_val,
345+
x'626c6f62' as blob_val",
315346
)
316347
.execute(&mut c)
317348
.await?;
@@ -344,7 +375,8 @@ mod tests {
344375
"year_val": 2024,
345376
"char_val": "CHAR",
346377
"varchar_val": "VARCHAR",
347-
"text_val": "TEXT"
378+
"text_val": "TEXT",
379+
"blob_val": "data:application/octet-stream;base64,YmxvYg=="
348380
}),
349381
);
350382

@@ -375,7 +407,7 @@ mod tests {
375407
"integer": 42,
376408
"real": 42.25,
377409
"string": "xxx",
378-
"blob": "hello world",
410+
"blob": "data:application/octet-stream;base64,aGVsbG8gd29ybGQ=",
379411
}),
380412
);
381413
Ok(())
@@ -388,7 +420,7 @@ mod tests {
388420
};
389421
let mut c = sqlx::AnyConnection::connect(&db_url).await?;
390422
let row = sqlx::query(
391-
"SELECT
423+
"SELECT
392424
CAST(1 AS BIT) as true_bit,
393425
CAST(0 AS BIT) as false_bit,
394426
CAST(NULL AS BIT) as null_bit,
@@ -407,7 +439,8 @@ mod tests {
407439
N'Unicode String' as nvarchar,
408440
'ASCII String' as varchar,
409441
CAST(1234.56 AS MONEY) as money_val,
410-
CAST(12.34 AS SMALLMONEY) as small_money_val",
442+
CAST(12.34 AS SMALLMONEY) as small_money_val,
443+
CAST(0x6D7373716C AS VARBINARY(10)) as blob_data",
411444
)
412445
.fetch_one(&mut c)
413446
.await?;
@@ -433,12 +466,54 @@ mod tests {
433466
"nvarchar": "Unicode String",
434467
"varchar": "ASCII String",
435468
"money_val": 1234.56,
436-
"small_money_val": 12.34
469+
"small_money_val": 12.34,
470+
"blob_data": "data:application/octet-stream;base64,bXNzcWw="
437471
}),
438472
);
439473
Ok(())
440474
}
441475

476+
#[test]
477+
fn test_vec_to_data_uri() {
478+
// Test with empty bytes
479+
let result = vec_to_data_uri(vec![]);
480+
assert_eq!(result, "data:application/octet-stream;base64,");
481+
482+
// Test with simple text
483+
let result = vec_to_data_uri(b"Hello World".to_vec());
484+
assert_eq!(result, "data:application/octet-stream;base64,SGVsbG8gV29ybGQ=");
485+
486+
// Test with binary data
487+
let binary_data = vec![0, 1, 2, 255, 254, 253];
488+
let result = vec_to_data_uri(binary_data);
489+
assert_eq!(result, "data:application/octet-stream;base64,AAEC//79");
490+
}
491+
492+
#[test]
493+
fn test_vec_to_data_uri_with_mime() {
494+
// Test with custom MIME type
495+
let result = vec_to_data_uri_with_mime(b"Hello".to_vec(), "text/plain");
496+
assert_eq!(result, "data:text/plain;base64,SGVsbG8=");
497+
498+
// Test with image MIME type
499+
let result = vec_to_data_uri_with_mime(vec![255, 216, 255], "image/jpeg");
500+
assert_eq!(result, "data:image/jpeg;base64,/9j/");
501+
502+
// Test with empty bytes and custom MIME
503+
let result = vec_to_data_uri_with_mime(vec![], "application/json");
504+
assert_eq!(result, "data:application/json;base64,");
505+
}
506+
507+
#[test]
508+
fn test_vec_to_data_uri_value() {
509+
// Test that it returns a JSON string value
510+
let result = vec_to_data_uri_value(b"test".to_vec());
511+
match result {
512+
Value::String(s) => assert_eq!(s, "data:application/octet-stream;base64,dGVzdA=="),
513+
_ => panic!("Expected String value"),
514+
}
515+
}
516+
442517
fn expect_json_object_equal(actual: &Value, expected: &Value) {
443518
use std::fmt::Write;
444519

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::RequestInfo;
22
use crate::webserver::{
33
database::{
44
execute_queries::DbConn, sqlpage_functions::url_parameter_deserializer::URLParameters,
5+
sql_to_json::vec_to_data_uri_with_mime,
56
},
67
http::SingleOrVec,
78
http_client::make_http_client,
@@ -504,12 +505,7 @@ async fn read_file_as_data_url<'a>(
504505
|| Cow::Owned(mime_guess_from_filename(&file_path)),
505506
Cow::Borrowed,
506507
);
507-
let mut data_url = format!("data:{mime};base64,");
508-
base64::Engine::encode_string(
509-
&base64::engine::general_purpose::STANDARD,
510-
bytes,
511-
&mut data_url,
512-
);
508+
let data_url = vec_to_data_uri_with_mime(bytes, &mime.to_string());
513509
Ok(Some(Cow::Owned(data_url)))
514510
}
515511

0 commit comments

Comments
 (0)