feat: accept category = 'All Categories' on cog_spending/cog_revenue

Returns one summed row per (year, govid, subtype) across every
category in the requested concept's subtype scope, so a caller never
sums categories client-side and cannot sum the wrong scope.

Combining it with other category names is an error rather than a
silent partial sum.
This commit is contained in:
2026-08-05 11:32:47 -04:00
parent 5e22e940e7
commit 11ae99c382
5 changed files with 111 additions and 5 deletions
+10
View File
@@ -8,6 +8,16 @@
#' multiplies by 1000 and records the conversion in `provenance`). #' multiplies by 1000 and records the conversion in `provenance`).
#' #'
#' @inheritParams cog_spending #' @inheritParams cog_spending
#' @param category Character vector of category names (from
#' `summary_categories.category`), or `NULL` for all categories broken out
#' one row each. The reserved value `"All Categories"` instead returns a
#' single summed row per `(year, canonical_govid, subtype)`, covering every
#' category inside the requested concept's subtype scope. It cannot be
#' combined with other category names, and it is not the same thing as
#' `revenue_concept = "total"`: the concept chooses which subtypes are in
#' scope, `"All Categories"` chooses whether rows inside that scope are
#' broken out or summed. Combine with `subtype = "operations"` for an
#' own-source revenue total.
#' @param revenue_concept Which of Census's two published revenue concepts to #' @param revenue_concept Which of Census's two published revenue concepts to
#' return. Concepts are defined as sets of the crosswalk's `revenue_subtype` #' return. Concepts are defined as sets of the crosswalk's `revenue_subtype`
#' values -- never as item-code first letters, which cannot classify #' values -- never as item-code first letters, which cannot classify
+24 -3
View File
@@ -71,7 +71,15 @@
#' @param govid Character vector of `canonical_govid` values. #' @param govid Character vector of `canonical_govid` values.
#' @param years Integer vector of years. #' @param years Integer vector of years.
#' @param category Character vector of category names (from #' @param category Character vector of category names (from
#' `summary_categories.category`), or `NULL` for all categories. #' `summary_categories.category`), or `NULL` for all categories broken out
#' one row each. The reserved value `"All Categories"` instead returns a
#' single summed row per `(year, canonical_govid, subtype)`, covering every
#' category inside the requested concept's subtype scope. It cannot be
#' combined with other category names, and it is not the same thing as
#' `expenditure_concept = "total"`: the concept chooses which subtypes are in
#' scope, `"All Categories"` chooses whether rows inside that scope are
#' broken out or summed. Combine with `subtype = "operations"` for an
#' operating-expenditure total.
#' @param per_capita If `TRUE`, adds `amt_per_capita_nominal` (and #' @param per_capita If `TRUE`, adds `amt_per_capita_nominal` (and
#' `amt_per_capita_real` when `adjust_to_year` is set) using the per-year #' `amt_per_capita_real` when `adjust_to_year` is set) using the per-year
#' Census F-33 population from `gov_population_yearly`. Result also gains #' Census F-33 population from `gov_population_yearly`. Result also gains
@@ -268,6 +276,17 @@ cog_spending <- function(govid, years, category = NULL,
.validate_verb_inputs(govid, years, category, per_capita, adjust_to_year, .validate_verb_inputs(govid, years, category, per_capita, adjust_to_year,
recipe) recipe)
# Recognize the reserved pseudo-category. Detected after type validation so a
# non-character `category` still fails with the ordinary type error.
all_categories <- !is.null(category) && .ALL_CATEGORIES %in% category
if (all_categories && length(category) > 1L) {
cli::cli_abort(c(
"{.val {(.ALL_CATEGORIES)}} cannot be combined with other categories.",
"i" = "It already sums every category in the requested concept's scope.",
"*" = "Ask for it alone, or list the specific categories you want."
), class = "uscogdata_all_categories_not_combinable")
}
if (!is.null(recipe) && identical(expenditure_concept, "total")) { if (!is.null(recipe) && identical(expenditure_concept, "total")) {
cli::cli_abort(c( cli::cli_abort(c(
"`recipe` and `expenditure_concept = \"total\"` are mutually exclusive.", "`recipe` and `expenditure_concept = \"total\"` are mutually exclusive.",
@@ -341,8 +360,10 @@ cog_spending <- function(govid, years, category = NULL,
} else { } else {
NULL NULL
} }
sql <- .build_verb_sql(view, subtype_col, govid, years, category, ig_view, sql <- .build_verb_sql(view, subtype_col, govid, years,
subtype_scope) if (all_categories) NULL else category,
ig_view, subtype_scope,
all_categories = all_categories)
result <- tibble::as_tibble(DBI::dbGetQuery(con, sql)) result <- tibble::as_tibble(DBI::dbGetQuery(con, sql))
} }
+9 -1
View File
@@ -22,7 +22,15 @@ cog_revenue(
\item{years}{Integer vector of years.} \item{years}{Integer vector of years.}
\item{category}{Character vector of category names (from \item{category}{Character vector of category names (from
`summary_categories.category`), or `NULL` for all categories.} `summary_categories.category`), or `NULL` for all categories broken out
one row each. The reserved value `"All Categories"` instead returns a
single summed row per `(year, canonical_govid, subtype)`, covering every
category inside the requested concept's subtype scope. It cannot be
combined with other category names, and it is not the same thing as
`revenue_concept = "total"`: the concept chooses which subtypes are in
scope, `"All Categories"` chooses whether rows inside that scope are
broken out or summed. Combine with `subtype = "operations"` for an
own-source revenue total.}
\item{per_capita}{If `TRUE`, adds `amt_per_capita_nominal` (and \item{per_capita}{If `TRUE`, adds `amt_per_capita_nominal` (and
`amt_per_capita_real` when `adjust_to_year` is set) using the per-year `amt_per_capita_real` when `adjust_to_year` is set) using the per-year
+9 -1
View File
@@ -22,7 +22,15 @@ cog_spending(
\item{years}{Integer vector of years.} \item{years}{Integer vector of years.}
\item{category}{Character vector of category names (from \item{category}{Character vector of category names (from
`summary_categories.category`), or `NULL` for all categories.} `summary_categories.category`), or `NULL` for all categories broken out
one row each. The reserved value `"All Categories"` instead returns a
single summed row per `(year, canonical_govid, subtype)`, covering every
category inside the requested concept's subtype scope. It cannot be
combined with other category names, and it is not the same thing as
`expenditure_concept = "total"`: the concept chooses which subtypes are in
scope, `"All Categories"` chooses whether rows inside that scope are
broken out or summed. Combine with `subtype = "operations"` for an
operating-expenditure total.}
\item{per_capita}{If `TRUE`, adds `amt_per_capita_nominal` (and \item{per_capita}{If `TRUE`, adds `amt_per_capita_nominal` (and
`amt_per_capita_real` when `adjust_to_year` is set) using the per-year `amt_per_capita_real` when `adjust_to_year` is set) using the per-year
+59
View File
@@ -37,3 +37,62 @@ test_that(".build_verb_sql is unchanged when all_categories is FALSE", {
test_that(".ALL_CATEGORIES is the exact reserved string", { test_that(".ALL_CATEGORIES is the exact reserved string", {
expect_identical(uscogdata:::.ALL_CATEGORIES, "All Categories") expect_identical(uscogdata:::.ALL_CATEGORIES, "All Categories")
}) })
test_that('cog_spending(category = "All Categories") sums to the per-category total', {
gov <- "552025209777"
by_cat <- cog_spending(gov, 2019L)
total <- cog_spending(gov, 2019L, category = "All Categories")
expect_true(nrow(total) > 0L)
expect_setequal(unique(total$category), "All Categories")
# one row per subtype present in the by-category result
expect_setequal(unique(total$spend_subtype), unique(by_cat$spend_subtype))
expect_equal(nrow(total), length(unique(by_cat$spend_subtype)))
# the dollars agree, per subtype
lhs <- tapply(by_cat$amt_nominal, by_cat$spend_subtype, sum)
rhs <- tapply(total$amt_nominal, total$spend_subtype, sum)
expect_equal(as.numeric(rhs[names(lhs)]), as.numeric(lhs), tolerance = 1e-8)
})
test_that('"All Categories" respects expenditure_concept', {
gov <- "552025209777"
prim <- cog_spending(gov, 2019L, category = "All Categories",
expenditure_concept = "primary")
dir <- cog_spending(gov, 2019L, category = "All Categories",
expenditure_concept = "direct")
# direct = primary plus interest and insurance benefits, so it is never smaller
expect_gte(sum(dir$amt_nominal), sum(prim$amt_nominal))
})
test_that('"All Categories" works on revenue and respects revenue_concept', {
gov <- "552025209777"
gen <- cog_revenue(gov, 2019L, category = "All Categories",
revenue_concept = "general")
tot <- cog_revenue(gov, 2019L, category = "All Categories",
revenue_concept = "total")
expect_setequal(unique(gen$category), "All Categories")
expect_gte(sum(tot$amt_nominal), sum(gen$amt_nominal))
})
test_that('"All Categories" cannot be combined with another category', {
expect_error(
cog_spending("552025209777", 2019L, category = c("All Categories", "Police")),
class = "uscogdata_all_categories_not_combinable"
)
})
test_that('"All Categories" is recorded in provenance', {
r <- cog_spending("552025209777", 2019L, category = "All Categories")
expect_identical(cog_explain(r, format = "list")$category, "All Categories")
})
test_that('"All Categories" combines with subtype to give operating totals', {
gov <- "552025209777"
ops_by_cat <- cog_spending(gov, 2019L)
ops_by_cat <- ops_by_cat[ops_by_cat$spend_subtype == "operations", ]
ops_total <- cog_spending(gov, 2019L, category = "All Categories")
ops_total <- ops_total[ops_total$spend_subtype == "operations", ]
expect_equal(sum(ops_total$amt_nominal), sum(ops_by_cat$amt_nominal),
tolerance = 1e-8)
})