use super::*;
const SMALL_THUMBNAIL_BYTES: usize = 1_024;
const MEDIUM_THUMBNAIL_BYTES: usize = 8_192;
const LARGE_CHUNK_BYTES: usize = 65_536;
const XL_CHUNK_BYTES: usize = 131_072;
fn deterministic_blob(seed: u8, len: usize) -> Vec<u8> {
(0u8..=250)
.cycle()
.take(len)
.map(|offset| seed.wrapping_add(offset))
.collect()
}
fn blob_row(
id: Ulid,
label: &str,
bucket: u64,
thumbnail_seed: u8,
thumbnail_len: usize,
chunk_seed: u8,
chunk_len: usize,
) -> SessionSqlBlobEntity {
SessionSqlBlobEntity {
id,
label: label.to_string(),
bucket,
thumbnail: Blob::from(deterministic_blob(thumbnail_seed, thumbnail_len)),
chunk: Blob::from(deterministic_blob(chunk_seed, chunk_len)),
}
}
fn seed_blob_rows(session: &DbSession<SessionSqlCanister>) -> Vec<SessionSqlBlobEntity> {
let rows = vec![
blob_row(
Ulid::from_u128(9_101),
"hero-thumb-a",
7,
11,
SMALL_THUMBNAIL_BYTES,
31,
LARGE_CHUNK_BYTES,
),
blob_row(
Ulid::from_u128(9_102),
"hero-thumb-b",
7,
17,
MEDIUM_THUMBNAIL_BYTES,
37,
XL_CHUNK_BYTES,
),
blob_row(
Ulid::from_u128(9_103),
"archive-thumb",
9,
23,
SMALL_THUMBNAIL_BYTES,
43,
LARGE_CHUNK_BYTES,
),
];
for row in rows.iter().cloned() {
session
.insert(row)
.expect("large blob setup insert should succeed");
}
rows
}
fn blob_row_summaries(rows: Vec<Vec<Value>>) -> Vec<(String, u64, usize, usize)> {
rows.into_iter()
.map(|row| match row.as_slice() {
[
Value::Text(label),
Value::Uint(bucket),
Value::Blob(thumbnail),
Value::Blob(chunk),
] => (label.clone(), *bucket, thumbnail.len(), chunk.len()),
other => panic!("blob projection should emit label/bucket/blob/blob, got {other:?}"),
})
.collect()
}
fn blob_payload_pairs(rows: &[Vec<Value>]) -> Vec<(Vec<u8>, Vec<u8>)> {
rows.iter()
.map(|row| match row.as_slice() {
[Value::Blob(thumbnail), Value::Blob(chunk)] => (thumbnail.clone(), chunk.clone()),
other => panic!("blob payload projection should emit thumbnail/chunk, got {other:?}"),
})
.collect()
}
fn blob_payload_pairs_sorted_by_shape(rows: &[Vec<Value>]) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut pairs = blob_payload_pairs(rows);
pairs.sort_by_key(|(thumbnail, chunk)| (thumbnail.len(), chunk.len()));
pairs
}
fn select_blob_rows(
session: &DbSession<SessionSqlCanister>,
where_clause: &str,
) -> Vec<Vec<Value>> {
let sql = format!(
"SELECT label, bucket, thumbnail, chunk \
FROM SessionSqlBlobEntity {where_clause} \
ORDER BY label ASC"
);
statement_projection_rows::<SessionSqlBlobEntity>(session, sql.as_str())
.expect("large blob SQL SELECT should succeed")
}
#[test]
fn sql_insert_select_copies_multiple_large_blob_rows() {
reset_session_sql_store();
let session = sql_session();
let seeded = seed_blob_rows(&session);
let inserted = statement_projection_rows::<SessionSqlBlobEntity>(
&session,
"INSERT INTO SessionSqlBlobEntity (label, bucket, thumbnail, chunk) \
SELECT label, bucket, thumbnail, chunk \
FROM SessionSqlBlobEntity \
WHERE bucket = 7 \
ORDER BY label ASC \
RETURNING label, bucket, thumbnail, chunk",
)
.expect("large blob INSERT SELECT RETURNING should succeed");
assert_eq!(
blob_row_summaries(inserted.clone()),
vec![
(
"hero-thumb-a".to_string(),
7,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
),
(
"hero-thumb-b".to_string(),
7,
MEDIUM_THUMBNAIL_BYTES,
XL_CHUNK_BYTES,
),
],
"INSERT SELECT should return copied blob rows in source order",
);
let expected_payloads = seeded
.iter()
.take(2)
.map(|row| (row.thumbnail.to_vec(), row.chunk.to_vec()))
.collect::<Vec<_>>();
assert_eq!(
blob_payload_pairs(
&inserted
.into_iter()
.map(|mut row| row.split_off(2))
.collect::<Vec<_>>(),
),
expected_payloads,
"SQL INSERT SELECT RETURNING should expose exact copied thumbnail/chunk bytes",
);
assert_eq!(
blob_row_summaries(select_blob_rows(&session, "WHERE bucket = 7")),
vec![
(
"hero-thumb-a".to_string(),
7,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
),
(
"hero-thumb-a".to_string(),
7,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
),
(
"hero-thumb-b".to_string(),
7,
MEDIUM_THUMBNAIL_BYTES,
XL_CHUNK_BYTES,
),
(
"hero-thumb-b".to_string(),
7,
MEDIUM_THUMBNAIL_BYTES,
XL_CHUNK_BYTES,
),
],
"SQL SELECT should observe both original and inserted blob rows",
);
}
#[test]
fn sql_update_metadata_preserves_large_blob_payloads() {
reset_session_sql_store();
let session = sql_session();
let seeded = seed_blob_rows(&session);
let before_payloads = seeded
.iter()
.take(2)
.map(|row| (row.thumbnail.to_vec(), row.chunk.to_vec()))
.collect::<Vec<_>>();
let updated = statement_projection_rows::<SessionSqlBlobEntity>(
&session,
"UPDATE SessionSqlBlobEntity \
SET label = 'hot-updated', bucket = 70 \
WHERE bucket = 7 \
ORDER BY label ASC \
RETURNING label, bucket, thumbnail, chunk",
)
.expect("large blob metadata UPDATE RETURNING should succeed");
assert_eq!(
blob_row_summaries(updated),
vec![
(
"hot-updated".to_string(),
70,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
),
(
"hot-updated".to_string(),
70,
MEDIUM_THUMBNAIL_BYTES,
XL_CHUNK_BYTES,
),
],
"UPDATE RETURNING should expose updated metadata beside unchanged blobs",
);
assert_eq!(
blob_payload_pairs_sorted_by_shape(
&statement_projection_rows::<SessionSqlBlobEntity>(
&session,
"SELECT thumbnail, chunk \
FROM SessionSqlBlobEntity \
WHERE bucket = 70",
)
.expect("post-update blob SELECT should succeed"),
),
before_payloads,
"SQL UPDATE should preserve untouched large blob bytes",
);
}
#[test]
fn typed_replace_then_sql_select_and_delete_large_blobs() {
reset_session_sql_store();
let session = sql_session();
seed_blob_rows(&session);
let replacement = blob_row(
Ulid::from_u128(9_102),
"hero-thumb-b-replaced",
11,
61,
MEDIUM_THUMBNAIL_BYTES * 2,
71,
XL_CHUNK_BYTES + LARGE_CHUNK_BYTES,
);
session
.replace(replacement.clone())
.expect("typed large blob replace should succeed");
assert_eq!(
blob_row_summaries(select_blob_rows(
&session,
"WHERE label = 'hero-thumb-b-replaced'"
)),
vec![(
"hero-thumb-b-replaced".to_string(),
11,
MEDIUM_THUMBNAIL_BYTES * 2,
XL_CHUNK_BYTES + LARGE_CHUNK_BYTES,
)],
"SQL SELECT should observe the typed replacement blob sizes",
);
assert_eq!(
blob_payload_pairs(
&statement_projection_rows::<SessionSqlBlobEntity>(
&session,
"SELECT thumbnail, chunk \
FROM SessionSqlBlobEntity \
WHERE label = 'hero-thumb-b-replaced'",
)
.expect("replacement blob SELECT should succeed"),
),
vec![(replacement.thumbnail.to_vec(), replacement.chunk.to_vec())],
"SQL SELECT should observe exact replacement bytes",
);
let deleted = statement_projection_rows::<SessionSqlBlobEntity>(
&session,
"DELETE FROM SessionSqlBlobEntity \
WHERE bucket >= 7 \
AND label LIKE 'hero%' \
ORDER BY label ASC \
LIMIT 2 \
RETURNING label, bucket, thumbnail, chunk",
)
.expect("large blob DELETE RETURNING should succeed");
assert_eq!(
blob_row_summaries(deleted),
vec![
(
"hero-thumb-a".to_string(),
7,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
),
(
"hero-thumb-b-replaced".to_string(),
11,
MEDIUM_THUMBNAIL_BYTES * 2,
XL_CHUNK_BYTES + LARGE_CHUNK_BYTES,
),
],
"DELETE RETURNING should preserve ordered blob rows before removal",
);
assert_eq!(
blob_row_summaries(select_blob_rows(&session, "")),
vec![(
"archive-thumb".to_string(),
9,
SMALL_THUMBNAIL_BYTES,
LARGE_CHUNK_BYTES,
)],
"DELETE should leave only the non-windowed blob row",
);
}