From 392e818f525c1c406c0498ba8613f4380aed8a86 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Tue, 28 Apr 2026 11:06:30 -0400 Subject: [PATCH] feat(search): add substring fallback and no_match handling .resolve_basket_row() now falls back to case-insensitive substring match when no exact match is found, and short-circuits empty/whitespace input to no_match. Disambiguation stub raises pending Task 5. --- R/search.R | 69 ++++++++++++++++++++++++++++-------- tests/testthat/test-search.R | 36 +++++++++++++++++++ 2 files changed, 90 insertions(+), 15 deletions(-) diff --git a/R/search.R b/R/search.R index b2b5127..67a7672 100644 --- a/R/search.R +++ b/R/search.R @@ -168,6 +168,18 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) { # Internal use only; takes an active DuckDB connection to reuse the session. #' @noRd .resolve_basket_row <- function(name, state, type, con) { + # Short-circuit: empty/whitespace name -> no_match without SQL. + if (!nzchar(trimws(name))) { + empty <- .empty_xwalk_tibble() + return(list( + status = "no_match", + match_method = NA_character_, + n_candidates = 0L, + row = empty, + candidates = empty + )) + } + preds <- character(0) if (!is.na(state)) { st_fips <- .coerce_state_to_fips(state) @@ -177,34 +189,61 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) { int_type <- .coerce_type(type) preds <- c(preds, sprintf("govs_type = %d", int_type)) } - base_where <- if (length(preds) == 0L) "" else paste("WHERE", paste(preds, collapse = " AND ")) + conj <- if (nzchar(base_where)) "AND" else "WHERE" exact_sql <- paste( "SELECT * FROM canonical_fips_xwalk", base_where, - if (nzchar(base_where)) "AND" else "WHERE", + conj, sprintf("LOWER(gov_name) = LOWER(%s)", .sql_lit_chr(name)) ) exact <- tibble::as_tibble(DBI::dbGetQuery(con, exact_sql)) if (nrow(exact) == 1L) { return(list( - status = "resolved", - match_method = "exact", - n_candidates = 1L, - row = exact, - candidates = exact + status = "resolved", + match_method = "exact", + n_candidates = 1L, + row = exact, + candidates = exact )) } + if (nrow(exact) > 1L) { + return(.disambiguate(exact, method = "exact")) + } - # Substring + disambiguation branches added in subsequent tasks. - empty <- exact[0, , drop = FALSE] - list( - status = "no_match", - match_method = NA_character_, - n_candidates = 0L, - row = empty, - candidates = empty + sub_sql <- paste( + "SELECT * FROM canonical_fips_xwalk", + base_where, + conj, + sprintf("regexp_matches(gov_name, %s, 'i')", .sql_lit_chr(name)) ) + sub <- tibble::as_tibble(DBI::dbGetQuery(con, sub_sql)) + + if (nrow(sub) == 0L) { + return(list( + status = "no_match", + match_method = NA_character_, + n_candidates = 0L, + row = sub, + candidates = sub + )) + } + if (nrow(sub) == 1L) { + return(list( + status = "resolved", + match_method = "substring", + n_candidates = 1L, + row = sub, + candidates = sub + )) + } + .disambiguate(sub, method = "substring") +} + +# Stub for Task 5; raises so any accidental hit during Task 4 is loud. +#' @noRd +.disambiguate <- function(matches, method) { + cli::cli_abort("internal: .disambiguate() not yet implemented") } diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index b5f68b3..5076a7f 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -168,3 +168,39 @@ test_that(".resolve_basket_row exact match honors per-row type", { expect_equal(out$status, "resolved") expect_equal(out$row$canonical_govid, "052037010") }) + +test_that(".resolve_basket_row substring fallback resolves single match", { + con <- uscogdata:::.ensure_session() + out <- uscogdata:::.resolve_basket_row( + name = "Broward", state = "FL", type = NA_character_, con = con + ) + expect_equal(out$status, "resolved") + expect_equal(out$match_method, "substring") + expect_equal(out$n_candidates, 1L) + expect_equal(out$row$canonical_govid, "101006006") +}) + +test_that(".resolve_basket_row no_match returns 0-row tibble", { + con <- uscogdata:::.ensure_session() + out <- uscogdata:::.resolve_basket_row( + name = "Notarealplace", state = "NY", type = NA_character_, con = con + ) + expect_equal(out$status, "no_match") + expect_true(is.na(out$match_method)) + expect_equal(out$n_candidates, 0L) + expect_equal(nrow(out$row), 0L) + expect_equal(nrow(out$candidates), 0L) +}) + +test_that(".resolve_basket_row treats empty/whitespace name as no_match", { + con <- uscogdata:::.ensure_session() + out_empty <- uscogdata:::.resolve_basket_row( + name = "", state = "FL", type = NA_character_, con = con + ) + expect_equal(out_empty$status, "no_match") + + out_ws <- uscogdata:::.resolve_basket_row( + name = " ", state = "FL", type = NA_character_, con = con + ) + expect_equal(out_ws$status, "no_match") +})