Scope canonical_rel_path and merge version-path annotations at link time (#194)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 19:22:52 -04:00
1 parent 008dd0e7f4
commit aefc4bc411
3 files changed
+173 -14

No files matched your search

+10 -8
View File
@@ -323,10 +323,11 @@ impl FavoriteDao for SqliteFavoriteDao {
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao");
// Favoriting a photo version favorites its original (#191).
let favorite_path = &version_dao::canonical_rel_path(connection.deref_mut(), favorite_path)
.ok()
.flatten()
.unwrap_or_else(|| favorite_path.to_string());
let favorite_path =
&version_dao::canonical_rel_path(connection.deref_mut(), None, favorite_path)
.ok()
.flatten()
.unwrap_or_else(|| favorite_path.to_string());
if favorites
.filter(userid.eq(user_id).and(rel_path.eq(&favorite_path)))
@@ -349,10 +350,11 @@ impl FavoriteDao for SqliteFavoriteDao {
use schema::favorites::dsl::*;
let mut connection = self.connection.lock().unwrap();
let favorite_path = version_dao::canonical_rel_path(connection.deref_mut(), &favorite_path)
.ok()
.flatten()
.unwrap_or(favorite_path);
let favorite_path =
version_dao::canonical_rel_path(connection.deref_mut(), None, &favorite_path)
.ok()
.flatten()
.unwrap_or(favorite_path);
diesel::delete(favorites)
.filter(userid.eq(user_id).and(rel_path.eq(favorite_path)))
.execute(connection.deref_mut())
+162 -5
View File
@@ -282,8 +282,15 @@ fn placeholders(n: usize) -> String {
/// 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>> {
#[derive(QueryableByName)]
@@ -295,15 +302,63 @@ pub fn canonical_rel_path(
"SELECT 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 = ? \
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.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>> {
@@ -424,6 +479,8 @@ impl VersionDao for SqliteVersionDao {
.bind::<Text, _>(reference)
.execute(conn)?;
}
merge_annotations_into_original(conn, link.library_id, &link.rel_path)?;
Ok(LinkOutcome::Linked)
})
})
@@ -448,7 +505,7 @@ impl VersionDao for SqliteVersionDao {
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, rel_path)
canonical_rel_path(&mut conn, None, rel_path)
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
@@ -1014,20 +1071,120 @@ mod tests {
.unwrap();
let mut conn = c.lock().unwrap();
assert_eq!(
canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(),
canonical_rel_path(&mut conn, None, "aiedits/orig.jpg.restored.png").unwrap(),
Some("orig.jpg".to_string())
);
assert_eq!(canonical_rel_path(&mut conn, "orig.jpg").unwrap(), None);
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, "aiedits/orig.jpg.restored.png").unwrap(),
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();
+1 -1
View File
@@ -608,7 +608,7 @@ pub trait TagDao: Send + Sync {
/// `path` is a linked photo version whose original is still indexed, else
/// `path` itself.
fn canonical_path(conn: &mut SqliteConnection, path: &str) -> String {
crate::database::version_dao::canonical_rel_path(conn, path)
crate::database::version_dao::canonical_rel_path(conn, None, path)
.ok()
.flatten()
.unwrap_or_else(|| path.to_string())