//! Photo versions (#191): hash-keyed links from an AI restoration / upscale //! / edit to its primary original, the global display choice, and the SQL //! the listings use to hide a version while its original is reachable. //! //! The link is the blake3 `content_hash` of the original (CLAUDE.md → //! "Multi-library data model"). A version keeps its own `image_exif` row, //! so `/image?path=` and thumbnails keep working; only listings //! hide it. `photo_versions` is tiny next to `image_exif`, so reads that //! need "which files are versions" load that set instead of joining per row. use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use diesel::prelude::*; use diesel::sql_query; use diesel::sql_types::{BigInt, Integer, Nullable, Text}; use diesel::sqlite::SqliteConnection; use crate::database::{DbError, DbErrorKind, connect}; use crate::otel::trace_db_call; /// The `kind` values a version may carry. pub const KINDS: &[&str] = &["restored", "upscale", "edit"]; /// `date_taken_source` on a version row whose date, GPS and camera fields /// were copied from its original. pub const DATE_SOURCE_VERSION_OF: &str = "version_of"; /// `photo_versions.source`: how the link was learned. pub const SOURCE_XMP: &str = "xmp"; pub const SOURCE_NAME: &str = "name"; pub const SOURCE_BACKFILL: &str = "backfill"; pub fn is_known_kind(kind: &str) -> bool { KINDS.contains(&kind) } /// SQL predicate over an unaliased `image_exif` row: true unless the row is /// a version that is hidden in the view (`view_library_ids` empty = every /// library). /// /// Same reachability rule as `duplicate_of_hash` in /// `list_rel_paths_for_libraries`: a version hides only while a row for its /// original exists in the view, so a version whose original was deleted, or /// lives in a library outside the view, stays visible as a normal photo. /// (Unlike duplicates the unscoped view still needs the existence check: /// the original may be gone.) Library ids are `i32`, so inlining the list /// is safe. pub fn visible_version_clause(view_library_ids: &[i32]) -> String { format!( "NOT EXISTS (SELECT 1 FROM photo_versions pv \ JOIN image_exif orig ON orig.content_hash = pv.original_hash \ WHERE pv.version_hash = image_exif.content_hash AND {})", reachable_original_condition(view_library_ids) ) } /// Condition over an `image_exif` row aliased `orig`: the row is an original /// that is itself shown in the view. It must be in the view, and not demoted /// by duplicates resolve, using the duplicate-listing rule: unscoped, any /// `duplicate_of_hash` hides it; scoped, only when the survivor is in the /// view too. Otherwise its versions would vanish along with it. fn reachable_original_condition(view_library_ids: &[i32]) -> String { if view_library_ids.is_empty() { return "orig.duplicate_of_hash IS NULL".to_string(); } let list = view_library_ids .iter() .map(i32::to_string) .collect::>() .join(","); format!( "orig.library_id IN ({list}) AND (orig.duplicate_of_hash IS NULL OR NOT EXISTS \ (SELECT 1 FROM image_exif AS survivor \ WHERE survivor.content_hash = orig.duplicate_of_hash \ AND survivor.library_id IN ({list})))" ) } /// SQL predicate over an unaliased `image_exif` row: true unless the row's /// hash is a version at all. For derived work (faces, CLIP, duplicates) /// that never runs on a version, whatever the view. pub const NOT_A_VERSION_CLAUSE: &str = "NOT EXISTS (SELECT 1 FROM photo_versions pv \ WHERE pv.version_hash = image_exif.content_hash)"; /// Same as [`NOT_A_VERSION_CLAUSE`] for a raw-SQL query that aliases /// `image_exif` as `alias`. pub fn not_a_version_clause_for(alias: &str) -> String { format!( "NOT EXISTS (SELECT 1 FROM photo_versions pv WHERE pv.version_hash = {alias}.content_hash)" ) } #[derive(Debug, Clone, PartialEq, Eq, QueryableByName, serde::Serialize)] pub struct PhotoVersionRow { #[diesel(sql_type = Text)] pub version_hash: String, #[diesel(sql_type = Text)] pub original_hash: String, #[diesel(sql_type = Text)] pub kind: String, #[diesel(sql_type = Text)] pub source: String, #[diesel(sql_type = BigInt)] pub created_at: i64, #[diesel(sql_type = Integer)] pub library_id: i32, #[diesel(sql_type = Text)] pub rel_path: String, } /// A link to record. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewVersionLink { pub version_hash: String, pub original_hash: String, pub kind: String, pub source: String, /// Back-ref to a live `image_exif` row of the version. pub library_id: i32, pub rel_path: String, /// Hashes of reference photos (provenance only). pub references: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum LinkOutcome { /// A new link was recorded. Linked, /// The version already had a link; it was left alone. AlreadyLinked, /// The link makes no sense (self-link, unknown kind, cycle). Rejected(&'static str), } /// A live version file of an original, for `GET /image/versions`. #[derive(Debug, Clone, QueryableByName)] pub struct VersionFile { #[diesel(sql_type = Text)] pub version_hash: String, #[diesel(sql_type = Text)] pub kind: String, #[diesel(sql_type = Text)] pub source: String, #[diesel(sql_type = BigInt)] pub created_at: i64, #[diesel(sql_type = Integer)] pub library_id: i32, #[diesel(sql_type = Text)] pub rel_path: String, #[diesel(sql_type = Nullable)] pub width: Option, #[diesel(sql_type = Nullable)] pub height: Option, } /// One file (some `image_exif` row) for a hash. #[derive(Debug, Clone, PartialEq, Eq, QueryableByName)] pub struct HashedFile { #[diesel(sql_type = Text)] pub content_hash: String, #[diesel(sql_type = Integer)] pub library_id: i32, #[diesel(sql_type = Text)] pub rel_path: String, } /// What `/photos` adds next to an original: how many live versions it has /// and, if the global choice isn't the original, the file to display. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PhotoVersionSummary { pub version_count: u32, /// `(library_id, rel_path)` of the chosen version file; the library is /// always the original's own. pub display: Option<(i32, String)>, } pub trait VersionDao: Sync + Send { /// Record a link (and its reference photos). Flattens chains: an /// `original_hash` that is itself a version is replaced by its root, and /// versions already pointing at the newly linked file move up to the root. fn link_version(&mut self, link: &NewVersionLink) -> Result; /// The link row for a version hash, if any. fn get_link(&mut self, version_hash: &str) -> Result, DbError>; /// If `rel_path` is a linked version whose original is still indexed, /// the original's rel_path (same library when possible). fn original_path_of(&mut self, rel_path: &str) -> Result, DbError>; /// `(library_id, rel_path)` of the original of the version at /// `(library_id, rel_path)`, when it is a linked version with a live /// original. For per-library writes that must land on the original. fn original_location_of( &mut self, library_id: i32, rel_path: &str, ) -> Result, DbError>; /// Which of `hashes` are versions of something. fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError>; /// Live versions of an original (those with an `image_exif` row), /// oldest first. fn list_versions(&mut self, original_hash: &str) -> Result, DbError>; /// Reference photo hashes recorded for a version. fn list_references(&mut self, version_hash: &str) -> Result, DbError>; /// First live `image_exif` row for each hash, preferring `prefer_library`. fn files_for_hashes( &mut self, hashes: &[String], prefer_library: Option, ) -> Result, DbError>; /// The stored display choice for an original, if any. fn get_display(&mut self, original_hash: &str) -> Result, DbError>; /// Set (`Some`) or clear (`None`) the global display choice. fn set_display( &mut self, original_hash: &str, display_hash: Option<&str>, ) -> Result<(), DbError>; /// Per original `(library_id, rel_path)` that has at least one live /// version: the version count and display file. Empty when no photo has /// versions, so `/photos` stays byte-identical. fn photo_summaries(&mut self) -> Result, DbError>; /// `(library_id, rel_path)` of every version row hidden in the view /// (`view_library_ids` empty = all libraries). For listings that don't /// go through SQL (filesystem walk, tag search, media gauges). fn hidden_version_rows( &mut self, view_library_ids: &[i32], ) -> Result, DbError>; /// Of `dirs` (rel paths), those that hold indexed files and where every /// indexed file anywhere underneath is a version hidden in the view. /// Such a folder would open empty. A folder with no indexed rows at all /// is not reported (nothing says it is safe to hide). One indexed range /// scan per dir; no filesystem walk. fn dirs_holding_only_hidden_versions( &mut self, view_library_ids: &[i32], dirs: &[String], ) -> Result, DbError>; /// Rows in a library whose file name follows the version convention /// (`.[.].`), have a hash and aren't linked yet. /// Returns `(id, rel_path, content_hash)` ordered by row id, starting /// after `after_id` (the caller's resume cursor, so rows that never /// resolve can't starve the ones behind them). fn unlinked_name_candidates( &mut self, library_id: i32, after_id: i32, limit: i64, ) -> Result, DbError>; /// Copy the original's date, GPS and camera fields onto every version /// row whose copy is stale. Returns the number of rows updated. /// Idempotent; only converted originals (`date_utc` set) are copied, so /// the legacy-convention date drains never fight the copy. fn sync_version_metadata(&mut self) -> Result; } pub struct SqliteVersionDao { connection: Arc>, } impl Default for SqliteVersionDao { fn default() -> Self { Self::new() } } impl SqliteVersionDao { pub fn new() -> Self { SqliteVersionDao { connection: Arc::new(Mutex::new(connect())), } } /// Test-only constructor sharing an already-wrapped connection, so /// other DAOs see the rows this one writes. #[cfg(test)] pub fn from_shared(connection: Arc>) -> Self { SqliteVersionDao { connection } } } /// Chunk size for `IN (...)` lists, safely under SQLite's variable limit. const CHUNK: usize = 500; fn placeholders(n: usize) -> String { vec!["?"; n].join(",") } /// If `rel_path` is a linked version whose original has a live row, the /// original's rel_path (same library when possible). Tag and favorite /// writes on a version path bind to the original through this. /// /// `library_id` scopes the version lookup to one library's row; `None` is /// for the library-agnostic tag/favorite DAOs, which only know a rel_path. /// Annotations are keyed by rel_path alone, so in either case a path that /// *also* names a file in some library that is not a linked version stays /// itself: redirecting it would hijack that photo's own tags and favorites. pub fn canonical_rel_path( conn: &mut SqliteConnection, library_id: Option, rel_path: &str, ) -> QueryResult> { Ok(canonical_location(conn, library_id, rel_path)?.map(|(_, p)| p)) } /// [`canonical_rel_path`] with the original's library too, for callers that /// write per-library rows (the manual date lives on the `image_exif` row). pub fn canonical_location( conn: &mut SqliteConnection, library_id: Option, rel_path: &str, ) -> QueryResult> { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Integer)] library_id: i32, #[diesel(sql_type = Text)] rel_path: String, } let rows = sql_query( "SELECT o.library_id AS library_id, o.rel_path AS rel_path FROM image_exif v \ JOIN photo_versions pv ON pv.version_hash = v.content_hash \ JOIN image_exif o ON o.content_hash = pv.original_hash \ WHERE v.rel_path = ?1 AND (?2 IS NULL OR v.library_id = ?2) \ AND NOT EXISTS (SELECT 1 FROM image_exif x WHERE x.rel_path = v.rel_path \ AND NOT EXISTS (SELECT 1 FROM photo_versions p \ WHERE p.version_hash = x.content_hash)) \ ORDER BY (o.library_id = v.library_id) DESC, o.id \ LIMIT 1", ) .bind::(rel_path) .bind::, _>(library_id) .load::(conn)?; Ok(rows.into_iter().next().map(|r| (r.library_id, r.rel_path))) } /// Fold favorites and tags recorded on a version's own path into its /// original's, now that reads and writes on the version path canonicalise /// to the original and could never reach them again. Set-valued data is /// unioned (duplicates dropped), then the version path's rows are removed. /// A no-op unless the version has a live original (`canonical_rel_path`). fn merge_annotations_into_original( conn: &mut SqliteConnection, library_id: i32, version_rel_path: &str, ) -> QueryResult<()> { let Some(original) = canonical_rel_path(conn, Some(library_id), version_rel_path)? else { return Ok(()); }; if original == version_rel_path { return Ok(()); } sql_query( "INSERT INTO favorites (userid, rel_path) \ SELECT f.userid, ?2 FROM favorites f WHERE f.rel_path = ?1 \ AND NOT EXISTS (SELECT 1 FROM favorites g WHERE g.userid = f.userid AND g.rel_path = ?2)", ) .bind::(version_rel_path) .bind::(&original) .execute(conn)?; sql_query("DELETE FROM favorites WHERE rel_path = ?") .bind::(version_rel_path) .execute(conn)?; sql_query( "INSERT INTO tagged_photo (rel_path, tag_id, created_time, content_hash) \ SELECT ?2, t.tag_id, t.created_time, \ (SELECT content_hash FROM image_exif WHERE rel_path = ?2 \ AND content_hash IS NOT NULL LIMIT 1) \ FROM tagged_photo t WHERE t.rel_path = ?1 \ AND NOT EXISTS (SELECT 1 FROM tagged_photo u WHERE u.tag_id = t.tag_id AND u.rel_path = ?2)", ) .bind::(version_rel_path) .bind::(&original) .execute(conn)?; sql_query("DELETE FROM tagged_photo WHERE rel_path = ?") .bind::(version_rel_path) .execute(conn)?; Ok(()) } /// Root original of `hash`: follows `photo_versions` until a hash that is /// not itself a version. `None` on a cycle. fn root_original(conn: &mut SqliteConnection, hash: &str) -> QueryResult> { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Text)] original_hash: String, } let mut current = hash.to_string(); for _ in 0..8 { let next = sql_query("SELECT original_hash FROM photo_versions WHERE version_hash = ?") .bind::(¤t) .load::(conn)? .into_iter() .next(); match next { Some(r) => current = r.original_hash, None => return Ok(Some(current)), } } Ok(None) } /// Columns the version copy takes from its original, in `UPDATE ... SET (..)` /// order, followed by the source marker. const COPIED_COLUMNS: &str = "date_taken, date_utc, camera_offset_minutes, date_taken_tz_source, \ gps_latitude, gps_longitude, gps_altitude, camera_make, camera_model, lens_model, \ focal_length, aperture, shutter_speed, iso"; fn copied_differs() -> String { COPIED_COLUMNS .split(',') .map(|c| c.trim()) .map(|c| format!("v.{c} IS NOT o.{c}")) .collect::>() .join(" OR ") } impl VersionDao for SqliteVersionDao { fn link_version(&mut self, link: &NewVersionLink) -> Result { trace_db_call("insert", "link_version", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); if !is_known_kind(&link.kind) { return Ok(LinkOutcome::Rejected("unknown kind")); } if link.version_hash == link.original_hash { return Ok(LinkOutcome::Rejected("a file cannot be a version of itself")); } conn.immediate_transaction::(|conn| { let existing = sql_query("SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path FROM photo_versions WHERE version_hash = ?") .bind::(&link.version_hash) .load::(conn)?; if !existing.is_empty() { return Ok(LinkOutcome::AlreadyLinked); } // Flatten: link to the root, never to another version. let Some(root) = root_original(conn, &link.original_hash)? else { return Ok(LinkOutcome::Rejected("link would form a cycle")); }; if root == link.version_hash { return Ok(LinkOutcome::Rejected("link would form a cycle")); } sql_query( "INSERT INTO photo_versions \ (version_hash, original_hash, kind, source, created_at, library_id, rel_path) \ VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind::(&link.version_hash) .bind::(&root) .bind::(&link.kind) .bind::(&link.source) .bind::(chrono::Utc::now().timestamp()) .bind::(link.library_id) .bind::(&link.rel_path) .execute(conn)?; // A version is never a face / CLIP candidate; if it was // ingested before the link, drop what was already derived. sql_query("DELETE FROM face_detections WHERE content_hash = ?") .bind::(&link.version_hash) .execute(conn)?; sql_query( "UPDATE image_exif SET clip_embedding = NULL, clip_model_version = NULL \ WHERE content_hash = ?", ) .bind::(&link.version_hash) .execute(conn)?; sql_query("DELETE FROM clip_failures WHERE content_hash = ?") .bind::(&link.version_hash) .execute(conn)?; // Versions that pointed at this file now point at the root. sql_query("UPDATE photo_versions SET original_hash = ? WHERE original_hash = ?") .bind::(&root) .bind::(&link.version_hash) .execute(conn)?; // A display choice made for the file that just became a // version is meaningless. sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") .bind::(&link.version_hash) .execute(conn)?; for reference in &link.references { // The primary original is not a reference to itself. if *reference == root || *reference == link.version_hash { continue; } sql_query( "INSERT OR IGNORE INTO photo_version_references \ (version_hash, reference_hash) VALUES (?, ?)", ) .bind::(&link.version_hash) .bind::(reference) .execute(conn)?; } merge_annotations_into_original(conn, link.library_id, &link.rel_path)?; Ok(LinkOutcome::Linked) }) }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } fn get_link(&mut self, version_hash: &str) -> Result, DbError> { trace_db_call("query", "get_version_link", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query( "SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path \ FROM photo_versions WHERE version_hash = ?", ) .bind::(version_hash) .load::(&mut *conn) .map(|rows| rows.into_iter().next()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn original_path_of(&mut self, rel_path: &str) -> Result, DbError> { trace_db_call("query", "original_path_of", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); canonical_rel_path(&mut conn, None, rel_path) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn original_location_of( &mut self, library_id: i32, rel_path: &str, ) -> Result, DbError> { trace_db_call("query", "original_location_of", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); canonical_location(&mut conn, Some(library_id), rel_path) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError> { trace_db_call("query", "version_hashes_among", |_span| { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Text)] version_hash: String, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); let mut out = HashSet::new(); for chunk in hashes.chunks(CHUNK) { let sql = format!( "SELECT version_hash FROM photo_versions WHERE version_hash IN ({})", placeholders(chunk.len()) ); let mut q = sql_query(sql).into_boxed(); for h in chunk { q = q.bind::(h); } let rows = q .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; out.extend(rows.into_iter().map(|r| r.version_hash)); } Ok(out) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn list_versions(&mut self, original_hash: &str) -> Result, DbError> { trace_db_call("query", "list_versions", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query( "SELECT pv.version_hash AS version_hash, pv.kind AS kind, pv.source AS source, \ pv.created_at AS created_at, e.library_id AS library_id, \ e.rel_path AS rel_path, e.width AS width, e.height AS height \ FROM photo_versions pv \ JOIN image_exif e ON e.id = \ (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.version_hash) \ WHERE pv.original_hash = ? \ ORDER BY pv.created_at, e.rel_path", ) .bind::(original_hash) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn list_references(&mut self, version_hash: &str) -> Result, DbError> { trace_db_call("query", "list_version_references", |_span| { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Text)] reference_hash: String, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query( "SELECT reference_hash FROM photo_version_references \ WHERE version_hash = ? ORDER BY reference_hash", ) .bind::(version_hash) .load::(&mut *conn) .map(|rows| rows.into_iter().map(|r| r.reference_hash).collect()) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn files_for_hashes( &mut self, hashes: &[String], prefer_library: Option, ) -> Result, DbError> { trace_db_call("query", "version_files_for_hashes", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); let mut out: HashMap = HashMap::new(); for chunk in hashes.chunks(CHUNK) { let sql = format!( "SELECT content_hash, library_id, rel_path FROM image_exif \ WHERE content_hash IN ({}) ORDER BY id", placeholders(chunk.len()) ); let mut q = sql_query(sql).into_boxed(); for h in chunk { q = q.bind::(h); } let rows = q .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; for r in rows { let better = match out.get(&r.content_hash) { None => true, Some((lib, _)) => { prefer_library == Some(r.library_id) && prefer_library != Some(*lib) } }; if better { out.insert(r.content_hash, (r.library_id, r.rel_path)); } } } Ok(out) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn get_display(&mut self, original_hash: &str) -> Result, DbError> { trace_db_call("query", "get_version_display", |_span| { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Text)] display_hash: String, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query("SELECT display_hash FROM photo_version_display WHERE original_hash = ?") .bind::(original_hash) .load::(&mut *conn) .map(|rows| rows.into_iter().next().map(|r| r.display_hash)) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn set_display( &mut self, original_hash: &str, display_hash: Option<&str>, ) -> Result<(), DbError> { trace_db_call("update", "set_version_display", |_span| { let mut conn = self.connection.lock().expect("Unable to get VersionDao"); match display_hash { Some(display) => sql_query( "INSERT INTO photo_version_display (original_hash, display_hash, decided_at) \ VALUES (?, ?, ?) \ ON CONFLICT(original_hash) DO UPDATE SET \ display_hash = excluded.display_hash, decided_at = excluded.decided_at", ) .bind::(original_hash) .bind::(display) .bind::(chrono::Utc::now().timestamp()) .execute(&mut *conn), None => sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") .bind::(original_hash) .execute(&mut *conn), } .map(|_| ()) .map_err(|e| anyhow::anyhow!("Update error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) } fn photo_summaries(&mut self) -> Result, DbError> { trace_db_call("query", "photo_version_summaries", |_span| { #[derive(QueryableByName)] struct OriginalRow { #[diesel(sql_type = Text)] original_hash: String, #[diesel(sql_type = Integer)] library_id: i32, #[diesel(sql_type = Text)] rel_path: String, #[diesel(sql_type = BigInt)] version_count: i64, } #[derive(QueryableByName)] struct DisplayRow { #[diesel(sql_type = Text)] original_hash: String, #[diesel(sql_type = Integer)] library_id: i32, #[diesel(sql_type = Text)] rel_path: String, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); let originals = sql_query( "SELECT o.content_hash AS original_hash, o.library_id AS library_id, \ o.rel_path AS rel_path, \ (SELECT COUNT(*) FROM photo_versions pv \ WHERE pv.original_hash = o.content_hash \ AND EXISTS (SELECT 1 FROM image_exif v \ WHERE v.content_hash = pv.version_hash)) AS version_count \ FROM image_exif o \ WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)", ) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; if originals.is_empty() { return Ok(HashMap::new()); } // Only a display choice that is still a version of that original // counts; anything else falls back to the original. let displays = sql_query( "SELECT d.original_hash AS original_hash, v.library_id AS library_id, \ v.rel_path AS rel_path \ FROM photo_version_display d \ JOIN photo_versions pv ON pv.version_hash = d.display_hash \ AND pv.original_hash = d.original_hash \ JOIN image_exif v ON v.content_hash = d.display_hash \ ORDER BY v.id", ) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; let mut display_by_hash: HashMap> = HashMap::new(); for d in displays { display_by_hash .entry(d.original_hash) .or_default() .push((d.library_id, d.rel_path)); } let mut out = HashMap::new(); for o in originals { if o.version_count == 0 { continue; } // Clients fetch the display file through the original's // library, so a chosen version with no row there falls back // to the original. let display = display_by_hash .get(&o.original_hash) .and_then(|files| files.iter().find(|(lib, _)| *lib == o.library_id).cloned()); out.insert( (o.library_id, o.rel_path), PhotoVersionSummary { version_count: o.version_count as u32, display, }, ); } Ok(out) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn hidden_version_rows( &mut self, view_library_ids: &[i32], ) -> Result, DbError> { trace_db_call("query", "hidden_version_rows", |_span| { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Integer)] library_id: i32, #[diesel(sql_type = Text)] rel_path: String, } let sql = format!( "SELECT image_exif.library_id AS library_id, image_exif.rel_path AS rel_path \ FROM image_exif \ JOIN photo_versions pv ON pv.version_hash = image_exif.content_hash \ WHERE EXISTS (SELECT 1 FROM image_exif orig \ WHERE orig.content_hash = pv.original_hash AND {})", reachable_original_condition(view_library_ids) ); let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query(sql) .load::(&mut *conn) .map(|rows| { rows.into_iter() .map(|r| (r.library_id, r.rel_path)) .collect() }) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn dirs_holding_only_hidden_versions( &mut self, view_library_ids: &[i32], dirs: &[String], ) -> Result, DbError> { if dirs.is_empty() { return Ok(HashSet::new()); } trace_db_call("query", "dirs_holding_only_hidden_versions", |_span| { #[derive(QueryableByName)] struct Counts { #[diesel(sql_type = BigInt)] total: i64, #[diesel(sql_type = BigInt)] visible: i64, } #[derive(QueryableByName)] struct Id { #[diesel(sql_type = Integer)] id: i32, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); // Scan the view's libraries (all of them when unscoped) so the // (library_id, rel_path) index serves the prefix range. let scan: Vec = if view_library_ids.is_empty() { sql_query("SELECT id FROM libraries") .load::(&mut *conn)? .into_iter() .map(|r| r.id) .collect() } else { view_library_ids.to_vec() }; let list = scan .iter() .map(i32::to_string) .collect::>() .join(","); let sql = format!( "SELECT COUNT(*) AS total, \ COALESCE(SUM(CASE WHEN {} THEN 1 ELSE 0 END), 0) AS visible \ FROM image_exif \ WHERE library_id IN ({list}) AND rel_path >= ? AND rel_path < ?", visible_version_clause(view_library_ids) ); let mut out = HashSet::new(); for dir in dirs { let dir = dir.trim_end_matches('/'); if dir.is_empty() { continue; } // '0' is the character after '/', closing the prefix range. let c = sql_query(&sql) .bind::(format!("{dir}/")) .bind::(format!("{dir}0")) .get_result::(&mut *conn)?; if c.total > 0 && c.visible == 0 { out.insert(dir.to_string()); } } Ok(out) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn unlinked_name_candidates( &mut self, library_id: i32, after_id: i32, limit: i64, ) -> Result, DbError> { trace_db_call("query", "unlinked_name_candidates", |_span| { #[derive(QueryableByName)] struct Row { #[diesel(sql_type = Integer)] id: i32, #[diesel(sql_type = Text)] rel_path: String, #[diesel(sql_type = Text)] content_hash: String, } let kinds = KINDS .iter() .map(|k| format!("rel_path LIKE '%.{k}.%'")) .collect::>() .join(" OR "); let sql = format!( "SELECT id, rel_path, content_hash FROM image_exif \ WHERE library_id = ? AND id > ? AND content_hash IS NOT NULL AND ({kinds}) \ AND NOT EXISTS (SELECT 1 FROM photo_versions pv \ WHERE pv.version_hash = image_exif.content_hash) \ ORDER BY id LIMIT ?" ); let mut conn = self.connection.lock().expect("Unable to get VersionDao"); sql_query(sql) .bind::(library_id) .bind::(after_id) .bind::(limit) .load::(&mut *conn) .map(|rows| { rows.into_iter() .map(|r| (r.id, r.rel_path, r.content_hash)) .collect() }) .map_err(|e| anyhow::anyhow!("Query error: {}", e)) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } fn sync_version_metadata(&mut self) -> Result { trace_db_call("update", "sync_version_metadata", |_span| { #[derive(QueryableByName)] struct Pair { #[diesel(sql_type = Integer)] vid: i32, #[diesel(sql_type = Integer)] oid: i32, } let mut conn = self.connection.lock().expect("Unable to get VersionDao"); // Version rows whose copy is missing or stale, against the // earliest row of a converted original. `IS NOT` compares NULLs. let stale = sql_query(format!( "SELECT v.id AS vid, o.id AS oid \ FROM image_exif v \ JOIN photo_versions pv ON pv.version_hash = v.content_hash \ JOIN image_exif o ON o.id = \ (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.original_hash) \ WHERE o.date_taken IS NOT NULL AND o.date_utc IS NOT NULL \ AND (v.date_taken_source IS NOT '{DATE_SOURCE_VERSION_OF}' OR {})", copied_differs() )) .load::(&mut *conn) .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; for p in &stale { sql_query(format!( "UPDATE image_exif SET ({COPIED_COLUMNS}, date_taken_source) = \ (SELECT {COPIED_COLUMNS}, '{DATE_SOURCE_VERSION_OF}' \ FROM image_exif WHERE id = ?) \ WHERE id = ?" )) .bind::(p.oid) .bind::(p.vid) .execute(&mut *conn) .map_err(|e| anyhow::anyhow!("Update error: {}", e))?; } Ok(stale.len()) }) .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) } } #[cfg(test)] mod tests { use super::*; use crate::database::test::in_memory_db_connection; fn dao() -> (SqliteVersionDao, Arc>) { let conn = Arc::new(Mutex::new(in_memory_db_connection())); (SqliteVersionDao::from_shared(conn.clone()), conn) } fn exec(conn: &Arc>, sql: &str) { sql_query(sql) .execute(&mut *conn.lock().unwrap()) .unwrap_or_else(|e| panic!("{sql}: {e}")); } fn insert_exif(conn: &Arc>, lib: i32, rel: &str, hash: &str) { exec( conn, &format!( "INSERT OR IGNORE INTO libraries (id, name, root_path, created_at) \ VALUES ({lib}, 'lib{lib}', '/tmp/lib{lib}', 0)" ), ); exec( conn, &format!( "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ VALUES ({lib}, '{rel}', 0, 0, '{hash}')" ), ); } fn link(vh: &str, oh: &str, rel: &str) -> NewVersionLink { NewVersionLink { version_hash: vh.into(), original_hash: oh.into(), kind: "restored".into(), source: SOURCE_XMP.into(), library_id: 1, rel_path: rel.into(), references: vec![], } } #[test] fn link_is_recorded_once_and_rejects_nonsense() { let (mut dao, _c) = dao(); assert_eq!( dao.link_version(&link("v", "o", "a/v.png")).unwrap(), LinkOutcome::Linked ); assert_eq!( dao.link_version(&link("v", "other", "a/v.png")).unwrap(), LinkOutcome::AlreadyLinked, "an existing link is never silently re-pointed" ); assert_eq!(dao.get_link("v").unwrap().unwrap().original_hash, "o"); assert!(matches!( dao.link_version(&link("x", "x", "a/x.png")).unwrap(), LinkOutcome::Rejected(_) )); let mut bad_kind = link("y", "o", "a/y.png"); bad_kind.kind = "bogus".into(); assert!(matches!( dao.link_version(&bad_kind).unwrap(), LinkOutcome::Rejected(_) )); } #[test] fn chains_are_flattened_to_the_root() { let (mut dao, _c) = dao(); dao.link_version(&link("v1", "root", "v1.png")).unwrap(); // A second-generation edit derived from the first version. dao.link_version(&link("v2", "v1", "v2.png")).unwrap(); assert_eq!(dao.get_link("v2").unwrap().unwrap().original_hash, "root"); // Linking an existing original underneath a new root moves its versions up. dao.link_version(&link("o2", "top", "o2.png")).unwrap(); dao.link_version(&link("child", "o2", "child.png")).unwrap(); assert_eq!(dao.get_link("child").unwrap().unwrap().original_hash, "top"); // A cycle is refused. assert!(matches!( dao.link_version(&link("root", "v2", "root.png")).unwrap(), LinkOutcome::Rejected(_) )); } #[test] fn references_are_stored_but_never_the_original() { let (mut dao, _c) = dao(); let mut l = link("v", "o", "v.png"); l.references = vec!["ref1".into(), "o".into(), "ref2".into(), "ref1".into()]; dao.link_version(&l).unwrap(); assert_eq!(dao.list_references("v").unwrap(), vec!["ref1", "ref2"]); } #[test] fn visible_clause_hides_only_while_original_is_reachable() { let (mut dao, c) = dao(); insert_exif(&c, 1, "a/orig.jpg", "o"); insert_exif(&c, 1, "a/orig.jpg.restored.png", "v"); insert_exif(&c, 2, "b/other.jpg", "other"); dao.link_version(&link("v", "o", "a/orig.jpg.restored.png")) .unwrap(); let visible = |libs: &[i32]| -> Vec { #[derive(QueryableByName)] struct R { #[diesel(sql_type = Text)] rel_path: String, } let mut rows: Vec = sql_query(format!( "SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path", visible_version_clause(libs) )) .load::(&mut *c.lock().unwrap()) .unwrap() .into_iter() .map(|r| r.rel_path) .collect(); rows.sort(); rows }; assert_eq!(visible(&[]), vec!["a/orig.jpg", "b/other.jpg"]); assert_eq!(visible(&[1, 2]), vec!["a/orig.jpg", "b/other.jpg"]); // The clause only decides hiding; the caller scopes rows to the view. // With the view narrowed to lib 2 the pair's original is out of // reach, so the version is not hidden. assert!(visible(&[2]).contains(&"a/orig.jpg.restored.png".to_string())); exec( &c, "UPDATE image_exif SET library_id = 2 WHERE rel_path = 'a/orig.jpg'", ); assert!( visible(&[1]).contains(&"a/orig.jpg.restored.png".to_string()), "original lives outside library 1" ); // Original deleted: the version shows up everywhere. exec(&c, "DELETE FROM image_exif WHERE rel_path = 'a/orig.jpg'"); assert_eq!(visible(&[]), vec!["a/orig.jpg.restored.png", "b/other.jpg"]); } #[test] fn hidden_rows_and_summaries_follow_the_same_rule() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v1"); insert_exif(&c, 1, "aiedits/orig.jpg.edit.png", "v2"); dao.link_version(&link("v1", "o", "aiedits/orig.jpg.restored.png")) .unwrap(); let mut l2 = link("v2", "o", "aiedits/orig.jpg.edit.png"); l2.kind = "edit".into(); dao.link_version(&l2).unwrap(); let hidden = dao.hidden_version_rows(&[]).unwrap(); assert_eq!(hidden.len(), 2); assert!(hidden.contains(&(1, "aiedits/orig.jpg.edit.png".to_string()))); assert!(dao.hidden_version_rows(&[3]).unwrap().is_empty()); let s = dao.photo_summaries().unwrap(); let sum = &s[&(1, "orig.jpg".to_string())]; assert_eq!(sum.version_count, 2); assert_eq!(sum.display, None); dao.set_display("o", Some("v2")).unwrap(); let s = dao.photo_summaries().unwrap(); assert_eq!( s[&(1, "orig.jpg".to_string())].display, Some((1, "aiedits/orig.jpg.edit.png".to_string())) ); // A display file that vanished falls back to the original. exec(&c, "DELETE FROM image_exif WHERE content_hash = 'v2'"); let s = dao.photo_summaries().unwrap(); let sum = &s[&(1, "orig.jpg".to_string())]; assert_eq!(sum.version_count, 1); assert_eq!(sum.display, None); dao.set_display("o", None).unwrap(); assert_eq!(dao.get_display("o").unwrap(), None); } #[test] fn no_versions_means_no_summaries() { let (mut dao, c) = dao(); insert_exif(&c, 1, "a.jpg", "a"); assert!(dao.photo_summaries().unwrap().is_empty()); assert!(dao.hidden_version_rows(&[]).unwrap().is_empty()); } #[test] fn canonical_path_resolves_a_version_to_its_original() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v"); dao.link_version(&link("v", "o", "aiedits/orig.jpg.restored.png")) .unwrap(); let mut conn = c.lock().unwrap(); assert_eq!( canonical_rel_path(&mut conn, None, "aiedits/orig.jpg.restored.png").unwrap(), Some("orig.jpg".to_string()) ); assert_eq!( canonical_rel_path(&mut conn, None, "orig.jpg").unwrap(), None ); drop(conn); // Original gone: nothing to bind to, the version is a normal photo. exec(&c, "DELETE FROM image_exif WHERE content_hash = 'o'"); let mut conn = c.lock().unwrap(); assert_eq!( canonical_rel_path(&mut conn, None, "aiedits/orig.jpg.restored.png").unwrap(), None ); } #[test] fn canonical_path_is_scoped_to_the_versions_library() { let (mut dao, c) = dao(); insert_exif(&c, 1, "album_a/orig.jpg", "o"); insert_exif(&c, 1, "album_a/aiedits/orig.jpg.edit.png", "v"); dao.link_version(&link("v", "o", "album_a/aiedits/orig.jpg.edit.png")) .unwrap(); // Library 2 has an unrelated, unlinked photo at the same path. insert_exif(&c, 2, "album_a/aiedits/orig.jpg.edit.png", "other"); let mut conn = c.lock().unwrap(); let p = "album_a/aiedits/orig.jpg.edit.png"; assert_eq!( canonical_rel_path(&mut conn, Some(2), p).unwrap(), None, "library 2's row is not a version" ); // Annotations are path-keyed, so a path that also names a // standalone photo is never redirected away from it. assert_eq!(canonical_rel_path(&mut conn, None, p).unwrap(), None); assert_eq!( canonical_rel_path(&mut conn, Some(1), p).unwrap(), None, "shared path with a standalone photo stays put" ); drop(conn); exec(&c, "DELETE FROM image_exif WHERE content_hash = 'other'"); let mut conn = c.lock().unwrap(); assert_eq!( canonical_rel_path(&mut conn, Some(1), p).unwrap(), Some("album_a/orig.jpg".to_string()) ); assert_eq!(canonical_rel_path(&mut conn, Some(2), p).unwrap(), None); } #[test] fn linking_merges_annotations_from_the_version_path_into_the_original() { let (mut dao, c) = dao(); insert_exif(&c, 1, "album_a/orig.jpg", "o"); insert_exif(&c, 1, "album_a/aiedits/orig.jpg.edit.png", "v"); let v = "album_a/aiedits/orig.jpg.edit.png"; let o = "album_a/orig.jpg"; for t in ["t1", "t2"] { exec( &c, &format!("INSERT INTO tags (name, created_time) VALUES ('{t}', 0)"), ); } // Tag 1 on both paths (duplicate), tag 2 only on the version. exec( &c, &format!( "INSERT INTO tagged_photo (rel_path, tag_id, created_time) \ SELECT '{o}', id, 1 FROM tags WHERE name = 't1'" ), ); exec( &c, &format!( "INSERT INTO tagged_photo (rel_path, tag_id, created_time) \ SELECT '{v}', id, 2 FROM tags" ), ); // User 1 favorited both; user 2 only the version. exec( &c, &format!( "INSERT INTO favorites (userid, rel_path) VALUES (1, '{o}'), (1, '{v}'), (2, '{v}')" ), ); dao.link_version(&link("v", "o", v)).unwrap(); let rows = |sql: &str| -> Vec<(String, i32)> { #[derive(QueryableByName)] struct R { #[diesel(sql_type = Text)] p: String, #[diesel(sql_type = Integer)] k: i32, } sql_query(sql) .load::(&mut *c.lock().unwrap()) .unwrap() .into_iter() .map(|r| (r.p, r.k)) .collect() }; assert_eq!( rows("SELECT rel_path AS p, userid AS k FROM favorites ORDER BY userid"), vec![(o.to_string(), 1), (o.to_string(), 2)] ); assert_eq!( rows("SELECT rel_path AS p, tag_id AS k FROM tagged_photo ORDER BY tag_id"), vec![(o.to_string(), 1), (o.to_string(), 2)] ); } #[test] fn sync_copies_date_gps_and_camera_from_a_converted_original() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); insert_exif(&c, 1, "orig.jpg.restored.png", "v"); dao.link_version(&link("v", "o", "orig.jpg.restored.png")) .unwrap(); // Unconverted original: nothing is copied (the version keeps its own date). exec( &c, "UPDATE image_exif SET date_taken = 1000 WHERE content_hash = 'o'", ); assert_eq!(dao.sync_version_metadata().unwrap(), 0); exec( &c, "UPDATE image_exif SET date_taken = 1000, date_utc = 1000, camera_offset_minutes = -240, \ date_taken_tz_source = 'offset_tag', gps_latitude = 12.5, gps_longitude = 7.25, \ camera_make = 'Canon', camera_model = 'EOS', date_taken_source = 'exif' \ WHERE content_hash = 'o'", ); exec( &c, "UPDATE image_exif SET date_taken = 5, date_taken_source = 'fs_time' WHERE content_hash = 'v'", ); assert_eq!(dao.sync_version_metadata().unwrap(), 1); assert_eq!(dao.sync_version_metadata().unwrap(), 0, "idempotent"); #[derive(QueryableByName)] struct R { #[diesel(sql_type = Nullable)] date_utc: Option, #[diesel(sql_type = Nullable)] src: Option, #[diesel(sql_type = Nullable)] lat: Option, #[diesel(sql_type = Nullable)] make: Option, } let row = sql_query( "SELECT date_utc, date_taken_source AS src, gps_latitude AS lat, camera_make AS make \ FROM image_exif WHERE content_hash = 'v'", ) .get_result::(&mut *c.lock().unwrap()) .unwrap(); assert_eq!(row.date_utc, Some(1000)); assert_eq!(row.src.as_deref(), Some("version_of")); assert_eq!(row.lat, Some(12.5)); assert_eq!(row.make.as_deref(), Some("Canon")); // The original's date changes (manual override): the copy follows. exec( &c, "UPDATE image_exif SET date_taken = 2000, date_utc = 2000 WHERE content_hash = 'o'", ); assert_eq!(dao.sync_version_metadata().unwrap(), 1); } #[test] fn name_candidates_skip_linked_rows() { let (mut dao, c) = dao(); insert_exif(&c, 1, "a/x.jpg.restored.png", "h1"); insert_exif(&c, 1, "a/x.jpg.edit.2.png", "h2"); insert_exif(&c, 1, "a/plain.png", "h3"); insert_exif(&c, 2, "a/y.jpg.restored.png", "h4"); dao.link_version(&link("h2", "ox", "a/x.jpg.edit.2.png")) .unwrap_or(LinkOutcome::Linked); let got = dao.unlinked_name_candidates(1, 0, 100).unwrap(); assert_eq!(got.len(), 1); assert_eq!( (got[0].1.as_str(), got[0].2.as_str()), ("a/x.jpg.restored.png", "h1") ); // The cursor skips rows at or before it. assert!( dao.unlinked_name_candidates(1, got[0].0, 100) .unwrap() .is_empty() ); } #[test] fn version_hashes_among_and_files_for_hashes() { let (mut dao, c) = dao(); insert_exif(&c, 1, "o.jpg", "o"); insert_exif(&c, 2, "o.jpg", "o"); dao.link_version(&link("v", "o", "v.png")).unwrap(); let got = dao .version_hashes_among(&["v".to_string(), "o".to_string()]) .unwrap(); assert_eq!(got, HashSet::from(["v".to_string()])); let files = dao.files_for_hashes(&["o".to_string()], Some(2)).unwrap(); assert_eq!(files["o"], (2, "o.jpg".to_string())); let files = dao.files_for_hashes(&["o".to_string()], None).unwrap(); assert_eq!(files["o"].0, 1); } fn visible_in(c: &Arc>, libs: &[i32]) -> Vec { #[derive(QueryableByName)] struct R { #[diesel(sql_type = Text)] rel_path: String, } let mut rows: Vec = sql_query(format!( "SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path", visible_version_clause(libs) )) .load::(&mut *c.lock().unwrap()) .unwrap() .into_iter() .map(|r| r.rel_path) .collect(); rows.sort(); rows } #[test] fn a_version_surfaces_when_its_original_is_demoted_as_a_duplicate() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); insert_exif(&c, 1, "orig.jpg.restored.png", "v"); insert_exif(&c, 1, "survivor.jpg", "s"); dao.link_version(&link("v", "o", "orig.jpg.restored.png")) .unwrap(); assert_eq!(visible_in(&c, &[]), vec!["orig.jpg", "survivor.jpg"]); exec( &c, "UPDATE image_exif SET duplicate_of_hash = 's' WHERE content_hash = 'o'", ); // Unscoped: O is hidden by the duplicate rule, so V stands alone. let shown = visible_in(&c, &[]); assert!( shown.contains(&"orig.jpg.restored.png".to_string()), "{shown:?}" ); assert!(dao.hidden_version_rows(&[]).unwrap().is_empty()); // Scoped with the survivor in view: same. assert!(visible_in(&c, &[1]).contains(&"orig.jpg.restored.png".to_string())); assert!(dao.hidden_version_rows(&[1]).unwrap().is_empty()); // Scoped to a view that lacks the survivor, O stays visible (the // duplicate rule), so V hides behind it again. insert_exif(&c, 2, "elsewhere.jpg", "e"); exec( &c, "UPDATE image_exif SET duplicate_of_hash = 'e' WHERE content_hash = 'o'", ); assert!(!visible_in(&c, &[1]).contains(&"orig.jpg.restored.png".to_string())); assert_eq!(dao.hidden_version_rows(&[1]).unwrap().len(), 1); } #[test] fn display_file_comes_from_the_originals_library_or_not_at_all() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); // Same bytes in two libraries. insert_exif(&c, 2, "copy/v.png", "v"); insert_exif(&c, 1, "aiedits/v.png", "v"); dao.link_version(&link("v", "o", "aiedits/v.png")).unwrap(); dao.set_display("o", Some("v")).unwrap(); let s = dao.photo_summaries().unwrap(); assert_eq!( s[&(1, "orig.jpg".to_string())].display, Some((1, "aiedits/v.png".to_string())) ); // Only the other library holds the chosen bytes: show the original. exec( &c, "DELETE FROM image_exif WHERE library_id = 1 AND content_hash = 'v'", ); let s = dao.photo_summaries().unwrap(); let sum = &s[&(1, "orig.jpg".to_string())]; assert_eq!(sum.version_count, 1); assert_eq!(sum.display, None); } #[test] fn linking_drops_faces_and_clip_data_derived_before_the_link() { let (mut dao, c) = dao(); insert_exif(&c, 1, "orig.jpg", "o"); insert_exif(&c, 1, "v.png", "v"); exec( &c, "UPDATE image_exif SET clip_embedding = x'00', clip_model_version = 'm' \ WHERE content_hash IN ('o', 'v')", ); for h in ["o", "v"] { exec( &c, &format!( "INSERT INTO face_detections (library_id, content_hash, rel_path, bbox_x, \ embedding, source, status, model_version, created_at) \ VALUES (1, '{h}', 'x', 0.1, x'00', 'auto', 'detected', 'm', 0)" ), ); exec( &c, &format!( "INSERT INTO clip_failures (library_id, rel_path, content_hash, error, failed_at) \ VALUES (1, 'p-{h}', '{h}', 'boom', 0)" ), ); } dao.link_version(&link("v", "o", "v.png")).unwrap(); let n = |sql: &str| -> i64 { #[derive(QueryableByName)] struct N { #[diesel(sql_type = BigInt)] n: i64, } sql_query(sql) .get_result::(&mut *c.lock().unwrap()) .unwrap() .n }; assert_eq!( n("SELECT COUNT(*) AS n FROM face_detections WHERE content_hash='v'"), 0 ); assert_eq!( n("SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash='v'"), 0 ); assert_eq!( n( "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash='v' \ AND (clip_embedding IS NOT NULL OR clip_model_version IS NOT NULL)" ), 0 ); // The original keeps its own. assert_eq!( n("SELECT COUNT(*) AS n FROM face_detections WHERE content_hash='o'"), 1 ); assert_eq!( n("SELECT COUNT(*) AS n FROM clip_failures WHERE content_hash='o'"), 1 ); assert_eq!( n( "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash='o' AND clip_embedding IS NOT NULL" ), 1 ); } }