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:
+10
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user