Skip to main content

max / goingson

Dedup search_repo FTS helpers Extract the two identical seams shared by the five search_*_fts functions into helpers, rather than collapsing their divergent bodies into one generic. run_fts_query<R> absorbs the bind-term/bind-user/bind-params/ fetch/map_err tail repeated in all five, handling the optional-term direct-query path for tasks and events via Option<&str>. push_project_filters absorbs the identical project: ID and name filter block from tasks, emails, and events, parameterized by table alias. The generated SQL and bind order are unchanged; about 99 lines of duplication removed. Build and clippy clean, all search_repo tests and the full db-sqlite suite pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 16:27 UTC
Commit: ef66d6a69c49fb97e76542e283ca42527228f357
Parent: 1c262e1
1 file changed, +65 insertions, -103 deletions
@@ -243,6 +243,63 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> {
243 243 .collect()
244 244 }
245 245
246 + /// Append the shared `project:` filters (by ID, then by partial name) to a search
247 + /// query being assembled, binding their parameters and advancing `param_idx`.
248 + /// `alias` is the SQL table alias the `project_id` column hangs off (e.g. `t`, `e`, `ev`).
249 + fn push_project_filters(
250 + sql: &mut String,
251 + params: &mut Vec<String>,
252 + param_idx: &mut i32,
253 + query: &SearchQuery,
254 + alias: &str,
255 + ) {
256 + // Project filter by ID
257 + if let Some(pid) = &query.project_id {
258 + sql.push_str(&format!(" AND {}.project_id = ${}", alias, param_idx));
259 + params.push(pid.to_string());
260 + *param_idx += 1;
261 + }
262 +
263 + // Project filter by name (partial match)
264 + if let Some(pname) = &query.project_name {
265 + sql.push_str(&format!(
266 + " AND {}.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
267 + alias, param_idx
268 + ));
269 + params.push(format!("%{}%", escape_like(pname)));
270 + *param_idx += 1;
271 + }
272 + }
273 +
274 + /// Run an assembled FTS search: bind the optional MATCH term and `user_id`, then the
275 + /// positional `params`, and fetch typed rows. When `search_term` is `None` the term
276 + /// bind is skipped (the direct, filter-only query path). Every `search_*_fts` helper
277 + /// shares this bind-and-fetch tail; only the row type and result mapping differ.
278 + async fn run_fts_query<R>(
279 + pool: &SqlitePool,
280 + sql: &str,
281 + search_term: Option<&str>,
282 + user_id: &str,
283 + params: Vec<String>,
284 + ) -> Result<Vec<R>>
285 + where
286 + R: for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> + Send + Unpin,
287 + {
288 + let mut db_query = sqlx::query_as::<_, R>(sql);
289 +
290 + if let Some(term) = search_term {
291 + db_query = db_query.bind(term).bind(user_id);
292 + } else {
293 + db_query = db_query.bind(user_id);
294 + }
295 +
296 + for param in params {
297 + db_query = db_query.bind(param);
298 + }
299 +
300 + db_query.fetch_all(pool).await.map_err(CoreError::database)
301 + }
302 +
246 303 /// Searches tasks via FTS5 MATCH with BM25 relevance ranking, or direct query when
247 304 /// no search text is provided. Applies optional filters: `is:` status/date, project
248 305 /// (by ID or name), priority, tag include/exclude, and date range on the due field.
@@ -312,22 +369,7 @@ async fn search_tasks_fts(
312 369 sql.push_str(&format!(" AND {}", clause));
313 370 }
314 371
315 - // Project filter by ID
316 - if let Some(pid) = &query.project_id {
317 - sql.push_str(&format!(" AND t.project_id = ${}", param_idx));
318 - params.push(pid.to_string());
319 - param_idx += 1;
320 - }
321 -
322 - // Project filter by name (partial match)
323 - if let Some(pname) = &query.project_name {
324 - sql.push_str(&format!(
325 - " AND t.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
326 - param_idx
327 - ));
328 - params.push(format!("%{}%", escape_like(pname)));
329 - param_idx += 1;
330 - }
372 + push_project_filters(&mut sql, &mut params, &mut param_idx, query, "t");
331 373
332 374 // Priority filter
333 375 if let Some(priority) = &query.priority {
@@ -377,22 +419,7 @@ async fn search_tasks_fts(
377 419
378 420 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
379 421
380 - // Build and execute query
381 - let mut db_query = sqlx::query_as::<_, Row>(&sql);
382 -
383 - if let Some(term) = search_term {
384 - db_query = db_query
385 - .bind(term)
386 - .bind(user_id);
387 - } else {
388 - db_query = db_query.bind(user_id);
389 - }
390 -
391 - for param in params {
392 - db_query = db_query.bind(param);
393 - }
394 -
395 - let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
422 + let rows: Vec<Row> = run_fts_query(pool, &sql, search_term, user_id, params).await?;
396 423
397 424 rows.into_iter()
398 425 .map(|row| {
@@ -459,22 +486,7 @@ async fn search_emails_fts(
459 486 let mut params: Vec<String> = Vec::new();
460 487 let mut param_idx = 3;
461 488
462 - // Project filter by ID
463 - if let Some(pid) = &query.project_id {
464 - sql.push_str(&format!(" AND e.project_id = ${}", param_idx));
465 - params.push(pid.to_string());
466 - param_idx += 1;
467 - }
468 -
469 - // Project filter by name
470 - if let Some(pname) = &query.project_name {
471 - sql.push_str(&format!(
472 - " AND e.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
473 - param_idx
474 - ));
475 - params.push(format!("%{}%", escape_like(pname)));
476 - param_idx += 1;
477 - }
489 + push_project_filters(&mut sql, &mut params, &mut param_idx, query, "e");
478 490
479 491 // Date filters. Compare the stored `received_at` text directly (it is written
480 492 // in the sortable "%Y-%m-%d %H:%M:%S" format, so lexicographic order is
@@ -494,15 +506,7 @@ async fn search_emails_fts(
494 506
495 507 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
496 508
497 - let mut db_query = sqlx::query_as::<_, Row>(&sql)
498 - .bind(search_term)
499 - .bind(user_id);
500 -
501 - for param in params {
502 - db_query = db_query.bind(param);
503 - }
504 -
505 - let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
509 + let rows: Vec<Row> = run_fts_query(pool, &sql, Some(search_term), user_id, params).await?;
506 510
507 511 rows.into_iter()
508 512 .map(|row| {
@@ -587,15 +591,7 @@ async fn search_projects_fts(
587 591
588 592 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
589 593
590 - let mut db_query = sqlx::query_as::<_, Row>(&sql)
591 - .bind(search_term)
592 - .bind(user_id);
593 -
594 - for param in params {
595 - db_query = db_query.bind(param);
596 - }
597 -
598 - let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
594 + let rows: Vec<Row> = run_fts_query(pool, &sql, Some(search_term), user_id, params).await?;
599 595
600 596 rows.into_iter()
601 597 .map(|row| {
@@ -703,22 +699,7 @@ async fn search_events_fts(
703 699 }
704 700 }
705 701
706 - // Project filter by ID
707 - if let Some(pid) = &query.project_id {
708 - sql.push_str(&format!(" AND ev.project_id = ${}", param_idx));
709 - params.push(pid.to_string());
710 - param_idx += 1;
711 - }
712 -
713 - // Project filter by name
714 - if let Some(pname) = &query.project_name {
715 - sql.push_str(&format!(
716 - " AND ev.project_id IN (SELECT id FROM projects WHERE name LIKE ${} ESCAPE '\\' COLLATE NOCASE)",
717 - param_idx
718 - ));
719 - params.push(format!("%{}%", escape_like(pname)));
720 - param_idx += 1;
721 - }
702 + push_project_filters(&mut sql, &mut params, &mut param_idx, query, "ev");
722 703
723 704 // Date filters. Sargable bare-column comparison against a same-format bound.
724 705 if let Some(df) = &query.date_from {
@@ -739,21 +720,7 @@ async fn search_events_fts(
739 720
740 721 sql.push_str(&format!(" ORDER BY rank LIMIT {}", per_type_limit));
741 722
742 - let mut db_query = sqlx::query_as::<_, Row>(&sql);
743 -
744 - if let Some(term) = search_term {
745 - db_query = db_query
746 - .bind(term)
747 - .bind(user_id);
748 - } else {
749 - db_query = db_query.bind(user_id);
750 - }
751 -
752 - for param in params {
753 - db_query = db_query.bind(param);
754 - }
755 -
756 - let rows: Vec<Row> = db_query.fetch_all(pool).await.map_err(CoreError::database)?;
723 + let rows: Vec<Row> = run_fts_query(pool, &sql, search_term, user_id, params).await?;
757 724
758 725 rows.into_iter()
759 726 .map(|row| {
@@ -827,12 +794,7 @@ async fn search_contacts_fts(
827 794 LIMIT {}
828 795 "#, per_type_limit);
829 796
830 - let rows: Vec<Row> = sqlx::query_as::<_, Row>(&sql)
831 - .bind(search_term)
832 - .bind(user_id)
833 - .fetch_all(pool)
834 - .await
835 - .map_err(CoreError::database)?;
797 + let rows: Vec<Row> = run_fts_query(pool, &sql, Some(search_term), user_id, Vec::new()).await?;
836 798
837 799 rows.into_iter()
838 800 .map(|row| {