Skip to content

Commit 638de98

Browse files
committed
Add schema-driven content validation: database registry, column enums, cross-file keys
Extends check_worksheet() with three declarative rules, setting the stage for progressively richer schema validation (full column schema deferred to the v4 refactor, where this merges into the recodeflow schema layer): - Database-token registry (inst/metadata/schemas/core/database_registry.yaml, 32 valid identifiers): every databaseStart token is validated against the registry, so typo identifiers fail at PR time instead of becoming silent dead rows. Adding a new cycle now requires a deliberate, reviewable registry entry. - Controlled vocabularies (column_enums in the worksheet schemas): typeStart (cat/cont/N/A) and typeEnd (cat/cont) in variable_details, variableType (Categorical/Continuous) in variables. - Cross-file key integrity (check_cross_file_keys()): every variable_details entry must have a variables.csv row. Registry naming follows the recodeflow database-metadata draft for a clean v4 merge. Fixes one pre-existing worksheet cell surfaced by the enum rule (HWTGCOR_der Func:: row had an empty typeStart; now N/A per convention) and updates test fixtures that used never-valid values (variableType 'cont', bare 'cchs' and invented 'cchs2013_p' tokens). Full checker green on the worksheets (all five rule groups); suite at 836 passing, 0 failures. Relates to CEP-017 Track 2 and CEP-018.
1 parent 1406aff commit 638de98

15 files changed

Lines changed: 574 additions & 26 deletions

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export(categorize_diet_score)
7575
export(categorize_energy_exp)
7676
export(categorize_immigration)
7777
export(categorize_pct_time)
78+
export(check_cross_file_keys)
7879
export(check_recode_blocks)
7980
export(check_worksheet)
8081
export(clean_variables)
@@ -109,6 +110,7 @@ export(has_cached_pattern)
109110
export(if_else2)
110111
export(is_equal)
111112
export(list_subjects)
113+
export(load_database_registry)
112114
export(load_schema)
113115
export(load_worksheet_metadata)
114116
export(load_worksheet_schemas)

R/check-worksheet.R

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,18 +124,144 @@ check_worksheet <- function(
124124
excessive_quote_errors <- .check_excessive_quoting(
125125
raw_lines, list(file_path = file_path, file_type = file_type))
126126

127+
# Content checks driven by the schema: controlled vocabularies and the
128+
# database-token registry (see inst/metadata/schemas/core/)
129+
enum_errors <- if (!is.null(schema$column_enums)) {
130+
.check_column_enums(
131+
csv_result$data, schema$column_enums,
132+
list(file_path = file_path, file_type = file_type)
133+
)
134+
} else {
135+
list()
136+
}
137+
138+
database_token_errors <- if (!is.null(schema$database_registry_file)) {
139+
.check_database_tokens(
140+
csv_result$data,
141+
load_database_registry(schema$database_registry_file),
142+
list(file_path = file_path, file_type = file_type)
143+
)
144+
} else {
145+
list()
146+
}
147+
127148
all_errors <- purrr::flatten(list(
128149
line_ending_errors,
129150
excessive_quote_errors,
130151
column_order_errors,
131152
row_sorting_errors,
132153
empty_column_errors,
133-
extra_column_errors
154+
extra_column_errors,
155+
enum_errors,
156+
database_token_errors
134157
))
135158

136159
return(all_errors)
137160
}
138161

162+
#' Check controlled-vocabulary columns against schema enums
163+
#'
164+
#' For every column declared under `column_enums` in the worksheet schema,
165+
#' flags cell values outside the declared vocabulary. Empty cells are
166+
#' violations too: fields that do not apply must carry the explicit "N/A"
167+
#' marker where the vocabulary includes it.
168+
#'
169+
#' @param csv_data Data frame of the worksheet
170+
#' @param column_enums Named list: column name -> character vector of
171+
#' allowed values (from the schema YAML)
172+
#' @param error_ctx Named list with file_type and file_path
173+
#'
174+
#' @return List of enum violation errors (one per distinct offending value
175+
#' per column, with the affected row numbers)
176+
.check_column_enums <- function(csv_data, column_enums, error_ctx) {
177+
errors <- list()
178+
179+
for (column_name in names(column_enums)) {
180+
if (!column_name %in% colnames(csv_data)) next
181+
allowed <- as.character(column_enums[[column_name]])
182+
values <- trimws(as.character(csv_data[[column_name]]))
183+
values[is.na(values)] <- ""
184+
185+
bad <- !(values %in% allowed)
186+
if (!any(bad)) next
187+
188+
for (offending in unique(values[bad])) {
189+
rows <- which(bad & values == offending) + 1 # +1 for the header line
190+
shown <- paste(utils::head(rows, 5), collapse = ", ")
191+
if (length(rows) > 5) shown <- paste0(shown, ", ...")
192+
errors[[length(errors) + 1]] <- list(
193+
error_type = "invalid_enum_value",
194+
file_type = error_ctx$file_type,
195+
file_path = error_ctx$file_path,
196+
column_name = column_name,
197+
value = offending,
198+
row_nums = rows,
199+
message = glue::glue(
200+
"Error in {.pretty_print_file_type(error_ctx$file_type)} at ",
201+
"{error_ctx$file_path}. Column \"{column_name}\" has value ",
202+
"\"{offending}\" outside its vocabulary ",
203+
"({paste(allowed, collapse = ', ')}) on line(s) {shown}."
204+
)
205+
)
206+
}
207+
}
208+
209+
errors
210+
}
211+
212+
#' Check databaseStart tokens against the database registry
213+
#'
214+
#' Splits every databaseStart cell on commas and flags tokens that are not
215+
#' in the registry of valid CCHS database identifiers. This catches typo
216+
#' identifiers (e.g. a missing underscore) that would otherwise become
217+
#' silent dead rows, because the engine matches databases by string.
218+
#'
219+
#' @param csv_data Data frame of the worksheet
220+
#' @param valid_databases Character vector from load_database_registry()
221+
#' @param error_ctx Named list with file_type and file_path
222+
#'
223+
#' @return List of invalid-token errors (one per distinct bad token, with
224+
#' the affected row numbers)
225+
.check_database_tokens <- function(csv_data, valid_databases, error_ctx) {
226+
if (!"databaseStart" %in% colnames(csv_data)) return(list())
227+
228+
cells <- as.character(csv_data$databaseStart)
229+
errors <- list()
230+
bad_rows <- list()
231+
232+
for (i in seq_along(cells)) {
233+
cell <- cells[i]
234+
if (is.na(cell) || trimws(cell) %in% c("", "N/A")) next
235+
tokens <- trimws(unlist(strsplit(cell, ",", fixed = TRUE)))
236+
tokens <- tokens[nzchar(tokens)]
237+
for (token in tokens[!(tokens %in% valid_databases)]) {
238+
bad_rows[[token]] <- c(bad_rows[[token]], i + 1) # +1 for header line
239+
}
240+
}
241+
242+
for (token in names(bad_rows)) {
243+
rows <- unique(bad_rows[[token]])
244+
shown <- paste(utils::head(rows, 5), collapse = ", ")
245+
if (length(rows) > 5) shown <- paste0(shown, ", ...")
246+
errors[[length(errors) + 1]] <- list(
247+
error_type = "invalid_database_token",
248+
file_type = error_ctx$file_type,
249+
file_path = error_ctx$file_path,
250+
token = token,
251+
row_nums = rows,
252+
message = glue::glue(
253+
"Error in {.pretty_print_file_type(error_ctx$file_type)} at ",
254+
"{error_ctx$file_path}. databaseStart token \"{token}\" is not in ",
255+
"the database registry ",
256+
"(inst/metadata/schemas/core/database_registry.yaml) on line(s) ",
257+
"{shown}. Fix the token, or add the new database to the registry."
258+
)
259+
)
260+
}
261+
262+
errors
263+
}
264+
139265
#' Check whether a worksheet has the correct line endings
140266
#'
141267
#' Uses vectorised grep on raw lines for performance. The raw file is read
@@ -664,3 +790,78 @@ check_recode_blocks <- function(file_path) {
664790
return("Variable details sheet")
665791
}
666792
}
793+
794+
#' Check cross-file key integrity between the two worksheets
795+
#'
796+
#' Verifies that every variable in variable_details.csv has a corresponding
797+
#' row in variables.csv (the foreign-key relationship between the two
798+
#' worksheets). A variable_details entry without a variables.csv row has no
799+
#' harmonized-variable metadata (labels, subject, type) and indicates either
800+
#' a missing variables.csv row or a typo in the variable name.
801+
#'
802+
#' @param variables_path Path to variables.csv
803+
#' @param variable_details_path Path to variable_details.csv
804+
#'
805+
#' @return A list of errors found. Each error is a named list with
806+
#' error_type "orphaned_variable_details" and the affected variable name.
807+
#'
808+
#' @export
809+
#'
810+
#' @examples
811+
#' \dontrun{
812+
#' check_cross_file_keys(
813+
#' "inst/extdata/variables.csv",
814+
#' "inst/extdata/variable_details.csv"
815+
#' )
816+
#' }
817+
check_cross_file_keys <- function(variables_path, variable_details_path) {
818+
for (p in c(variables_path, variable_details_path)) {
819+
if (!file.exists(p)) {
820+
file_type <- if (identical(p, variables_path)) "variables" else "variable_details"
821+
return(list(.create_file_not_found_error(file_type, p)))
822+
}
823+
}
824+
825+
vs <- tryCatch(
826+
read.csv(variables_path, stringsAsFactors = FALSE, check.names = FALSE),
827+
error = function(e) NULL
828+
)
829+
vd <- tryCatch(
830+
read.csv(variable_details_path, stringsAsFactors = FALSE,
831+
check.names = FALSE),
832+
error = function(e) NULL
833+
)
834+
if (is.null(vs)) {
835+
return(list(.create_invalid_csv_error("variables", variables_path,
836+
"Unable to parse CSV")))
837+
}
838+
if (is.null(vd)) {
839+
return(list(.create_invalid_csv_error(
840+
"variable_details", variable_details_path, "Unable to parse CSV")))
841+
}
842+
if (!"variable" %in% names(vs) || !"variable" %in% names(vd)) {
843+
return(list()) # column-order checks report the structural problem
844+
}
845+
846+
known <- unique(trimws(vs$variable))
847+
vd_vars <- trimws(vd$variable)
848+
orphaned <- sort(unique(vd_vars[!(vd_vars %in% known) & nzchar(vd_vars)]))
849+
850+
purrr::map(orphaned, function(v) {
851+
rows <- which(vd_vars == v) + 1
852+
shown <- paste(utils::head(rows, 5), collapse = ", ")
853+
if (length(rows) > 5) shown <- paste0(shown, ", ...")
854+
list(
855+
error_type = "orphaned_variable_details",
856+
file_type = "variable_details",
857+
file_path = variable_details_path,
858+
variable = v,
859+
row_nums = rows,
860+
message = glue::glue(
861+
"Variable \"{v}\" has rows in variable_details.csv (line(s) ",
862+
"{shown}) but no row in variables.csv. Add the variables.csv row ",
863+
"or fix the variable name."
864+
)
865+
)
866+
})
867+
}

R/load-schema.R

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,45 @@ load_schema <- function(file_type) {
4141
}
4242
)
4343
}
44+
45+
#' Load the database-token registry
46+
#'
47+
#' @description Loads the registry of valid CCHS database identifiers used to
48+
#' validate databaseStart tokens in the worksheets. The registry file name
49+
#' comes from the worksheet schema's `database_registry_file` key.
50+
#'
51+
#' @param registry_file File name of the registry YAML (default
52+
#' "database_registry.yaml"), resolved inside the package's
53+
#' metadata/schemas/core directory.
54+
#'
55+
#' @return Character vector of valid database identifiers.
56+
#'
57+
#' @export
58+
#'
59+
#' @examples
60+
#' \dontrun{
61+
#' load_database_registry()
62+
#' }
63+
load_database_registry <- function(registry_file = "database_registry.yaml") {
64+
registry_path <- system.file(
65+
"metadata", "schemas", "core", registry_file,
66+
package = "cchsflow",
67+
mustWork = TRUE
68+
)
69+
70+
registry <- tryCatch(
71+
yaml::read_yaml(registry_path),
72+
error = function(e) {
73+
stop("Failed to load database registry. The file at ", registry_path,
74+
" may be corrupted: ", e$message)
75+
}
76+
)
77+
78+
if (is.null(registry$valid_databases) ||
79+
length(registry$valid_databases) == 0) {
80+
stop("Database registry at ", registry_path,
81+
" has no 'valid_databases' entries.")
82+
}
83+
84+
as.character(registry$valid_databases)
85+
}

exec/check-worksheets.R

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,20 @@ if (n_recode_block_errors > 0) {
7272
}
7373
cli_text("")
7474

75+
cli_alert_info("Checking cross-file key integrity...")
76+
cross_file_errors <- check_cross_file_keys(
77+
scope$variables_path, scope$variable_details_path)
78+
n_cross_file_errors <- length(cross_file_errors)
79+
if (n_cross_file_errors > 0) {
80+
cli_alert_danger("Found {cli::no(n_cross_file_errors)} error{?s}")
81+
} else {
82+
cli_alert_success("Found {cli::no(n_cross_file_errors)} error{?s}")
83+
}
84+
cli_text("")
85+
7586
all_errors <- purrr::flatten(
76-
list(variables_sheet_errors, variable_details_errors, recode_block_errors))
87+
list(variables_sheet_errors, variable_details_errors, recode_block_errors,
88+
cross_file_errors))
7789

7890
# Report results
7991
n_all_errors <- length(all_errors)

inst/extdata/variable_details.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1637,7 +1637,7 @@ HWTGCOR,N/A,cont,"cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p",[HWTDGCOR],
16371637
HWTGCOR,N/A,cont,"cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p",[HWTDGCOR],N/A,NA::a,N/A,not applicable,not applicable,kg/m2,999.96,Not applicable,Adjusted BMI,Adjusted BMI,
16381638
HWTGCOR,N/A,cont,"cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p",[HWTDGCOR],N/A,NA::b,N/A,missing,missing,kg/m2,"[999.97,999.99]",don't know (999.97); refusal (999.98); not stated (999.99),Adjusted BMI,Adjusted BMI,
16391639
HWTGCOR,N/A,cont,"cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p",[HWTDGCOR],N/A,NA::b,N/A,missing,missing,kg/m2,else,else,Adjusted BMI,Adjusted BMI,
1640-
HWTGCOR_der,N/A,cont,"cchs2001_p, cchs2003_p, cchs2005_p, cchs2007_2008_p, cchs2009_2010_p, cchs2010_p, cchs2011_2012_p, cchs2012_p, cchs2013_2014_p, cchs2014_p, cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p","DerivedVar::[DHH_SEX, HWTGHTM, HWTGWTK]",,Func::adjust_bmi,N/A,N/A,N/A,kg/m2,"[15,50]",Adjusted BMI valid range,Derived adjusted BMI,Derived adjusted BMI,
1640+
HWTGCOR_der,N/A,cont,"cchs2001_p, cchs2003_p, cchs2005_p, cchs2007_2008_p, cchs2009_2010_p, cchs2010_p, cchs2011_2012_p, cchs2012_p, cchs2013_2014_p, cchs2014_p, cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p","DerivedVar::[DHH_SEX, HWTGHTM, HWTGWTK]",N/A,Func::adjust_bmi,N/A,N/A,N/A,kg/m2,"[15,50]",Adjusted BMI valid range,Derived adjusted BMI,Derived adjusted BMI,
16411641
HWTGCOR_der,N/A,cont,"cchs2001_p, cchs2003_p, cchs2005_p, cchs2007_2008_p, cchs2009_2010_p, cchs2010_p, cchs2011_2012_p, cchs2012_p, cchs2013_2014_p, cchs2014_p, cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p","DerivedVar::[DHH_SEX, HWTGHTM, HWTGWTK]",N/A,NA::a,N/A,not applicable,not applicable,kg/m2,N/A,not applicable,Derived adjusted BMI,Derived adjusted BMI,
16421642
HWTGCOR_der,N/A,cont,"cchs2001_p, cchs2003_p, cchs2005_p, cchs2007_2008_p, cchs2009_2010_p, cchs2010_p, cchs2011_2012_p, cchs2012_p, cchs2013_2014_p, cchs2014_p, cchs2015_2016_p, cchs2017_2018_p, cchs2019_2020_p","DerivedVar::[DHH_SEX, HWTGHTM, HWTGWTK]",N/A,NA::b,N/A,missing,missing,kg/m2,N/A,missing,Derived adjusted BMI,Derived adjusted BMI,
16431643
HWTGHTM,N/A,cont,"cchs2001_p, cchs2003_p","cchs2001_p::HWTAGHT, cchs2003_p::HWTCGHT",cat,1.118,N/A,Height,converted height (3'8 IN - 44 inches),meters,1,3'8 IN - 44 inches,Height,"Height (metres)/self-reported - (D,G)",
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
schema_version: "1.0.0"
2+
schema_date: "2026-07-15"
3+
description: >
4+
Registry of valid CCHS database identifiers for databaseStart tokens in
5+
variables.csv and variable_details.csv. check_worksheet() validates every
6+
databaseStart token against this list, so typos (e.g. a missing underscore)
7+
fail at PR time instead of becoming silent dead rows. Adding support for a
8+
new cycle requires adding its identifier here - a deliberate, reviewable act.
9+
Naming follows the recodeflow database-metadata draft
10+
(scope-docs/metadata-schema/database_metadata.yaml) so this registry can
11+
merge into the recodeflow schema layer at the v4 refactor.
12+
13+
# Canonical token pattern: cchs<YYYY>[_<YYYY>]_<p|m>
14+
# _p = Public Use Microdata File (PUMF)
15+
# _m = Master file (includes converted share/_s and ICES/_i identifiers)
16+
valid_databases:
17+
- "cchs2001_p"
18+
- "cchs2001_m"
19+
- "cchs2003_p"
20+
- "cchs2003_m"
21+
- "cchs2005_p"
22+
- "cchs2005_m"
23+
- "cchs2007_2008_p"
24+
- "cchs2007_2008_m"
25+
- "cchs2009_2010_p"
26+
- "cchs2009_2010_m"
27+
- "cchs2009_m"
28+
- "cchs2010_p"
29+
- "cchs2010_m"
30+
- "cchs2011_2012_p"
31+
- "cchs2011_2012_m"
32+
- "cchs2012_p"
33+
- "cchs2012_m"
34+
- "cchs2013_2014_p"
35+
- "cchs2013_2014_m"
36+
- "cchs2014_p"
37+
- "cchs2014_m"
38+
- "cchs2015_2016_p"
39+
- "cchs2015_2016_m"
40+
- "cchs2017_2018_p"
41+
- "cchs2017_2018_m"
42+
- "cchs2019_2020_p"
43+
- "cchs2019_2020_m"
44+
- "cchs2021_m"
45+
- "cchs2022_p"
46+
- "cchs2022_m"
47+
- "cchs2023_p"
48+
- "cchs2023_m"

inst/metadata/schemas/core/variable_details.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,18 @@ expected_column_order:
1717
- "notes"
1818

1919
id_column_name: "variable"
20+
21+
# Controlled vocabularies - check_worksheet() flags any value outside these
22+
# lists. "N/A" marks rows where the field does not apply (DerivedVar and
23+
# Func:: rows). Extend deliberately.
24+
column_enums:
25+
typeStart:
26+
- "cat"
27+
- "cont"
28+
- "N/A"
29+
typeEnd:
30+
- "cat"
31+
- "cont"
32+
33+
# Registry of valid databaseStart tokens (shared with variables)
34+
database_registry_file: "database_registry.yaml"

inst/metadata/schemas/core/variables.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,13 @@ expected_column_order:
1111
- "description"
1212

1313
id_column_name: "variable"
14+
15+
# Controlled vocabularies - check_worksheet() flags any value outside these
16+
# lists. Extend deliberately; keep in sync with worksheet conventions.
17+
column_enums:
18+
variableType:
19+
- "Categorical"
20+
- "Continuous"
21+
22+
# Registry of valid databaseStart tokens (shared with variable_details)
23+
database_registry_file: "database_registry.yaml"

0 commit comments

Comments
 (0)