Skip to content

Commit c2af5e5

Browse files
committed
Add reader for study details
1 parent 327b0fc commit c2af5e5

5 files changed

Lines changed: 370 additions & 4 deletions

File tree

R/spec_builder.R

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ spec_to_metacore <- function(path, quiet = deprecated(), where_sep_sheet = TRUE,
7272
codelist <- spec_type_to_codelist(doc)
7373

7474
# Define.xml-only tables — skipped when define_fields = FALSE
75+
study_level <- if (define_fields) spec_type_to_study_level(doc) else NULL
7576
documents <- if (define_fields) spec_type_to_documents(doc) else NULL
7677
comments <- if (define_fields) spec_type_to_comments(doc) else NULL
7778
supp <- base_column_schema()$.supp
@@ -96,6 +97,7 @@ spec_to_metacore <- function(path, quiet = deprecated(), where_sep_sheet = TRUE,
9697
mc <- metacore(
9798
ds_spec, ds_vars, var_spec, value_spec, derivations, codelist,
9899
supp = supp,
100+
study_level = study_level,
99101
documents = documents,
100102
comments = comments,
101103
define_fields = define_fields,
@@ -1267,6 +1269,164 @@ spec_type_to_comments <- function(
12671269
reorder_by_schema("comments")
12681270
}
12691271

1272+
#' Spec to study_level
1273+
#'
1274+
#' `r lifecycle::badge("experimental")`
1275+
#' Builds the `study_level` table from a named list of Excel sheets produced by
1276+
#' [read_all_sheets()]. `study_level` holds **one row of study-wide metadata**
1277+
#' used when generating Define.xml 2.0 output.
1278+
#'
1279+
#' @details
1280+
#' ## How it works
1281+
#'
1282+
#' The function locates a sheet whose name matches `sheet` (typically `"Define"`
1283+
#' or `"Study"`). It then either **pivots** the sheet (the default) or reads
1284+
#' columns directly, depending on `pivot`.
1285+
#'
1286+
#' ## Pivoted format (`pivot = TRUE`, default)
1287+
#'
1288+
#' The sheet contains two columns: one whose heading matches `[Aa]ttribute`
1289+
#' (holding the field names) and one whose heading matches `[Vv]alue` (holding
1290+
#' the corresponding values). For example:
1291+
#'
1292+
#' | Attribute | Value |
1293+
#' |:-----------------|:----------------------|
1294+
#' | StudyName | CDISC Pilot |
1295+
#' | StudyDescription | Phase III efficacy... |
1296+
#' | ProtocolName | CDISCPILOT01 |
1297+
#'
1298+
#' The function pivots this into a single-row tibble with one column per
1299+
#' attribute, then renames columns to the standard schema names using the
1300+
#' regular expressions in `cols`. Any attribute not matched by a `cols` entry is
1301+
#' discarded; any schema column not found in the sheet is filled with `NA`.
1302+
#'
1303+
#' ## Columnar format (`pivot = FALSE`)
1304+
#'
1305+
#' The sheet already stores each field as its own column (one row of data). The
1306+
#' regexes in `cols` are matched against the **column headings** of the sheet
1307+
#' directly, and the matching columns are selected and renamed. This is the
1308+
#' same approach used by all other `spec_type_to_*` builders.
1309+
#'
1310+
#' ## Output columns
1311+
#'
1312+
#' | Column | Description |
1313+
#' |:-------|:------------|
1314+
#' | `study_name` | Short study identifier |
1315+
#' | `study_description` | Human-readable study description |
1316+
#' | `protocol_name` | Protocol number or name |
1317+
#' | `standard_name` | CDISC standard name (e.g. `"ADaM"`) |
1318+
#' | `standard_version` | Version of the standard (e.g. `"1.0"`) |
1319+
#' | `define_version` | Define-XML version (e.g. `"2.0"`) |
1320+
#' | `language` | Language code (e.g. `"en"`) |
1321+
#'
1322+
#' @param doc Named list of data frames produced by [read_all_sheets()].
1323+
#' @param cols Named character vector of regular expressions.
1324+
#' - When `pivot = TRUE` (default): regexes are matched against the **pivoted
1325+
#' attribute values** (which become column names after the pivot). Any
1326+
#' attribute not matched is silently discarded; missing schema columns are
1327+
#' filled with `NA`.
1328+
#' - When `pivot = FALSE`: regexes are matched against the **sheet's column
1329+
#' headings**, identical to other `spec_type_to_*` builders. Only the
1330+
#' columns listed in `cols` are required; missing schema columns are filled
1331+
#' with `NA`.
1332+
#'
1333+
#' Names must be a subset of the `study_level` schema column names:
1334+
#' `study_name`, `study_description`, `protocol_name`, `standard_name`,
1335+
#' `standard_version`, `define_version`, `language`. A minimum of
1336+
#' `study_name`, `study_description`, and `protocol_name` should be supplied.
1337+
#' @param sheet Regular expression used to identify the study-level sheet.
1338+
#' Defaults to matching sheets named `"Define"` or `"Study"`.
1339+
#' @param pivot Logical; when `TRUE` (default) the sheet is assumed to be in
1340+
#' attribute–value format and is pivoted to a single wide row before column
1341+
#' mapping. Set to `FALSE` when the sheet already stores study metadata as
1342+
#' named columns.
1343+
#'
1344+
#' @return A one-row tibble formatted for the `study_level` slot of a
1345+
#' [MetacoreDefine] object.
1346+
#' @export
1347+
#'
1348+
#' @family spec builders
1349+
spec_type_to_study_level <- function(
1350+
doc,
1351+
cols = c(
1352+
"study_name" = "[Ss]tudy.?[Nn]ame",
1353+
"study_description" = "[Ss]tudy.?[Dd]escription",
1354+
"protocol_name" = "[Pp]rotocol.?[Nn]ame",
1355+
"standard_name" = "[Ss]tandard.?[Nn]ame",
1356+
"standard_version" = "[Ss]tandard.?[Vv]ersion",
1357+
"define_version" = "[Dd]efine.?[Vv]ersion",
1358+
"language" = "[Ll]anguage"
1359+
),
1360+
sheet = "[Dd]efine|[Ss]tudy",
1361+
pivot = TRUE) {
1362+
1363+
valid_names <- names(define_column_schema()$.study_level)
1364+
if (!all(names(cols) %in% valid_names) || is.null(names(cols))) {
1365+
cli_abort(c(
1366+
"x" = "Incorrect column names supplied for {.var study_level}",
1367+
"i" = "The column vector {.arg cols} must be named with a subset of {.val {valid_names}}"
1368+
))
1369+
}
1370+
1371+
if (!is.null(sheet)) {
1372+
sheet_ls <- str_subset(names(doc), sheet)
1373+
if (length(sheet_ls) == 0) {
1374+
cli_abort("No sheet matching {.val {sheet}} was found in the specification.")
1375+
}
1376+
if (length(sheet_ls) > 1) {
1377+
cli_warn(c(
1378+
"Multiple sheets match the sheet pattern.",
1379+
"i" = "Using the first match: {.val {sheet_ls[1]}}."
1380+
))
1381+
sheet_ls <- sheet_ls[1]
1382+
}
1383+
doc <- doc[sheet_ls]
1384+
}
1385+
1386+
sheet_data <- doc[[1]]
1387+
1388+
if (pivot) {
1389+
col_names <- names(sheet_data)
1390+
attr_col <- col_names[str_detect(col_names, "[Aa]ttribute")]
1391+
val_col <- col_names[str_detect(col_names, "[Vv]alue")]
1392+
1393+
if (length(attr_col) == 0) {
1394+
cli_abort(c(
1395+
"x" = "Could not find an attribute column in sheet {.val {names(doc)[1]}}.",
1396+
"i" = "Expected a column matching {.val Attribute} when {.arg pivot = TRUE}."
1397+
))
1398+
}
1399+
if (length(val_col) == 0) {
1400+
cli_abort(c(
1401+
"x" = "Could not find a value column in sheet {.val {names(doc)[1]}}.",
1402+
"i" = "Expected a column matching {.val Value} when {.arg pivot = TRUE}."
1403+
))
1404+
}
1405+
1406+
sheet_data <- sheet_data |>
1407+
select(attribute = all_of(attr_col[1]), value = all_of(val_col[1])) |>
1408+
filter(!is.na(.data$attribute)) |>
1409+
tidyr::pivot_wider(names_from = attribute, values_from = value)
1410+
}
1411+
1412+
# Match each cols regex against the (possibly pivoted) column names and
1413+
# extract the matching column, filling NA for any unmatched entry.
1414+
pivot_names <- names(sheet_data)
1415+
out <- imap(cols, function(regex, output_name) {
1416+
match_idx <- which(str_detect(pivot_names, regex))
1417+
if (length(match_idx) > 0) {
1418+
sheet_data[[pivot_names[match_idx[1]]]]
1419+
} else {
1420+
rep(NA_character_, nrow(sheet_data))
1421+
}
1422+
}) |>
1423+
tibble::as_tibble()
1424+
1425+
fill_cols(out, define_column_schema()$.study_level) |>
1426+
reorder_by_schema("study_level")
1427+
}
1428+
1429+
12701430
#' Spec to supp
12711431
#'
12721432
#' `r lifecycle::badge("experimental")`

R/zzz.R

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ globalVariables(c(
3333
"where_new",
3434
"var1",
3535
"n_lab",
36-
"super"
36+
"super",
37+
"attribute"
3738
))
3839

3940
.onAttach <- function(libname, pkgname) {

tests/testthat/_snaps/dataset-meta.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
reference, and purpose
4141
> ds_vars: character [31 x 8] dataset, variable, order, mandatory, key_seq,
4242
core, supp_flag, and role
43-
> study_level: character [0 x 7] study_name, study_description, protocol_name,
43+
> study_level: character [1 x 7] study_name, study_description, protocol_name,
4444
standard_name, standard_version, define_version, and language
4545
> supp: character [6 x 4] dataset, variable, idvar, and qeval
4646
> value_spec: character [31 x 10] dataset, variable, origin, type, code_id,

tests/testthat/test-metacore-define.R

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ test_that("all 7 base tables still accessible from MetacoreDefine", {
4040

4141
# NULL inputs produce empty schema tibbles -------------------------------------
4242

43-
test_that("NULL study_level produces empty tibble with correct columns", {
43+
test_that("study_level read from spec has correct columns and one row", {
4444
expect_equal(names(mc_define$study_level), names(define_column_schema()$.study_level))
45-
expect_equal(nrow(mc_define$study_level), 0L)
45+
expect_equal(nrow(mc_define$study_level), 1L)
4646
})
4747

4848
test_that("NULL documents produces empty tibble with correct columns", {

0 commit comments

Comments
 (0)