diff --git a/R/revenue.R b/R/revenue.R index 583752c..6e9aa5f 100644 --- a/R/revenue.R +++ b/R/revenue.R @@ -8,6 +8,16 @@ #' multiplies by 1000 and records the conversion in `provenance`). #' #' @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 #' return. Concepts are defined as sets of the crosswalk's `revenue_subtype` #' values -- never as item-code first letters, which cannot classify diff --git a/R/spending.R b/R/spending.R index 6aa82ec..9086e64 100644 --- a/R/spending.R +++ b/R/spending.R @@ -71,7 +71,15 @@ #' @param govid Character vector of `canonical_govid` values. #' @param years Integer vector of years. #' @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 #' `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 @@ -268,6 +276,17 @@ cog_spending <- function(govid, years, category = NULL, .validate_verb_inputs(govid, years, category, per_capita, adjust_to_year, 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")) { cli::cli_abort(c( "`recipe` and `expenditure_concept = \"total\"` are mutually exclusive.", @@ -341,8 +360,10 @@ cog_spending <- function(govid, years, category = NULL, } else { NULL } - sql <- .build_verb_sql(view, subtype_col, govid, years, category, ig_view, - subtype_scope) + sql <- .build_verb_sql(view, subtype_col, govid, years, + if (all_categories) NULL else category, + ig_view, subtype_scope, + all_categories = all_categories) result <- tibble::as_tibble(DBI::dbGetQuery(con, sql)) } diff --git a/man/cog_revenue.Rd b/man/cog_revenue.Rd index 753dede..de677dd 100644 --- a/man/cog_revenue.Rd +++ b/man/cog_revenue.Rd @@ -22,7 +22,15 @@ cog_revenue( \item{years}{Integer vector of years.} \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 `amt_per_capita_real` when `adjust_to_year` is set) using the per-year diff --git a/man/cog_spending.Rd b/man/cog_spending.Rd index 7a958b9..e0dedd2 100644 --- a/man/cog_spending.Rd +++ b/man/cog_spending.Rd @@ -22,7 +22,15 @@ cog_spending( \item{years}{Integer vector of years.} \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 `amt_per_capita_real` when `adjust_to_year` is set) using the per-year diff --git a/tests/testthat/test-all-categories.R b/tests/testthat/test-all-categories.R index d19dd3c..6b9f6ec 100644 --- a/tests/testthat/test-all-categories.R +++ b/tests/testthat/test-all-categories.R @@ -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", { 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) +})