diff --git a/R/search.R b/R/search.R index e124c48..dbccc0a 100644 --- a/R/search.R +++ b/R/search.R @@ -2,32 +2,45 @@ #' Search for governments by name, state, and/or type #' -#' Returns rows from `canonical_fips_xwalk` matching the supplied filters. -#' Intended as the entry point users call to resolve a human-readable place -#' name into one or more `canonical_govid` values before calling -#' [cog_spending()] / [cog_revenue()] / etc. +#' Two modes: #' -#' @param name Character regex matched case-insensitively against -#' `gov_name`. `NULL` (default) means no name filter. -#' @param state Either a 2-letter USPS abbreviation (e.g. `"FL"`), a FIPS -#' integer (e.g. `12`), or `NULL`. +#' * **Utility mode** (single `name`): returns all rows from +#' `canonical_fips_xwalk` whose `gov_name` matches the regex +#' case-insensitively, sorted by `population_acs` descending. +#' * **Basket mode** (`length(name) > 1`): resolves each input row to a +#' single canonical govid via exact-then-substring matching with +#' deterministic disambiguation. Returns up to `length(name)` rows in +#' input order plus a `"resolution"` sidecar attribute. See +#' [cog_basket_resolution()]. +#' +#' @param name Character vector of place name(s). Length 1 = utility mode; +#' length >1 = basket mode. +#' @param state Either a 2-letter USPS abbreviation, a FIPS integer, or +#' `NULL`. Length 1 recycles across all entries in basket mode. #' @param type Government type: an integer in `0:3` or one of `"state"`, -#' `"county"`, `"city"`, `"township"`. Passing `4`, `5`, -#' `"special_district"`, or `"school_district"` emits an explanatory -#' message and returns an empty tibble (v0.1 corpus excludes those types). -#' @return Tibble from `canonical_fips_xwalk` sorted by `population_acs` -#' descending (`NULL`s last). +#' `"county"`, `"city"`, `"township"`, or `NA` (per-row optional in +#' basket mode). Passing `4`, `5`, `"special_district"`, or +#' `"school_district"` emits an explanatory message and returns an +#' empty tibble (v0.1 corpus excludes those types). +#' @return Tibble from `canonical_fips_xwalk`. In utility mode, sorted by +#' `population_acs` descending (`NULL`s last). In basket mode, in input +#' order, with `attr(result, "resolution")` set to the sidecar tibble. #' @export cog_gov_search <- function(name = NULL, state = NULL, type = NULL) { - if (!is.null(type) && .is_excluded_type(type)) { + if (!is.null(type) && length(type) == 1L && .is_excluded_type(type)) { cli::cli_inform(c( i = "v0.1 covers gov_types 0-3 (state/county/city/township) only.", i = "Types 4 (special districts) and 5 (school districts) are excluded; see vignette('coverage-scope')." )) return(.empty_xwalk_tibble()) } + con <- .ensure_session() + if (length(name) > 1L) { + return(.resolve_basket(name = name, state = state, type = type, con = con)) + } + preds <- character(0) if (!is.null(name)) { if (!is.character(name) || length(name) != 1L) { @@ -266,3 +279,72 @@ cog_gov_search <- function(name = NULL, state = NULL, type = NULL) { candidates = matches ) } + +# Orchestrates basket-mode resolution: validate, per-row resolve, +# assemble the basket tibble + sidecar, attach the sidecar as an attr. +# Caller is responsible for emitting any post-resolution summary message +# (see Task 7 — this stays silent for now). +#' @noRd +.resolve_basket <- function(name, state, type, con) { + args <- .validate_basket_args(name = name, state = state, type = type) + n <- length(args$name) + + resolved <- vector("list", n) + for (i in seq_len(n)) { + resolved[[i]] <- .resolve_basket_row( + name = args$name[i], + state = args$state[i], + type = args$type[i], + con = con + ) + } + + basket_rows <- lapply(resolved, function(r) r$row) + basket <- dplyr::bind_rows(basket_rows[vapply(basket_rows, function(r) nrow(r) > 0L, logical(1))]) + if (nrow(basket) == 0L) basket <- .empty_xwalk_tibble() + + sidecar <- .build_sidecar(args, resolved) + attr(basket, "resolution") <- sidecar + basket +} + +# Build the sidecar tibble. One row per input; carries query_*, status, +# match_method, canonical_govid, gov_name, n_candidates, and a list-col +# `candidates` of full-schema match-candidate tibbles. +#' @noRd +.build_sidecar <- function(args, resolved) { + type_label <- unname(vapply(args$type, function(t) { + if (is.na(t)) NA_character_ else .type_to_label(t) + }, character(1))) + + status <- vapply(resolved, `[[`, character(1), "status") + method <- vapply(resolved, `[[`, character(1), "match_method") + ncand <- vapply(resolved, `[[`, integer(1), "n_candidates") + govid <- vapply(resolved, function(r) { + if (nrow(r$row) == 0L) NA_character_ else r$row$canonical_govid[1L] + }, character(1)) + gname <- vapply(resolved, function(r) { + if (nrow(r$row) == 0L) NA_character_ else r$row$gov_name[1L] + }, character(1)) + cands <- lapply(resolved, `[[`, "candidates") + + tibble::tibble( + query_name = args$name, + query_state = args$state, + query_type = type_label, + status = status, + match_method = method, + canonical_govid = govid, + gov_name = gname, + n_candidates = ncand, + candidates = cands + ) +} + +# Convert a type input (integer-like or label) into the canonical label +# string used in the sidecar query_type column. +#' @noRd +.type_to_label <- function(type) { + int_type <- .coerce_type(type) + c("0" = "state", "1" = "county", "2" = "city", "3" = "township")[[as.character(int_type)]] +} diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index 2c7b843..f8f9ca2 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -243,3 +243,100 @@ test_that(".resolve_basket_row resolves with type override on ambiguous case", { expect_equal(out$match_method, "substring") expect_equal(out$row$canonical_govid, "052037010") }) + +# ---- basket mode public surface ---- + +test_that("cog_gov_search basket mode resolves clean inputs in input order", { + skip_if_no_corpus() + basket <- cog_gov_search( + name = c("BROWARD COUNTY", "SAN DIEGO CITY", "AUSTIN CITY"), + state = c("FL", "CA", "TX") + ) + expect_s3_class(basket, "tbl_df") + expect_equal(nrow(basket), 3L) + expect_equal(basket$canonical_govid, c("101006006", "052037010", "442227001")) + expect_equal(basket$gov_name, c("BROWARD COUNTY", "SAN DIEGO CITY", "AUSTIN CITY")) +}) + +test_that("cog_gov_search basket mode attaches a resolution sidecar", { + skip_if_no_corpus() + basket <- cog_gov_search( + name = c("Broward", "San Diego"), + state = c("FL", "CA"), + type = c(NA, "city") + ) + res <- attr(basket, "resolution") + expect_s3_class(res, "tbl_df") + expect_equal(nrow(res), 2L) + expect_equal(res$query_name, c("Broward", "San Diego")) + expect_equal(res$query_state, c("FL", "CA")) + expect_equal(res$query_type, c(NA_character_, "city")) + expect_equal(res$status, c("resolved", "resolved")) + expect_equal(res$match_method, c("substring", "substring")) + expect_true(is.list(res$candidates)) +}) + +test_that("cog_gov_search basket mode skips ambiguous and no_match rows", { + skip_if_no_corpus() + basket <- suppressMessages(cog_gov_search( + name = c("Broward", "San Diego", "Notarealplace"), + state = c("FL", "CA", "NY") + )) + # Broward resolves; San Diego ambiguous; Notarealplace no_match. + expect_equal(nrow(basket), 1L) + expect_equal(basket$canonical_govid, "101006006") + res <- attr(basket, "resolution") + expect_equal(nrow(res), 3L) + expect_equal(res$status, c("resolved", "ambiguous", "no_match")) +}) + +test_that("cog_gov_search basket mode preserves input order", { + skip_if_no_corpus() + basket <- cog_gov_search( + name = c("AUSTIN CITY", "BROWARD COUNTY", "SAN DIEGO CITY"), + state = c("TX", "FL", "CA") + ) + expect_equal(basket$gov_name, c("AUSTIN CITY", "BROWARD COUNTY", "SAN DIEGO CITY")) +}) + +test_that("cog_gov_search basket mode recycles single state", { + skip_if_no_corpus() + basket <- cog_gov_search( + name = c("SAN DIEGO CITY", "OAKLAND CITY"), + state = "CA" + ) + expect_equal(nrow(basket), 2L) + expect_equal(basket$canonical_govid, c("052037010", "052001009")) +}) + +test_that("cog_gov_search basket mode within-type largest_pop records candidates", { + skip_if_no_corpus() + basket <- suppressMessages(cog_gov_search( + name = c("Miami", "OAKLAND CITY"), + state = c("FL", "CA") + )) + expect_equal(nrow(basket), 2L) + res <- attr(basket, "resolution") + miami_row <- res[res$query_name == "Miami", ] + expect_equal(miami_row$status, "largest_pop") + expect_equal(miami_row$canonical_govid, "102013013") + expect_gte(miami_row$n_candidates, 2L) + expect_gte(nrow(miami_row$candidates[[1]]), 2L) +}) + +test_that("cog_gov_search utility mode (length-1 name) has no sidecar", { + skip_if_no_corpus() + r <- cog_gov_search("BROWARD") + expect_null(attr(r, "resolution")) + expect_gte(nrow(r), 1L) +}) + +test_that("cog_gov_search basket mode validates argument lengths", { + expect_error( + cog_gov_search( + name = c("Broward", "San Diego", "Austin"), + state = c("FL", "CA") + ), + regexp = "must be length 1 or 3" + ) +})