Files
DarkRoom/core/dr-catalog/src/rating.rs
T
dtourolle d5b1f6bff5 Add collections, ratings, and soft delete to the catalog
Three features over a shared schema migration.

Collections: a tree of manual collections plus smart collections whose
membership *is* their stored selector. Dropping images onto a smart
collection is refused rather than silently discarded, so the UI can say why
the drop did nothing — member rows there would be a second source of truth
that nothing reads.

Ratings: the star and pick/reject axes, kept independent.

Trash: soft delete to a folder, then permanent delete.

Catalog::open now backfills after migrating. A migration adds a column but
cannot know what the value should be for rows that already existed;
backfilling on open is what stops those rows being silently partial.

Timeline queries exclude shadowed JPEGs, which would otherwise double every
paired shot in the histogram, and gain a range-bounded variant so zooming in
returns finer buckets rather than the same coarse ones with the ends cropped.

Assisted-by: LLM
2026-08-09 20:42:13 +02:00

716 lines
26 KiB
Rust

//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4
//! Star ratings and pick/reject flags — the judgement a cull produces.
//!
//! # Why this hangs off `versions` rather than `images`
//!
//! The schema already carries `rating`, `label` and `flag` on `versions`, and
//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and
//! [`dr_types::Selector::Flag`] against the *default* version. What was
//! missing is that nothing ever created a version row: a scan inserts into
//! `images` and stops, so every image had no version, and therefore nowhere
//! to record a rating. The whole library sat permanently unrated with no way
//! out of that state.
//!
//! So this module's first job is [`ensure_default_versions`] — every image
//! gets exactly one default version, created at scan time and backfilled by
//! the v2 migration for libraries scanned before this existed.
//!
//! Keeping judgement on the version rather than the image is what makes
//! FR-CAT-12's virtual copies coherent: two crops of one frame are two
//! photographs to the photographer, and one may be a keeper while the other
//! is a reject. Hoisting the rating onto the image would force them to agree.
//!
//! # Unrated is a real state, not a zero
//!
//! `rating = 0` means *not yet judged*, and that is precisely what "filter to
//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one
//! star" or with "rejected" — those are three different answers, and a cull
//! that cannot distinguish "I have not looked at this" from "I looked and it
//! is poor" cannot be resumed.
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use crate::error::CatalogError;
/// Highest star rating. Five, as every photo tool has settled on.
pub const MAX_RATING: u8 = 5;
/// Name given to the version created for an image that has none.
///
/// Matches what [`crate::collections`] and the sidecar both expect to see for
/// the original, unmodified frame.
pub const DEFAULT_VERSION_NAME: &str = "Default";
/// The judgement recorded against one image's default version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Judgement {
/// 0..=5. Zero means *unrated*, which is a state in its own right.
pub rating: u8,
pub flag: FlagState,
}
impl Judgement {
/// Whether this image has been judged at all.
///
/// Either axis counts: a photographer who flags without starring, or stars
/// without flagging, has still made a decision about the frame. "Filter to
/// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong
/// means a resumed session re-presents work already done.
pub fn is_judged(self) -> bool {
self.rating > 0 || self.flag != FlagState::Unflagged
}
}
/// Give every image without one a default version.
///
/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is
/// answered by the `versions_image` index, so a library that already has its
/// versions costs one indexed scan and writes nothing.
///
/// Returns how many were created, so a scan can log the backfill rather than
/// silently doing thousands of inserts.
///
/// The UUID is per row and generated here — it is the merge identity across
/// devices (FR-NC-8), so two images must never share one.
pub fn ensure_default_versions(conn: &Connection) -> Result<usize, CatalogError> {
let ids: Vec<i64> = {
let mut stmt = conn.prepare(
"SELECT i.id FROM images i
WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)",
)?;
let found = stmt
.query_map([], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
found
};
if ids.is_empty() {
return Ok(0);
}
// One transaction for the batch. A backfill over a 24k-image library is
// 24k inserts, and per-statement commits would make it minutes rather
// than seconds.
let tx = conn.unchecked_transaction()?;
{
let mut insert = tx.prepare(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
)?;
for id in &ids {
insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?;
}
}
tx.commit()?;
Ok(ids.len())
}
/// The default version's row id for an image, creating one if it has none.
///
/// Every write path goes through this rather than assuming a version exists.
/// An image can arrive without one in two ways that are not worth trying to
/// prevent: a row inserted by a build predating this module, and a scan whose
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| r.get(0),
)
.optional()?;
if let Some(id) = existing {
return Ok(id);
}
conn.execute(
"INSERT INTO versions(image_id, uuid, name, is_default, rating, flag)
VALUES (?1, ?2, ?3, 1, 0, 0)",
rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME],
)?;
Ok(conn.last_insert_rowid())
}
/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`].
///
/// Clamped rather than rejected: the value comes from a keystroke or a click
/// on a star strip, and there is no useful error to show a photographer who
/// pressed a key. Out of range can only mean a UI bug, and losing the
/// keystroke would be a worse symptom than recording five.
pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET rating = ?2 WHERE id = ?1",
rusqlite::params![version, rating.min(MAX_RATING) as i64],
)?;
Ok(())
}
/// Set the pick/reject flag for one image.
pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> {
let version = default_version_id(conn, image)?;
conn.execute(
"UPDATE versions SET flag = ?2 WHERE id = ?1",
rusqlite::params![version, flag_code(flag)],
)?;
Ok(())
}
/// Apply a rating to many images in one transaction.
///
/// The bulk path exists because rating a selection is one gesture: the user
/// selects forty frames and presses `3`. Forty separate transactions would be
/// forty fsyncs for what is conceptually a single edit, and a crash partway
/// through would leave the selection half-rated.
pub fn set_rating_many(
conn: &Connection,
images: &[ImageId],
rating: u8,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_rating(conn, id, rating))
}
/// Apply a flag to many images in one transaction. See [`set_rating_many`].
pub fn set_flag_many(
conn: &Connection,
images: &[ImageId],
flag: FlagState,
) -> Result<usize, CatalogError> {
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
}
/// Shared bulk wrapper, so the two axes cannot drift in their commit
/// behaviour — a partially-committed rating and a fully-committed flag from
/// the same keystroke would be hard to explain and harder to notice.
fn apply_many(
conn: &Connection,
images: &[ImageId],
mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>,
) -> Result<usize, CatalogError> {
if images.is_empty() {
return Ok(0);
}
let tx = conn.unchecked_transaction()?;
for id in images {
one(&tx, *id)?;
}
tx.commit()?;
Ok(images.len())
}
/// Read the judgement for one image.
///
/// An image with no version reads as unrated and unflagged rather than as an
/// error: that is exactly what it is.
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
let row: Option<(i64, i64)> = conn
.query_row(
"SELECT rating, flag FROM versions
WHERE image_id = ?1
ORDER BY is_default DESC, id ASC
LIMIT 1",
[image.0 as i64],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
Ok(match row {
Some((rating, flag)) => Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
None => Judgement::default(),
})
}
/// Judgements for a window of images, in one statement.
///
/// The grid needs a star strip per cell, and one query per cell would be 120
/// round trips on every scroll — the same reasoning as
/// `collections_ui::sync_badges`. Images with no version simply do not appear
/// in the result, and the caller treats a miss as unrated.
pub fn judgements(
conn: &Connection,
images: &[ImageId],
) -> Result<std::collections::HashMap<ImageId, Judgement>, CatalogError> {
let mut out = std::collections::HashMap::new();
if images.is_empty() {
return Ok(out);
}
// Placeholders are generated from the *count* of ids, never from any text
// that came from outside — the same rule `read_cells_scoped` follows.
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT image_id, rating, flag FROM versions
WHERE image_id IN ({placeholders}) AND is_default = 1"
);
let params: Vec<rusqlite::types::Value> = images
.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((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
))
})?;
for (image, rating, flag) in rows.flatten() {
out.insert(
ImageId(image as u64),
Judgement {
rating: rating.clamp(0, MAX_RATING as i64) as u8,
flag: flag_from_code(flag),
},
);
}
Ok(out)
}
/// How the library divides by rating, for the filter bar's counts.
///
/// Index `n` is the number of images rated `n`, so index 0 is the unrated
/// count. Shown beside each filter button so the user can see there is
/// something behind it before narrowing to it — a filter that silently
/// empties the grid reads as a broken filter.
pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> {
let mut out = [0usize; 6];
// LEFT JOIN, so an image whose version row is missing still counts as
// unrated rather than vanishing from the totals. The histogram has to sum
// to the library size or it is not believable.
let mut stmt = conn.prepare(
"SELECT coalesce(v.rating, 0) AS r, count(*)
FROM images i
LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1
GROUP BY r",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
for (rating, count) in rows.flatten() {
if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) {
*slot += count as usize;
}
}
Ok(out)
}
/// How many images carry each flag: `(picks, rejects)`.
pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> {
let picks: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1",
[],
|r| r.get(0),
)?;
let rejects: i64 = conn.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2",
[],
|r| r.get(0),
)?;
Ok((picks as usize, rejects as usize))
}
/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s
/// mapping — the two must agree or a filter will not find what a write stored.
fn flag_code(f: FlagState) -> i64 {
match f {
FlagState::Unflagged => 0,
FlagState::Pick => 1,
FlagState::Reject => 2,
}
}
fn flag_from_code(v: i64) -> FlagState {
match v {
1 => FlagState::Pick,
2 => FlagState::Reject,
_ => FlagState::Unflagged,
}
}
/// A version UUID.
///
/// Hand-rolled rather than pulling in the `uuid` crate for one function — the
/// same reasoning as the date maths in `library_ui`. This needs to be unique
/// across devices, not cryptographically unguessable: it keys a merge, and an
/// attacker who can write to the sidecar has already won.
///
/// Seeded from the system clock and a per-process counter, so two versions
/// created inside the same nanosecond tick still differ.
fn new_uuid() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
// Mixed so successive ids do not share a long common prefix, which makes
// them easier to tell apart when reading a sidecar by eye.
let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let b = nanos
.rotate_left(32)
.wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9));
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(a >> 32) as u32,
(a >> 16) as u16,
(a & 0x0FFF) as u16,
// Variant bits, so this is a well-formed v4-shaped UUID rather than
// something that merely looks like one.
((b >> 48) as u16 & 0x3FFF) | 0x8000,
b & 0xFFFF_FFFF_FFFF,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
/// A catalog holding `n` images and nothing else — the state a scan
/// leaves behind before this module runs.
fn with_images(n: usize) -> Catalog {
let cat = Catalog::in_memory().unwrap();
let c = cat.connection();
c.execute(
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for i in 0..n {
c.execute(
"INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)",
[format!("img{i:03}.CR3")],
)
.unwrap();
}
cat
}
fn ids(cat: &Catalog) -> Vec<ImageId> {
let mut stmt = cat
.connection()
.prepare("SELECT id FROM images ORDER BY id")
.unwrap();
stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn every_scanned_image_gets_a_default_version() {
// The gap this module exists to close: a scan inserted images and no
// versions, so there was nowhere for a rating to go.
let cat = with_images(5);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5);
let n: i64 = cat
.connection()
.query_row(
"SELECT count(*) FROM versions WHERE is_default = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 5);
}
#[test]
fn images_enter_unrated_rather_than_at_one_star() {
// "Not yet judged" is the state a cull starts from and resumes to.
let cat = with_images(3);
ensure_default_versions(cat.connection()).unwrap();
for id in ids(&cat) {
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert_eq!(j.flag, FlagState::Unflagged);
assert!(!j.is_judged());
}
}
#[test]
fn backfilling_twice_creates_nothing_the_second_time() {
// Runs on every scan, so a second pass must not double every version.
let cat = with_images(4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4);
assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0);
let n: i64 = cat
.connection()
.query_row("SELECT count(*) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 4, "one version per image, not two");
}
#[test]
fn version_uuids_are_unique_across_a_batch() {
// The uuid is the cross-device merge identity: two images sharing one
// silently fuse their edits at the next sync.
let cat = with_images(200);
ensure_default_versions(cat.connection()).unwrap();
let distinct: i64 = cat
.connection()
.query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| r.get(0))
.unwrap();
assert_eq!(distinct, 200);
}
#[test]
fn a_rating_round_trips() {
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4);
}
#[test]
fn rating_an_image_with_no_version_creates_one() {
// A library scanned by a build predating this module, or a scan that
// died between the image insert and the version pass. The keystroke
// must still land.
let cat = with_images(1);
let id = ids(&cat)[0];
// Deliberately *not* calling ensure_default_versions first.
set_rating(cat.connection(), id, 3).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3);
}
#[test]
fn an_out_of_range_rating_is_clamped_rather_than_stored() {
// A stored 9 would sort above five stars forever and no filter would
// reach it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 99).unwrap();
assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING);
}
#[test]
fn rating_back_to_zero_returns_an_image_to_unrated() {
// Pressing 0 is how a mistake is undone, so it has to be reachable —
// not a floor at one star.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 5).unwrap();
set_rating(cat.connection(), id, 0).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 0);
assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it");
}
#[test]
fn flags_and_stars_are_independent_axes() {
// Rejecting a four-star frame is a normal thing to do while culling,
// and one axis must not clear the other.
let cat = with_images(1);
let id = ids(&cat)[0];
set_rating(cat.connection(), id, 4).unwrap();
set_flag(cat.connection(), id, FlagState::Reject).unwrap();
let j = judgement(cat.connection(), id).unwrap();
assert_eq!(j.rating, 4);
assert_eq!(j.flag, FlagState::Reject);
}
#[test]
fn a_flag_alone_counts_as_judged() {
// Filter-to-unjudged must not re-present a frame the user already
// picked, merely because they did not also star it.
let cat = with_images(1);
let id = ids(&cat)[0];
set_flag(cat.connection(), id, FlagState::Pick).unwrap();
assert!(judgement(cat.connection(), id).unwrap().is_judged());
}
#[test]
fn a_bulk_rating_applies_to_the_whole_selection() {
// One gesture: select forty, press 3.
let cat = with_images(10);
let all = ids(&cat);
let chosen = &all[2..7];
assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5);
for id in chosen {
assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3);
}
// And nothing outside the selection moved.
assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0);
assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0);
}
#[test]
fn a_bulk_write_over_an_empty_selection_is_a_no_op() {
let cat = with_images(3);
assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0);
assert_eq!(set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(), 0);
}
#[test]
fn judgements_reads_a_whole_window_in_one_query() {
// The grid draws a star strip per cell; one query per cell would be
// 120 round trips on every scroll.
let cat = with_images(6);
let all = ids(&cat);
set_rating(cat.connection(), all[1], 2).unwrap();
set_flag(cat.connection(), all[3], FlagState::Pick).unwrap();
let map = judgements(cat.connection(), &all).unwrap();
assert_eq!(map.get(&all[1]).unwrap().rating, 2);
assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick);
// Never rated, so it is either absent or explicitly unrated — both
// mean the same thing to the caller.
assert_eq!(
map.get(&all[5]).copied().unwrap_or_default(),
Judgement::default()
);
}
#[test]
fn the_histogram_sums_to_the_library_size() {
// A histogram that disagrees with the image count is not believable,
// and the unrated bucket is the one a fresh library lives in.
let cat = with_images(8);
ensure_default_versions(cat.connection()).unwrap();
let all = ids(&cat);
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 5).unwrap();
set_rating(cat.connection(), all[2], 3).unwrap();
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[5], 2);
assert_eq!(h[3], 1);
assert_eq!(h[0], 5, "the rest are still unrated");
assert_eq!(h.iter().sum::<usize>(), 8);
}
#[test]
fn the_histogram_counts_images_with_no_version_as_unrated() {
// They are unrated. Dropping them would make the counts disagree with
// the grid, which is the failure the LEFT JOIN exists to prevent.
let cat = with_images(4);
// No ensure_default_versions call at all.
let h = rating_histogram(cat.connection()).unwrap();
assert_eq!(h[0], 4);
assert_eq!(h.iter().sum::<usize>(), 4);
}
#[test]
fn flag_counts_separate_picks_from_rejects() {
let cat = with_images(5);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[2], FlagState::Reject).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1));
}
#[test]
fn unflagging_removes_an_image_from_both_counts() {
let cat = with_images(2);
let all = ids(&cat);
set_flag(cat.connection(), all[0], FlagState::Reject).unwrap();
set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap();
assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0));
}
#[test]
fn a_rating_survives_the_selector_that_queries_it() {
// The end-to-end property: what this module writes is what
// `dr_catalog::query` compiles `Selector::Rating` to find. These are
// two independent pieces of SQL and they must agree on where a rating
// lives, or rating an image would appear to do nothing.
use crate::Query;
use dr_types::Selector;
let cat = with_images(6);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 5).unwrap();
set_rating(cat.connection(), all[1], 4).unwrap();
set_rating(cat.connection(), all[2], 1).unwrap();
let q = Query {
filter: Selector::Rating { min: 4 },
..Default::default()
};
assert_eq!(cat.count(&q, 0).unwrap(), 2);
}
#[test]
fn a_flag_survives_the_selector_that_queries_it() {
// Same contract for the other axis: `flag_code` here and in `query`
// are separate mappings and must not drift.
use crate::Query;
use dr_types::Selector;
let cat = with_images(4);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_flag(cat.connection(), all[0], FlagState::Pick).unwrap();
set_flag(cat.connection(), all[1], FlagState::Reject).unwrap();
let picks = Query {
filter: Selector::Flag(FlagState::Pick),
..Default::default()
};
assert_eq!(cat.count(&picks, 0).unwrap(), 1);
let rejects = Query {
filter: Selector::Flag(FlagState::Reject),
..Default::default()
};
assert_eq!(cat.count(&rejects, 0).unwrap(), 1);
}
#[test]
fn unjudged_is_reachable_as_a_filter() {
// FR-CULL-4's "filter to unjudged", which is what lets a session
// resume where it stopped.
use crate::Query;
use dr_types::Selector;
let cat = with_images(5);
let all = ids(&cat);
ensure_default_versions(cat.connection()).unwrap();
set_rating(cat.connection(), all[0], 2).unwrap();
let q = Query {
filter: Selector::Rating { min: 0 },
..Default::default()
};
// `min: 0` matches everything, so unjudged needs the negation.
assert_eq!(cat.count(&q, 0).unwrap(), 5);
let unrated = Query {
filter: Selector::Not(Box::new(Selector::Rating { min: 1 })),
..Default::default()
};
assert_eq!(cat.count(&unrated, 0).unwrap(), 4);
}
}