Filter the people rail in the query, and count the unassigned faces

`faces::people` grouped `face_person` after a LEFT JOIN over every person
and sorted the lot by name; the rail then discarded the empty, unnamed
groups a regrouping pass leaves behind — 17,000 of 19,000 rows on the
reference library. `people_in_use` filters them in the WHERE and joins
`people` to face counts aggregated first (2,000 groups), so the sort sees
only the rows that will be drawn. `count_unassigned` replaces fetching
2,400 ids to take their length. `load_people` 22 ms → 10 ms.
This commit is contained in:
2026-09-20 10:56:36 +02:00
parent 9d1aa5735b
commit b4821ee1ab
2 changed files with 58 additions and 14 deletions
+53 -8
View File
@@ -838,6 +838,22 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// How many faces have no identity yet — [`unassigned`] counted rather than
/// listed, for a screen that only shows the number.
pub fn count_unassigned(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
let n: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces f
LEFT JOIN face_person fp ON fp.face_id = f.id
WHERE fp.face_id IS NULL AND {} = ?1",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get(0),
)?;
Ok(n as u64)
}
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A struct rather than a tuple because it crosses a crate boundary and "the
@@ -910,17 +926,46 @@ pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<
/// Merged-away people are excluded: they exist as redirects so a sync does not
/// resurrect them, not as entries in a list.
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
let mut q = conn.prepare(
people_where(conn, false)
}
/// Everyone who holds a face, carries a name, or was set aside — the people a
/// screen has a row for.
///
/// The rest are the empty, unnamed groups a regrouping pass leaves behind
/// (`prune_empty_unnamed`), and on the reference library they were 17,000 of
/// 19,000 rows: read, counted, sorted by name and then thrown away by the
/// caller on every redraw. Filtered here, in the query, they are never
/// sorted. The filter is SQL's `trim`, which strips spaces and not every
/// whitespace character, so a name that is only a tab is listed rather than
/// hidden — the safe direction for a row the user typed something into.
pub fn people_in_use(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
people_where(conn, true)
}
/// [`people`], with face counts aggregated once per person *before* the join
/// rather than grouped after it: the face table is joined to the 2,000
/// people it names, not the 19,000 rows of the people table.
fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogError> {
let filter = if in_use {
"AND (c.person_id IS NOT NULL OR trim(p.name) <> '' OR p.ignored)"
} else {
""
};
let mut q = conn.prepare(&format!(
"SELECT p.id, p.uuid, p.name,
COALESCE(SUM(fp.confirmed = 1), 0),
COALESCE(SUM(fp.confirmed = 0), 0),
COALESCE(c.confirmed, 0),
COALESCE(c.suggested, 0),
p.ignored
FROM people p
LEFT JOIN face_person fp ON fp.person_id = p.id
WHERE p.merged_into IS NULL
GROUP BY p.id
ORDER BY 4 DESC, 5 DESC, p.name",
)?;
LEFT JOIN (SELECT person_id,
SUM(confirmed = 1) AS confirmed,
SUM(confirmed = 0) AS suggested
FROM face_person
GROUP BY person_id) c ON c.person_id = p.id
WHERE p.merged_into IS NULL {filter}
ORDER BY 4 DESC, 5 DESC, p.name"
))?;
let rows = q.query_map([], |r| {
Ok(Person {
id: PersonId(r.get::<_, i64>(0)? as u64),