Files
ImageApi/src/database/version_dao.rs
T

1548 lines
61 KiB
Rust

//! 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=<version>` 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::<Vec<_>>()
.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<String>,
}
#[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<Integer>)]
pub width: Option<i32>,
#[diesel(sql_type = Nullable<Integer>)]
pub height: Option<i32>,
}
/// 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<LinkOutcome, DbError>;
/// The link row for a version hash, if any.
fn get_link(&mut self, version_hash: &str) -> Result<Option<PhotoVersionRow>, 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<Option<String>, 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<Option<(i32, String)>, DbError>;
/// Which of `hashes` are versions of something.
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError>;
/// Live versions of an original (those with an `image_exif` row),
/// oldest first.
fn list_versions(&mut self, original_hash: &str) -> Result<Vec<VersionFile>, DbError>;
/// Reference photo hashes recorded for a version.
fn list_references(&mut self, version_hash: &str) -> Result<Vec<String>, DbError>;
/// First live `image_exif` row for each hash, preferring `prefer_library`.
fn files_for_hashes(
&mut self,
hashes: &[String],
prefer_library: Option<i32>,
) -> Result<HashMap<String, (i32, String)>, DbError>;
/// The stored display choice for an original, if any.
fn get_display(&mut self, original_hash: &str) -> Result<Option<String>, 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<HashMap<(i32, String), PhotoVersionSummary>, 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<HashSet<(i32, String)>, 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<HashSet<String>, DbError>;
/// Rows in a library whose file name follows the version convention
/// (`<name>.<kind>[.<n>].<ext>`), 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<Vec<(i32, String, String)>, 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<usize, DbError>;
}
pub struct SqliteVersionDao {
connection: Arc<Mutex<SqliteConnection>>,
}
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<Mutex<SqliteConnection>>) -> 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<i32>,
rel_path: &str,
) -> QueryResult<Option<String>> {
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<i32>,
rel_path: &str,
) -> QueryResult<Option<(i32, String)>> {
#[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::<Text, _>(rel_path)
.bind::<Nullable<Integer>, _>(library_id)
.load::<Row>(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::<Text, _>(version_rel_path)
.bind::<Text, _>(&original)
.execute(conn)?;
sql_query("DELETE FROM favorites WHERE rel_path = ?")
.bind::<Text, _>(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::<Text, _>(version_rel_path)
.bind::<Text, _>(&original)
.execute(conn)?;
sql_query("DELETE FROM tagged_photo WHERE rel_path = ?")
.bind::<Text, _>(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<Option<String>> {
#[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::<Text, _>(&current)
.load::<Row>(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::<Vec<_>>()
.join(" OR ")
}
impl VersionDao for SqliteVersionDao {
fn link_version(&mut self, link: &NewVersionLink) -> Result<LinkOutcome, DbError> {
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::<LinkOutcome, anyhow::Error, _>(|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::<Text, _>(&link.version_hash)
.load::<PhotoVersionRow>(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::<Text, _>(&link.version_hash)
.bind::<Text, _>(&root)
.bind::<Text, _>(&link.kind)
.bind::<Text, _>(&link.source)
.bind::<BigInt, _>(chrono::Utc::now().timestamp())
.bind::<Integer, _>(link.library_id)
.bind::<Text, _>(&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::<Text, _>(&link.version_hash)
.execute(conn)?;
sql_query(
"UPDATE image_exif SET clip_embedding = NULL, clip_model_version = NULL \
WHERE content_hash = ?",
)
.bind::<Text, _>(&link.version_hash)
.execute(conn)?;
sql_query("DELETE FROM clip_failures WHERE content_hash = ?")
.bind::<Text, _>(&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::<Text, _>(&root)
.bind::<Text, _>(&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::<Text, _>(&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::<Text, _>(&link.version_hash)
.bind::<Text, _>(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<Option<PhotoVersionRow>, 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::<Text, _>(version_hash)
.load::<PhotoVersionRow>(&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<Option<String>, 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<Option<(i32, String)>, 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<HashSet<String>, 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::<Text, _>(h);
}
let rows = q
.load::<Row>(&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<Vec<VersionFile>, 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::<Text, _>(original_hash)
.load::<VersionFile>(&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<Vec<String>, 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::<Text, _>(version_hash)
.load::<Row>(&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<i32>,
) -> Result<HashMap<String, (i32, String)>, 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<String, (i32, String)> = 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::<Text, _>(h);
}
let rows = q
.load::<HashedFile>(&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<Option<String>, 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::<Text, _>(original_hash)
.load::<Row>(&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::<Text, _>(original_hash)
.bind::<Text, _>(display)
.bind::<BigInt, _>(chrono::Utc::now().timestamp())
.execute(&mut *conn),
None => sql_query("DELETE FROM photo_version_display WHERE original_hash = ?")
.bind::<Text, _>(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<HashMap<(i32, String), PhotoVersionSummary>, 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::<OriginalRow>(&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::<DisplayRow>(&mut *conn)
.map_err(|e| anyhow::anyhow!("Query error: {}", e))?;
let mut display_by_hash: HashMap<String, Vec<(i32, String)>> = 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<HashSet<(i32, String)>, 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::<Row>(&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<HashSet<String>, 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<i32> = if view_library_ids.is_empty() {
sql_query("SELECT id FROM libraries")
.load::<Id>(&mut *conn)?
.into_iter()
.map(|r| r.id)
.collect()
} else {
view_library_ids.to_vec()
};
let list = scan
.iter()
.map(i32::to_string)
.collect::<Vec<_>>()
.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::<Text, _>(format!("{dir}/"))
.bind::<Text, _>(format!("{dir}0"))
.get_result::<Counts>(&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<Vec<(i32, String, String)>, 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::<Vec<_>>()
.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::<Integer, _>(library_id)
.bind::<Integer, _>(after_id)
.bind::<BigInt, _>(limit)
.load::<Row>(&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<usize, DbError> {
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::<Pair>(&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::<Integer, _>(p.oid)
.bind::<Integer, _>(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<Mutex<SqliteConnection>>) {
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
(SqliteVersionDao::from_shared(conn.clone()), conn)
}
fn exec(conn: &Arc<Mutex<SqliteConnection>>, sql: &str) {
sql_query(sql)
.execute(&mut *conn.lock().unwrap())
.unwrap_or_else(|e| panic!("{sql}: {e}"));
}
fn insert_exif(conn: &Arc<Mutex<SqliteConnection>>, 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<String> {
#[derive(QueryableByName)]
struct R {
#[diesel(sql_type = Text)]
rel_path: String,
}
let mut rows: Vec<String> = sql_query(format!(
"SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path",
visible_version_clause(libs)
))
.load::<R>(&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::<R>(&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<BigInt>)]
date_utc: Option<i64>,
#[diesel(sql_type = Nullable<Text>)]
src: Option<String>,
#[diesel(sql_type = Nullable<diesel::sql_types::Float>)]
lat: Option<f32>,
#[diesel(sql_type = Nullable<Text>)]
make: Option<String>,
}
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::<R>(&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<Mutex<SqliteConnection>>, libs: &[i32]) -> Vec<String> {
#[derive(QueryableByName)]
struct R {
#[diesel(sql_type = Text)]
rel_path: String,
}
let mut rows: Vec<String> = sql_query(format!(
"SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path",
visible_version_clause(libs)
))
.load::<R>(&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::<N>(&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
);
}
}