Ask which collections a selection is filed in, and how much of it
`collections_for_image` answers this for one photograph and has no counts, which is enough to badge a cell and not enough to offer a removal: with forty selected and three of them in "Iceland", a sheet that says only "Iceland" invites the user to take all forty out of a collection thirty-seven were never in. `membership_of` returns the count alongside the name so the row can say "3 of 40". Chunked over the image list rather than one `IN (...)`, because the list is a selection and a select-all makes it as large as the library — past SQLite's bound-parameter cap on exactly the gesture most likely to produce it. Counts are summed across chunks, so the answer is the one the unchunked query would have given. Smart collections are excluded by construction: they have no member rows, so there is nothing a removal could do.
This commit is contained in:
@@ -535,6 +535,105 @@ pub fn collections_for_image(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// One collection a set of images is filed in, and how much of that set is in
|
||||
/// it.
|
||||
///
|
||||
/// `holding` is what makes a removal honest: with forty photographs selected
|
||||
/// and three of them in "Iceland", the row has to say "3 of 40" or the user
|
||||
/// reads it as "this selection is in Iceland" and takes all forty out of a
|
||||
/// collection thirty-seven of them were never in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Membership {
|
||||
pub id: CollectionId,
|
||||
pub name: String,
|
||||
/// How many of the images asked about are members. Never zero — a
|
||||
/// collection holding none of them is not returned at all.
|
||||
pub holding: usize,
|
||||
}
|
||||
|
||||
/// Every collection the given images are filed in, with how many of them each
|
||||
/// holds.
|
||||
///
|
||||
/// The read behind "which collections is this selection in, and take it out of
|
||||
/// one" — the counterpart to [`collections_for_image`], which answers the same
|
||||
/// question for a single photograph and does not need the counts.
|
||||
///
|
||||
/// Smart collections never appear: they have no `collection_members` rows, so
|
||||
/// there is nothing to remove and offering it would be a button that does
|
||||
/// nothing. Sorted by name, matching the sidebar.
|
||||
///
|
||||
/// # Why this is chunked
|
||||
///
|
||||
/// The image list is a *selection*, which a select-all makes as large as the
|
||||
/// library. SQLite caps the number of bound parameters in one statement, so a
|
||||
/// single `IN (...)` over every selected id fails outright on exactly the
|
||||
/// gesture most likely to produce it. The counts are summed across chunks
|
||||
/// rather than re-queried, so the result is the same as the unchunked query
|
||||
/// would have given.
|
||||
pub fn membership_of(
|
||||
conn: &Connection,
|
||||
images: &[ImageId],
|
||||
) -> Result<Vec<Membership>, CatalogError> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Well under SQLite's default parameter cap, and large enough that an
|
||||
// ordinary selection is one round trip.
|
||||
const CHUNK: usize = 400;
|
||||
|
||||
let mut totals: std::collections::HashMap<CollectionId, (String, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for chunk in images.chunks(CHUNK) {
|
||||
let placeholders = std::iter::repeat_n("?", chunk.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// The placeholder list is built from the id *count*, never from user
|
||||
// text — the same construction `deep_count` uses.
|
||||
let sql = format!(
|
||||
"SELECT c.id, c.name, count(*)
|
||||
FROM collection_members m
|
||||
JOIN collections c ON c.id = m.collection_id
|
||||
WHERE m.image_id IN ({placeholders}) AND c.deleted = 0
|
||||
GROUP BY c.id, c.name"
|
||||
);
|
||||
let params: Vec<rusqlite::types::Value> = chunk
|
||||
.iter()
|
||||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||||
.collect();
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((
|
||||
CollectionId(r.get::<_, i64>(0)? as u64),
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, name, n) = row?;
|
||||
let entry = totals.entry(id).or_insert((name, 0));
|
||||
entry.1 += n;
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<Membership> = totals
|
||||
.into_iter()
|
||||
.map(|(id, (name, holding))| Membership { id, name, holding })
|
||||
.collect();
|
||||
// By name, then by id, so two collections sharing a name have a stable
|
||||
// order rather than the hash map's.
|
||||
out.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then(a.id.0.cmp(&b.id.0))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What kind of collection `id` is, or `None` if there is no such collection.
|
||||
///
|
||||
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
|
||||
@@ -1438,4 +1537,85 @@ mod tests {
|
||||
let d = descendants(c, a).unwrap();
|
||||
assert!(d.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_says_how_many_of_the_selection_each_collection_holds() {
|
||||
// The count is the whole point: "3 of 40" is what stops a user taking
|
||||
// forty photographs out of a collection thirty-seven were never in.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let iceland = create(c, "Iceland", None, CollectionKind::Manual).unwrap();
|
||||
let best = create(c, "Best", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, iceland, &[img(1), img(2), img(3)]).unwrap();
|
||||
add_images(c, best, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1), img(2), img(3), img(4)]).unwrap();
|
||||
assert_eq!(m.len(), 2);
|
||||
// Sorted by name, so "Best" comes before "Iceland".
|
||||
assert_eq!(m[0].id, best);
|
||||
assert_eq!(m[0].holding, 1);
|
||||
assert_eq!(m[1].id, iceland);
|
||||
assert_eq!(m[1].holding, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_omits_collections_holding_none_of_them() {
|
||||
// A row offering to remove images that are not there would be a button
|
||||
// that does nothing, which is worse than an absent one.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
create(c, "Empty", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
|
||||
let m = membership_of(c, &[img(1)]).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].id, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_of_nothing_is_nothing() {
|
||||
let cat = seeded();
|
||||
assert!(membership_of(cat.connection(), &[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_skips_a_deleted_collection() {
|
||||
// The tombstone survives the delete, and its member rows are dropped —
|
||||
// but a merge can leave rows behind, and the sheet must not offer a
|
||||
// collection the sidebar does not draw.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
add_images(c, a, &[img(1)]).unwrap();
|
||||
delete(c, a).unwrap();
|
||||
|
||||
assert!(membership_of(c, &[img(1)]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_sums_across_chunks() {
|
||||
// The chunking exists for a select-all, which is exactly the gesture
|
||||
// that would otherwise exceed SQLite's parameter cap. A count that was
|
||||
// per-chunk rather than summed would under-report on the one selection
|
||||
// large enough to need it.
|
||||
let cat = seeded();
|
||||
let c = cat.connection();
|
||||
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
||||
// Past the fixture's six, so the member rows have images to point at.
|
||||
for i in 7..=900i64 {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, added_at)
|
||||
VALUES (?1, 1, ?2, 0)",
|
||||
rusqlite::params![i, format!("img{i}.CR3")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let ids: Vec<ImageId> = (1..=900).map(img).collect();
|
||||
add_images(c, a, &ids).unwrap();
|
||||
|
||||
let m = membership_of(c, &ids).unwrap();
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].holding, 900, "counted across every chunk");
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user