@@ -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+ }
0 commit comments