From 389b35f56ee33e7fb1240516fd7e0893ec10ede0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 3 Nov 2023 13:33:51 +0100 Subject: [PATCH 001/240] insert package skeleton --- .Rbuildignore | 3 +++ .gitignore | 1 + DESCRIPTION | 14 ++++++++++++++ LICENSE | 2 ++ LICENSE.md | 21 +++++++++++++++++++++ NAMESPACE | 2 ++ unbiased.Rproj | 15 +++++++++++++-- 7 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 DESCRIPTION create mode 100644 LICENSE create mode 100644 LICENSE.md create mode 100644 NAMESPACE diff --git a/.Rbuildignore b/.Rbuildignore index 4862ac6..d6786a1 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,3 +1,6 @@ ^renv$ ^renv\.lock$ ^\.github$ +^unbiased\.Rproj$ +^\.Rproj\.user$ +^LICENSE\.md$ diff --git a/.gitignore b/.gitignore index e75435c..7cc8b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ po/*~ # RStudio Connect folder rsconnect/ +.Rproj.user diff --git a/DESCRIPTION b/DESCRIPTION new file mode 100644 index 0000000..0a50c80 --- /dev/null +++ b/DESCRIPTION @@ -0,0 +1,14 @@ +Package: unbiased +Title: What the Package Does (One Line, Title Case) +Version: 0.0.0.9000 +Authors@R: + person("First", "Last", , "first.last@example.com", role = c("aut", "cre"), + comment = c(ORCID = "YOUR-ORCID-ID")) +Description: What the package does (one paragraph). +License: MIT + file LICENSE +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.2.3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..473aa63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2023 +COPYRIGHT HOLDER: unbiased authors diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d01cf81 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2023 unbiased authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NAMESPACE b/NAMESPACE new file mode 100644 index 0000000..6ae9268 --- /dev/null +++ b/NAMESPACE @@ -0,0 +1,2 @@ +# Generated by roxygen2: do not edit by hand + diff --git a/unbiased.Rproj b/unbiased.Rproj index 8e3c2eb..8d6c830 100644 --- a/unbiased.Rproj +++ b/unbiased.Rproj @@ -1,7 +1,7 @@ Version: 1.0 -RestoreWorkspace: Default -SaveWorkspace: Default +RestoreWorkspace: No +SaveWorkspace: No AlwaysSaveHistory: Default EnableCodeIndexing: Yes @@ -11,3 +11,14 @@ Encoding: UTF-8 RnwWeave: Sweave LaTeX: pdfLaTeX + +AutoAppendNewline: Yes +StripTrailingWhitespace: Yes +LineEndingConversion: Posix + +BuildType: Package +PackageUseDevtools: Yes +PackageInstallArgs: --no-multiarch --with-keep.source +PackageRoxygenize: rd,collate,namespace + +QuitChildProcessesOnExit: Yes From bb5a75b958d95ff070a57d66aff521693cfeb9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 3 Nov 2023 14:47:08 +0100 Subject: [PATCH 002/240] reorganize files --- Dockerfile | 7 ++++++- {api => R}/meta.R | 0 {api => R}/randomize-simple.R | 0 {api => inst/api}/plumber.R | 0 4 files changed, 6 insertions(+), 1 deletion(-) rename {api => R}/meta.R (100%) rename {api => R}/randomize-simple.R (100%) rename {api => inst/api}/plumber.R (100%) diff --git a/Dockerfile b/Dockerfile index 5354402..cc5c845 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,12 @@ COPY renv.lock . RUN R -e 'renv::restore()' -COPY api/ ./api +COPY app/ ./app +COPY inst/api/ ./api + +# Copy more package data +# Build package from app +# Or maybe separate Dockerfiles and build package in a separate image EXPOSE 3838 diff --git a/api/meta.R b/R/meta.R similarity index 100% rename from api/meta.R rename to R/meta.R diff --git a/api/randomize-simple.R b/R/randomize-simple.R similarity index 100% rename from api/randomize-simple.R rename to R/randomize-simple.R diff --git a/api/plumber.R b/inst/api/plumber.R similarity index 100% rename from api/plumber.R rename to inst/api/plumber.R From 451bb706fb71ccd03c050b654130ac9ff2a62256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 8 Nov 2023 15:16:25 +0100 Subject: [PATCH 003/240] move waiting responsibility to setup --- tests/testthat/setup-api.R | 8 ++++++++ tests/testthat/test-E2E-meta-tag.R | 1 - tests/testthat/test-E2E-simple.R | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index ec4d80b..c11ae80 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -26,3 +26,11 @@ if (!isTRUE(as.logical(Sys.getenv("CI")))) { # Close API upon exiting withr::defer({ api$kill() }, teardown_env()) } + +# Retry a request until the API starts +request(api_url) |> + # Endpoint that should be always available + req_url_path("meta", "sha") |> + req_method("GET") |> + req_retry(max_tries = 5) |> + req_perform() diff --git a/tests/testthat/test-E2E-meta-tag.R b/tests/testthat/test-E2E-meta-tag.R index 048e4d6..80305af 100644 --- a/tests/testthat/test-E2E-meta-tag.R +++ b/tests/testthat/test-E2E-meta-tag.R @@ -2,7 +2,6 @@ test_that("meta tag endpoint returns the SHA", { response <- request(api_url) |> req_url_path("meta", "sha") |> req_method("GET") |> - req_retry(max_tries = 5) |> req_perform() |> resp_body_json() diff --git a/tests/testthat/test-E2E-simple.R b/tests/testthat/test-E2E-simple.R index f32b8e6..510375f 100644 --- a/tests/testthat/test-E2E-simple.R +++ b/tests/testthat/test-E2E-simple.R @@ -2,7 +2,6 @@ test_that("hello world endpoint returns the message", { response <- request(api_url) |> req_url_path("simple", "hello") |> req_method("GET") |> - req_retry(max_tries = 5) |> req_perform() |> resp_body_json() From 27deaab1db104023ceb0bc02f99b7e520c258ea3 Mon Sep 17 00:00:00 2001 From: kinga Date: Wed, 8 Nov 2023 14:37:49 +0000 Subject: [PATCH 004/240] Added block randomization (randomize-block.R) with helping functions (helpers.R) and changes in plumber.R --- api/helpers.R | 44 ++++++++++++++ api/plumber.R | 18 +++++- api/randomize-block.R | 138 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 api/helpers.R create mode 100644 api/randomize-block.R diff --git a/api/helpers.R b/api/helpers.R new file mode 100644 index 0000000..cd56c6d --- /dev/null +++ b/api/helpers.R @@ -0,0 +1,44 @@ +# objects to save for dynamic randomization +to_save <- c("trial", "study_name", "N", "arm_names", "arm_ratios", "centers", "pIDs", "strata", "study_path", "patient_data") + +print_log <- function(...) { + cat(as.character(Sys.time()), "-", ..., "\n") +} + +library(dplyr) +library(stringr) +library(tibble) +library(tidyr) +# convert S4 class to a dataframe +S4_to_dataframe <- function(mypatients) { + + if(!is.null(mypatients)){ + lapply(1:length(mypatients), function(mys4){ + names <- slotNames(mypatients[[mys4]]) + + lt <- lapply(names, function(names) slot(mypatients[[mys4]], names)) + lt <- setNames(lt, names) + + lt %>% + unlist(recursive = FALSE) %>% + enframe() %>% + spread(name, value) %>% + unnest(cols = names(.)) %>% + mutate(date = as.Date(date, origin="1970-01-01")) + }) %>% + bind_rows() + } else { + return(NULL) + } +} + +TTSIminimizeTaves <- function(df, features, trtvec, obsdf, trttab){ + picks <- randPack:::factorCounts(df, features, trtvec, obsdf) + picks <- vapply(picks, base::sum, FUN.VALUE = integer(1L)) + missing_picks <- setdiff(names(trttab), names(picks)) + additional_picks <- integer(length(missing_picks)) + names(additional_picks) <- missing_picks + picks <- c(picks, additional_picks) + picks <- picks[picks == min(picks)] + return(sample(x = names(picks), size = 1L, prob = trttab[names(picks)])) +} \ No newline at end of file diff --git a/api/plumber.R b/api/plumber.R index c798871..5112c65 100644 --- a/api/plumber.R +++ b/api/plumber.R @@ -1,9 +1,25 @@ +dirs <- list.dirs(recursive = FALSE) +if (!any(dirs == "./studies")) { + dir.create("studies") +} + +source("helpers.R") + +#* @filter Log request +function(req){ + print_log(req$REQUEST_METHOD, req$PATH_INFO) + plumber::forward() +} + #* @plumber + function(api) { rand_simple <- plumber::pr("randomize-simple.R") + rand_block <- plumber::pr("randomize-block.R") meta <- plumber::pr("meta.R") api |> plumber::pr_mount("/simple", rand_simple) |> - plumber::pr_mount("/meta", meta) + plumber::pr_mount("/block", rand_block) |> + plumber::pr_mount("/meta", meta) } diff --git a/api/randomize-block.R b/api/randomize-block.R new file mode 100644 index 0000000..18fad0c --- /dev/null +++ b/api/randomize-block.R @@ -0,0 +1,138 @@ +#* Block randomization, one-off. +#* +#' @post / +#' @param study_name:character Name of the study +#' @param N:int Number of participants to (maximum) in a study +#' @param block:[int] Block sizes (array), typically small integers: e.g. 2, 4, 6, must be multiples of the sum of ratios (if ratio not specified, multiples of the number of arms instead). +#' @param strata:object Strata definition as JSON, e.g. {"strata":{"foo":["bar","baz"],"baz":["hip","hop"]}}. Optional, can also accept empty definition, i.e. {"strata":{}}. +#' @param arms:object Arms definition as JSON array, e.g. {"arms":["arm1","arm2"]} +#' @param ratio:object Frequency of each arm, e.g. {"ratio":[1,2]}. Optional. +#' @serializer unboxedJSON + +function(study_name, N, arms, block, ratio, req, res) { + print_log("query:", paste(names(unlist(req$argsQuery)), "=", unlist(req$argsQuery))) + print_log("body:", req$postBody) + + randomized <- list.files("studies") + + if (study_name %in% randomized) { + message <- paste("Study", study_name, "is already randomized") + print_log("http 409:", message) + res$status <- 409 + return(message) + } + + # parse and validate inputs + N <- as.integer(N) + checkmate::assert_count(N, positive = TRUE) + + #validate JSON structure + if(!jsonlite::validate(req$postBody)) { + # not a valid JSON + print_log("Invalid JSON structure, returning http 400") + res$status <- 400 + validation_result <- jsonlite::validate(req$postBody) + return(attributes(validation_result)) + } + + parsed_body <- jsonlite::fromJSON(req$postBody) + if (is.null(parsed_body$strata)) { + parsed_body$strata <- list() + } + if (length(parsed_body$strata) == 0) { + # Adding names makes JSON converter use {} instead of [] + names(parsed_body$strata) <- character() + } + if (!all(c("strata", "arms") %in% names(parsed_body))) { + print_log("Missing elements in JSON, returning http 400") + res$status <- 400 + return("JSON must contain 'strata' and 'arms' elements.") + } + + checkmate::assert_list(parsed_body, + any.missing = FALSE, + min.len = 1, + names = "strict") + + arms <- length(parsed_body$arms) + arm_names <- parsed_body$arms + checkmate::assert_count(arms, positive = TRUE) + checkmate::assert(arms > 1, .var.name = "There should be at least two arms") + checkmate::assert_true(length(arm_names) == length(unique(arm_names)), .var.name = "Arms must be unique") + + strata <- parsed_body$strata + checkmate::assert_list(strata, any.missing = FALSE, names = "strict") + lapply(strata, function(individual_strata) checkmate::assert_true(length(individual_strata)>1L, .var.name = "Each strata need at least two values")) + lapply(strata, function(individual_strata) checkmate::assert_character(individual_strata, min.len = 1)) + lapply(strata, function(individual_strata) checkmate::assert_true(length(individual_strata) == length(unique(individual_strata)), .var.name = "Strata values must be unique")) + checkmate::assert_false(any(c("name", "alloc", "allocation") %in% names(strata)), .var.name = "Strata name must be different than 'name', 'alloc' or 'allocation' for technical reasons") + + ratio <- parsed_body$ratio + if (is.null(ratio)) { + # Default value if none available + ratio <- rep(1, arms) + } + ratio <- as.integer(ratio) + checkmate::assert_integer(ratio, lower = 1, any.missing = FALSE) + checkmate::assert_true(length(ratio) == arms, .var.name = "Arm and ratio lengths must be equal") + + block <- as.integer(block) + checkmate::assert_integer(block, lower = 1, any.missing = FALSE) + checkmate::assert(all(block %% sum(ratio) == 0)) + + # randomize + library(randomizeR) + + strata_grid <- if (length(strata) == 0) { + tibble::tibble(.rows = 1) + } else { + tibble::as_tibble(expand.grid(strata, stringsAsFactors = FALSE)) + } + strata_n <- nrow(strata_grid) + + # wygenerowanie sekwencji dla każdego stratum + genSeq_list <- lapply(seq_len(strata_n), function(i) { + rand <- rpbrPar( + N = N, rb = block, K = arms, ratio = ratio, + groups = arm_names, filledBlock = FALSE + ) + getRandList(genSeq(rand))[1, ] + }) + + #stworzenie listy randomizacyjnej + df_list = tibble::tibble() + for(i in seq_len(strata_n)) { + local_df <- strata_grid %>% + dplyr::slice(i) %>% + dplyr::mutate(count = N) %>% + tidyr::uncount(count) %>% + tibble::add_column(arm = genSeq_list[[i]]) + df_list <- rbind(local_df, df_list) + } + #przypisanie unikalnych kodow randomizacyjnych + randomization_space <- nrow(df_list) * 3 + df_list$randomization_code <- sample( + 1:randomization_space, strata_n * N, replace=FALSE + ) + df_list$randomization_code <- stringr::str_pad( + string = df_list$randomization_code, + width = max(nchar(as.character(df_list$randomization_code))), + pad = 0 + ) + + # save results + study_path <- paste0("studies/", study_name) + dir.create(study_path) + file.create(paste0(study_path, "/T0block")) + + save(list = ls(all.names = TRUE), + file = paste0(study_path, "/session_0.RData"), + envir = environment(), + ascii = TRUE, + compress = T) + + readr::write_csv(df_list, + file = paste0(study_path, "/randomization.csv")) + + return(df_list) +} \ No newline at end of file From 978698d63776c8e5ed00f8fdd4d81706a7a6882a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 09:15:23 +0100 Subject: [PATCH 005/240] fix path --- tests/testthat/setup-api.R | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index c11ae80..8fa79f3 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -5,24 +5,24 @@ if (!isTRUE(as.logical(Sys.getenv("CI")))) { # Extract current SHA and set it as a temporary env var list(GITHUB_SHA = system("git rev-parse HEAD", intern = TRUE)) ) - + # Overwrite API URL if not on CI api_url <- "http://localhost:3838" api_path <- tempdir() - + # Start the API api <- callr::r_bg(\(path) { # 1. Set path to `path` # 2. Build a plumber API - plumber::plumb(dir = fs::path("..", "..", "api")) |> + plumber::plumb(dir = fs::path_package("unbiased", "api")) |> plumber::pr_run(port = 3838) }, args = list(path = api_path)) - + # Wait until started while (!api$is_alive()) { Sys.sleep(.2) } - + # Close API upon exiting withr::defer({ api$kill() }, teardown_env()) } From 810b360cbf4f52566e438d0d427827cce5a5b9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 09:18:44 +0100 Subject: [PATCH 006/240] Revert "Added block randomization (randomize-block.R) with helping functions (helpers.R) and changes in plumber.R" This reverts commit 27deaab1db104023ceb0bc02f99b7e520c258ea3. --- api/helpers.R | 44 -------------- api/plumber.R | 18 +----- api/randomize-block.R | 138 ------------------------------------------ 3 files changed, 1 insertion(+), 199 deletions(-) delete mode 100644 api/helpers.R delete mode 100644 api/randomize-block.R diff --git a/api/helpers.R b/api/helpers.R deleted file mode 100644 index cd56c6d..0000000 --- a/api/helpers.R +++ /dev/null @@ -1,44 +0,0 @@ -# objects to save for dynamic randomization -to_save <- c("trial", "study_name", "N", "arm_names", "arm_ratios", "centers", "pIDs", "strata", "study_path", "patient_data") - -print_log <- function(...) { - cat(as.character(Sys.time()), "-", ..., "\n") -} - -library(dplyr) -library(stringr) -library(tibble) -library(tidyr) -# convert S4 class to a dataframe -S4_to_dataframe <- function(mypatients) { - - if(!is.null(mypatients)){ - lapply(1:length(mypatients), function(mys4){ - names <- slotNames(mypatients[[mys4]]) - - lt <- lapply(names, function(names) slot(mypatients[[mys4]], names)) - lt <- setNames(lt, names) - - lt %>% - unlist(recursive = FALSE) %>% - enframe() %>% - spread(name, value) %>% - unnest(cols = names(.)) %>% - mutate(date = as.Date(date, origin="1970-01-01")) - }) %>% - bind_rows() - } else { - return(NULL) - } -} - -TTSIminimizeTaves <- function(df, features, trtvec, obsdf, trttab){ - picks <- randPack:::factorCounts(df, features, trtvec, obsdf) - picks <- vapply(picks, base::sum, FUN.VALUE = integer(1L)) - missing_picks <- setdiff(names(trttab), names(picks)) - additional_picks <- integer(length(missing_picks)) - names(additional_picks) <- missing_picks - picks <- c(picks, additional_picks) - picks <- picks[picks == min(picks)] - return(sample(x = names(picks), size = 1L, prob = trttab[names(picks)])) -} \ No newline at end of file diff --git a/api/plumber.R b/api/plumber.R index 5112c65..c798871 100644 --- a/api/plumber.R +++ b/api/plumber.R @@ -1,25 +1,9 @@ -dirs <- list.dirs(recursive = FALSE) -if (!any(dirs == "./studies")) { - dir.create("studies") -} - -source("helpers.R") - -#* @filter Log request -function(req){ - print_log(req$REQUEST_METHOD, req$PATH_INFO) - plumber::forward() -} - #* @plumber - function(api) { rand_simple <- plumber::pr("randomize-simple.R") - rand_block <- plumber::pr("randomize-block.R") meta <- plumber::pr("meta.R") api |> plumber::pr_mount("/simple", rand_simple) |> - plumber::pr_mount("/block", rand_block) |> - plumber::pr_mount("/meta", meta) + plumber::pr_mount("/meta", meta) } diff --git a/api/randomize-block.R b/api/randomize-block.R deleted file mode 100644 index 18fad0c..0000000 --- a/api/randomize-block.R +++ /dev/null @@ -1,138 +0,0 @@ -#* Block randomization, one-off. -#* -#' @post / -#' @param study_name:character Name of the study -#' @param N:int Number of participants to (maximum) in a study -#' @param block:[int] Block sizes (array), typically small integers: e.g. 2, 4, 6, must be multiples of the sum of ratios (if ratio not specified, multiples of the number of arms instead). -#' @param strata:object Strata definition as JSON, e.g. {"strata":{"foo":["bar","baz"],"baz":["hip","hop"]}}. Optional, can also accept empty definition, i.e. {"strata":{}}. -#' @param arms:object Arms definition as JSON array, e.g. {"arms":["arm1","arm2"]} -#' @param ratio:object Frequency of each arm, e.g. {"ratio":[1,2]}. Optional. -#' @serializer unboxedJSON - -function(study_name, N, arms, block, ratio, req, res) { - print_log("query:", paste(names(unlist(req$argsQuery)), "=", unlist(req$argsQuery))) - print_log("body:", req$postBody) - - randomized <- list.files("studies") - - if (study_name %in% randomized) { - message <- paste("Study", study_name, "is already randomized") - print_log("http 409:", message) - res$status <- 409 - return(message) - } - - # parse and validate inputs - N <- as.integer(N) - checkmate::assert_count(N, positive = TRUE) - - #validate JSON structure - if(!jsonlite::validate(req$postBody)) { - # not a valid JSON - print_log("Invalid JSON structure, returning http 400") - res$status <- 400 - validation_result <- jsonlite::validate(req$postBody) - return(attributes(validation_result)) - } - - parsed_body <- jsonlite::fromJSON(req$postBody) - if (is.null(parsed_body$strata)) { - parsed_body$strata <- list() - } - if (length(parsed_body$strata) == 0) { - # Adding names makes JSON converter use {} instead of [] - names(parsed_body$strata) <- character() - } - if (!all(c("strata", "arms") %in% names(parsed_body))) { - print_log("Missing elements in JSON, returning http 400") - res$status <- 400 - return("JSON must contain 'strata' and 'arms' elements.") - } - - checkmate::assert_list(parsed_body, - any.missing = FALSE, - min.len = 1, - names = "strict") - - arms <- length(parsed_body$arms) - arm_names <- parsed_body$arms - checkmate::assert_count(arms, positive = TRUE) - checkmate::assert(arms > 1, .var.name = "There should be at least two arms") - checkmate::assert_true(length(arm_names) == length(unique(arm_names)), .var.name = "Arms must be unique") - - strata <- parsed_body$strata - checkmate::assert_list(strata, any.missing = FALSE, names = "strict") - lapply(strata, function(individual_strata) checkmate::assert_true(length(individual_strata)>1L, .var.name = "Each strata need at least two values")) - lapply(strata, function(individual_strata) checkmate::assert_character(individual_strata, min.len = 1)) - lapply(strata, function(individual_strata) checkmate::assert_true(length(individual_strata) == length(unique(individual_strata)), .var.name = "Strata values must be unique")) - checkmate::assert_false(any(c("name", "alloc", "allocation") %in% names(strata)), .var.name = "Strata name must be different than 'name', 'alloc' or 'allocation' for technical reasons") - - ratio <- parsed_body$ratio - if (is.null(ratio)) { - # Default value if none available - ratio <- rep(1, arms) - } - ratio <- as.integer(ratio) - checkmate::assert_integer(ratio, lower = 1, any.missing = FALSE) - checkmate::assert_true(length(ratio) == arms, .var.name = "Arm and ratio lengths must be equal") - - block <- as.integer(block) - checkmate::assert_integer(block, lower = 1, any.missing = FALSE) - checkmate::assert(all(block %% sum(ratio) == 0)) - - # randomize - library(randomizeR) - - strata_grid <- if (length(strata) == 0) { - tibble::tibble(.rows = 1) - } else { - tibble::as_tibble(expand.grid(strata, stringsAsFactors = FALSE)) - } - strata_n <- nrow(strata_grid) - - # wygenerowanie sekwencji dla każdego stratum - genSeq_list <- lapply(seq_len(strata_n), function(i) { - rand <- rpbrPar( - N = N, rb = block, K = arms, ratio = ratio, - groups = arm_names, filledBlock = FALSE - ) - getRandList(genSeq(rand))[1, ] - }) - - #stworzenie listy randomizacyjnej - df_list = tibble::tibble() - for(i in seq_len(strata_n)) { - local_df <- strata_grid %>% - dplyr::slice(i) %>% - dplyr::mutate(count = N) %>% - tidyr::uncount(count) %>% - tibble::add_column(arm = genSeq_list[[i]]) - df_list <- rbind(local_df, df_list) - } - #przypisanie unikalnych kodow randomizacyjnych - randomization_space <- nrow(df_list) * 3 - df_list$randomization_code <- sample( - 1:randomization_space, strata_n * N, replace=FALSE - ) - df_list$randomization_code <- stringr::str_pad( - string = df_list$randomization_code, - width = max(nchar(as.character(df_list$randomization_code))), - pad = 0 - ) - - # save results - study_path <- paste0("studies/", study_name) - dir.create(study_path) - file.create(paste0(study_path, "/T0block")) - - save(list = ls(all.names = TRUE), - file = paste0(study_path, "/session_0.RData"), - envir = environment(), - ascii = TRUE, - compress = T) - - readr::write_csv(df_list, - file = paste0(study_path, "/randomization.csv")) - - return(df_list) -} \ No newline at end of file From b64f795487d591b47e81315b25a8acc37d5f41dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 13:49:57 +0100 Subject: [PATCH 007/240] make local tests work with package --- R/randomize-simple.R | 8 -------- {R => inst/api}/meta.R | 0 inst/api/plumber.R | 12 +++++++++--- tests/testthat.R | 3 ++- 4 files changed, 11 insertions(+), 12 deletions(-) rename {R => inst/api}/meta.R (100%) diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 9406713..5eed94e 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -1,11 +1,3 @@ -#* Return hello world -#* -#* @get /hello -#* @serializer unboxedJSON -function() { - call_hello_world() -} - call_hello_world <- function() { "Hello TTSI!" } diff --git a/R/meta.R b/inst/api/meta.R similarity index 100% rename from R/meta.R rename to inst/api/meta.R diff --git a/inst/api/plumber.R b/inst/api/plumber.R index c798871..d05217a 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -1,9 +1,15 @@ #* @plumber function(api) { - rand_simple <- plumber::pr("randomize-simple.R") meta <- plumber::pr("meta.R") - + api |> - plumber::pr_mount("/simple", rand_simple) |> plumber::pr_mount("/meta", meta) } + +#* Return hello world +#* +#* @get /simple/hello +#* @serializer unboxedJSON +function() { + unbiased:::call_hello_world() +} diff --git a/tests/testthat.R b/tests/testthat.R index 35c4c98..12c5f5b 100644 --- a/tests/testthat.R +++ b/tests/testthat.R @@ -9,5 +9,6 @@ library(testthat) library(checkmate) library(httr2) +library(unbiased) -test_dir(fs::path("tests", "testthat")) +test_check("unbiased") From 96c2a98e08e45c78a10e97821f6958fca1616c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 14:47:48 +0100 Subject: [PATCH 008/240] build, then test --- Dockerfile | 16 +++++++++------- docker-compose.test.yaml | 6 +----- tests/testthat.R | 2 -- tests/testthat/setup-api.R | 3 +++ 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index cc5c845..be73059 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rocker/r-ver:4.3.1 +FROM rocker/r-ver:4.2.1 WORKDIR /src/unbiased @@ -17,16 +17,18 @@ COPY renv.lock . RUN R -e 'renv::restore()' -COPY app/ ./app -COPY inst/api/ ./api +COPY .Rbuildignore . +COPY DESCRIPTION . +COPY NAMESPACE . +COPY inst/ ./inst +COPY R/ ./R +COPY tests/ ./inst/tests -# Copy more package data -# Build package from app -# Or maybe separate Dockerfiles and build package in a separate image +RUN R CMD INSTALL --no-multiarch . EXPOSE 3838 ARG github_sha ENV GITHUB_SHA=${github_sha} -CMD ["R", "-e", "plumber::plumb(dir = 'api') |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] +CMD ["R", "-e", "plumber::plumb(dir = fs::path_package('unbiased', 'api') |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index df8aeb5..4ac16a0 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -14,11 +14,7 @@ services: - CI=true networks: - test_net - volumes: - - type: bind - source: ./tests - target: /src/unbiased/tests - command: Rscript tests/testthat.R + command: R -e "testthat::test_package('unbiased')" networks: test_net: diff --git a/tests/testthat.R b/tests/testthat.R index 12c5f5b..ac6d738 100644 --- a/tests/testthat.R +++ b/tests/testthat.R @@ -7,8 +7,6 @@ # * https://testthat.r-lib.org/articles/special-files.html library(testthat) -library(checkmate) -library(httr2) library(unbiased) test_check("unbiased") diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 8fa79f3..3f0b73c 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -1,3 +1,6 @@ +library(checkmate) +library(httr2) + api_url <- "http://api:3838" if (!isTRUE(as.logical(Sys.getenv("CI")))) { From a75066d9555094fe15df18cb99a8de80104f213c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 14:48:28 +0100 Subject: [PATCH 009/240] attach to all containers --- .github/workflows/R-CMD-check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index ac76e84..0d365c6 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -25,9 +25,9 @@ jobs: - uses: actions/checkout@v3 - uses: r-lib/actions/setup-pandoc@v2 - + - name: Build image run: docker build -t unbiased --build-arg github_sha=${{ github.sha }} . - name: Run tests - run: docker compose -f "docker-compose.test.yaml" up --abort-on-container-exit --exit-code-from tests --attach tests + run: docker compose -f "docker-compose.test.yaml" up --abort-on-container-exit --exit-code-from tests From a88813e79fe57b7d2d9783ceeb5efa23d04eeaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 14:49:21 +0100 Subject: [PATCH 010/240] fix typo --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index be73059..8d72954 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,4 @@ EXPOSE 3838 ARG github_sha ENV GITHUB_SHA=${github_sha} -CMD ["R", "-e", "plumber::plumb(dir = fs::path_package('unbiased', 'api') |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] +CMD ["R", "-e", "plumber::plumb(dir = fs::path_package('unbiased', 'api')) |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] From c5df2b3d2074f43437ffe0caa0514d1ad7256e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 14:55:22 +0100 Subject: [PATCH 011/240] add imported packages --- DESCRIPTION | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 0a50c80..da070d8 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,13 +1,18 @@ Package: unbiased Title: What the Package Does (One Line, Title Case) -Version: 0.0.0.9000 +Version: 0.0.0.9001 Authors@R: person("First", "Last", , "first.last@example.com", role = c("aut", "cre"), comment = c(ORCID = "YOUR-ORCID-ID")) Description: What the package does (one paragraph). License: MIT + file LICENSE +Imports: + checkmate, + plumber Suggests: - testthat (>= 3.0.0) + httr2, + testthat (>= 3.0.0), + usethis Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) From 2d0ac8bad6884760c43f6b509b997c941890d23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 9 Nov 2023 14:58:22 +0100 Subject: [PATCH 012/240] add some more test pkgs --- DESCRIPTION | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index da070d8..4183592 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,9 +10,11 @@ Imports: checkmate, plumber Suggests: + callr, httr2, testthat (>= 3.0.0), - usethis + usethis, + withr Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) From 044a1779df1dd7e92c0347ab54cbe7578c55c633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 10 Nov 2023 14:32:23 +0100 Subject: [PATCH 013/240] Add NEWS.md --- NEWS.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 NEWS.md diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..6effb01 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,3 @@ +# unbiased (development version) + +* Initial CRAN submission. From d58911d8b2562fb0854480be493288caf779d907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 10 Nov 2023 14:34:40 +0100 Subject: [PATCH 014/240] add NEWS file --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 6effb01..de79bdc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,3 @@ # unbiased (development version) -* Initial CRAN submission. +* Initialized package structure. From 0be676d010313369af949e542d58ed9272b19119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 10 Nov 2023 16:02:12 +0100 Subject: [PATCH 015/240] implement simple randomization --- NAMESPACE | 1 + R/hello_world.R | 3 +++ R/randomize-simple.R | 28 ++++++++++++++++++++++++++-- inst/api/plumber.R | 26 ++++++++++++++++++++++++++ man/randomize_simple.Rd | 26 ++++++++++++++++++++++++++ renv.lock | 4 ++-- 6 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 R/hello_world.R create mode 100644 man/randomize_simple.Rd diff --git a/NAMESPACE b/NAMESPACE index 6ae9268..d37053d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,2 +1,3 @@ # Generated by roxygen2: do not edit by hand +export(randomize_simple) diff --git a/R/hello_world.R b/R/hello_world.R new file mode 100644 index 0000000..5eed94e --- /dev/null +++ b/R/hello_world.R @@ -0,0 +1,3 @@ +call_hello_world <- function() { + "Hello TTSI!" +} diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 5eed94e..1c6dadd 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -1,3 +1,27 @@ -call_hello_world <- function() { - "Hello TTSI!" +#' Simple randomization +#' +#' @description +#' Randomly assigns a patient to one of the arms according to specified ratios, +#' regardless of already performed assignments. +#' +#' @param arms `character()`\cr +#' Arm names. +#' @param ratio `numeric()`\cr +#' Ratio of patient assignment to each arm. Must be the same length as `arms`. +#' +#' @return Selected arm assignment. +#' +#' @examples +#' randomize_simple(c("active", "placebo"), c(2, 1)) +#' +#' @export +randomize_simple <- function(arms, ratio) { + checkmate::assert_character( + arms, any.missing = FALSE, unique = TRUE, min.chars = 1 + ) + checkmate::assert_numeric( + ratio, any.missing = FALSE, lower = 0, finite = TRUE, len = length(arms) + ) + + sample(arms, 1, prob = ratio) } diff --git a/inst/api/plumber.R b/inst/api/plumber.R index d05217a..e271b38 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -6,6 +6,32 @@ function(api) { plumber::pr_mount("/meta", meta) } +#* Randomize one patient +#* +#* @param strata:object +#* +#* @get /study//randomize +function(strata, req, res) { + # Check whether study with study_id exists, if not, return error + + # Retrieve study details, especially the ones about randomization + method <- NULL + params <- list( + arms = character(), + ratio = numeric() + ) + + # Assert that patient has the same strata as study + # and that patient's values are allowed in study + + # Dispatch based on randomization method + switch( + method, + simple = do.call(unbiased:::randomize_simple, params), + # block = do.call(unbiased:::randomize_blocked, c(params, strata = strata)) + ) +} + #* Return hello world #* #* @get /simple/hello diff --git a/man/randomize_simple.Rd b/man/randomize_simple.Rd new file mode 100644 index 0000000..2a24ef8 --- /dev/null +++ b/man/randomize_simple.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/randomize-simple.R +\name{randomize_simple} +\alias{randomize_simple} +\title{Simple randomization} +\usage{ +randomize_simple(arms, ratio) +} +\arguments{ +\item{arms}{\code{character()}\cr +Arm names.} + +\item{ratio}{\code{numeric()}\cr +Ratio of patient assignment to each arm. Must be the same length as \code{arms}.} +} +\value{ +Selected arm assignment. +} +\description{ +Randomly assigns a patient to one of the arms according to specified ratios, +regardless of already performed assignments. +} +\examples{ +randomize_simple(c("active", "placebo"), c(2, 1)) + +} diff --git a/renv.lock b/renv.lock index d27d571..595416c 100644 --- a/renv.lock +++ b/renv.lock @@ -651,7 +651,7 @@ }, "withr": { "Package": "withr", - "Version": "2.5.1", + "Version": "2.5.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -660,7 +660,7 @@ "graphics", "stats" ], - "Hash": "d77c6f74be05c33164e33fbc85540cae" + "Hash": "4b25e70111b7d644322e9513f403a272" } } } From 326f197b08a88e24edc942cecf4bbe5ab61890cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 10 Nov 2023 16:02:23 +0100 Subject: [PATCH 016/240] add unit tests for simple rand --- .../{test-E2E-simple.R => test-E2E-hello.R} | 2 +- tests/testthat/test-randomize-simple.R | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) rename tests/testthat/{test-E2E-simple.R => test-E2E-hello.R} (98%) create mode 100644 tests/testthat/test-randomize-simple.R diff --git a/tests/testthat/test-E2E-simple.R b/tests/testthat/test-E2E-hello.R similarity index 98% rename from tests/testthat/test-E2E-simple.R rename to tests/testthat/test-E2E-hello.R index 510375f..714faf8 100644 --- a/tests/testthat/test-E2E-simple.R +++ b/tests/testthat/test-E2E-hello.R @@ -4,6 +4,6 @@ test_that("hello world endpoint returns the message", { req_method("GET") |> req_perform() |> resp_body_json() - + expect_identical(response, "Hello TTSI!") }) diff --git a/tests/testthat/test-randomize-simple.R b/tests/testthat/test-randomize-simple.R new file mode 100644 index 0000000..6b94c89 --- /dev/null +++ b/tests/testthat/test-randomize-simple.R @@ -0,0 +1,35 @@ +test_that("returns a single string", { + expect_vector( + randomize_simple(c("active", "placebo"), c(2, 1)), + ptype = character(), + size = 1 + ) +}) + +test_that("returns one of the arms", { + arms <- c("arm 1", "arm 2") + expect_subset( + randomize_simple(arms, c(1, 1)), + arms + ) +}) + +test_that("ratio equal to 0 means that this arm is never assigned", { + expect_identical( + randomize_simple(c("yes", "no"), c(1, 0)), + "yes" + ) +}) + +test_that("incorrect parameters raise an exception", { + # Incorrect arm type + expect_error(randomize_simple(c(7, 4), c(1, 2))) + # Incorrect ratio type + expect_error(randomize_simple(c("roof", "basement"), c("high", "low"))) + # Lengths not matching + expect_error(randomize_simple(c("Paris", "Barcelona"), c(1, 2, 1))) + # Missing value + expect_error(randomize_simple(c("yen", NA), c(1, 1))) + # Empty arm name + expect_error(randomize_simple(c("llama", ""), c(2, 3))) +}) From cc8bdf83dee3b3d73a0efb7beb79ded4283cd451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Mon, 13 Nov 2023 12:58:26 +0100 Subject: [PATCH 017/240] define several DB tables --- docker-compose.test.yaml | 11 +++++++++++ inst/postgres/01-initialize.sql | 35 +++++++++++++++++++++++++++++++++ inst/postgres/02-examples.sql | 0 3 files changed, 46 insertions(+) create mode 100644 inst/postgres/01-initialize.sql create mode 100644 inst/postgres/02-examples.sql diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 4ac16a0..518d674 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -1,8 +1,19 @@ version: "3.9" services: + db: + image: postgres:16.0 + container_name: unbiased_db + networks: + - test_net + volumes: + - type: bind + source: ./inst/postgres/ + target: /docker-entrypoint-initdb.d/ api: image: unbiased container_name: unbiased_api + depends_on: + - db networks: - test_net tests: diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql new file mode 100644 index 0000000..a12806a --- /dev/null +++ b/inst/postgres/01-initialize.sql @@ -0,0 +1,35 @@ +CREATE TABLE method ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE study ( + id SERIAL PRIMARY KEY, + method_id INT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT study_method + FOREIGN KEY (method_id) + REFERENCES id (method) +); + +CREATE TABLE arm ( + id SERIAL PRIMARY KEY, + study_id INT NOT NULL, + name TEXT NOT NULL, + ratio INT NOT NULL DEFAULT 1, + CONSTRAINT arm_study + FOREIGN KEY (study_id) + REFERENCES study (id) ON DELETE CASCADE, + CONSTRAINT UC_arm_study + UNIQUE (id, study_id) +); + +CREATE TABLE patient ( + id SERIAL PRIMARY KEY, + study_id INT NOT NULL, + arm_id INT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT patient_arm_study + FOREIGN KEY (arm_id, study_id) + REFERENCES arm (id, study_id) ON DELETE CASCADE +); diff --git a/inst/postgres/02-examples.sql b/inst/postgres/02-examples.sql new file mode 100644 index 0000000..e69de29 From fe6f0df471a03a0917e9c79c41b1318cfafb62fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Mon, 13 Nov 2023 14:41:25 +0100 Subject: [PATCH 018/240] implement stratum tables --- inst/postgres/01-initialize.sql | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index a12806a..63af643 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -20,10 +20,52 @@ CREATE TABLE arm ( CONSTRAINT arm_study FOREIGN KEY (study_id) REFERENCES study (id) ON DELETE CASCADE, - CONSTRAINT UC_arm_study + CONSTRAINT uc_arm_study UNIQUE (id, study_id) ); +CREATE TABLE stratum ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + value_type TEXT, + CONSTRAINT chk_value_type + CHECK (value_type IN ('factor', 'numeric', 'integer')) +); + +CREATE TABLE stratum_in_study ( + stratum_id INT NOT NULL, + study_id INT NOT NULL, + CONSTRAINT fk_stratum + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT fk_study + FOREIGN KEY (study_id) + REFERENCES study (id) ON DELETE CASCADE +); + +-- TODO: Add trigger to check for stratum value type = 'factor' +CREATE TABLE factor_constraint ( + stratum_id INT NOT NULL, + value TEXT NOT NULL, + CONSTRAINT factor_stratum + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum_value + UNIQUE (stratum_id, value) +); + +-- TODO: Add trigger to check for stratum value type = 'numeric' / 'integer' +CREATE TABLE numeric_constraint ( + stratum_id INT NOT NULL, + min_value DOUBLE, + max_value DOUBLE, + CONSTRAINT numeric_stratum + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum + UNIQUE (stratum_id) +); + CREATE TABLE patient ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, From 7c253a472ddeb2184719d116df2e342e2a711e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 12:16:48 +0100 Subject: [PATCH 019/240] implement triggers for constraint tables --- docker-compose.test.yaml | 2 + inst/postgres/01-initialize.sql | 81 +++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 518d674..86eef05 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -3,6 +3,8 @@ services: db: image: postgres:16.0 container_name: unbiased_db + environment: + - POSTGRES_PASSWORD=postgres networks: - test_net volumes: diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 63af643..d6c6600 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,35 +1,40 @@ CREATE TABLE method ( id SERIAL PRIMARY KEY, - name TEXT NOT NULL + name VARCHAR(255) NOT NULL ); CREATE TABLE study ( id SERIAL PRIMARY KEY, + identifier VARCHAR(12) NOT NULL, + name VARCHAR(255) NOT NULL, method_id INT NOT NULL, + parameters JSON, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT study_method FOREIGN KEY (method_id) - REFERENCES id (method) + REFERENCES method (id) ); CREATE TABLE arm ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, - name TEXT NOT NULL, + name VARCHAR(255) NOT NULL, ratio INT NOT NULL DEFAULT 1, CONSTRAINT arm_study FOREIGN KEY (study_id) REFERENCES study (id) ON DELETE CASCADE, CONSTRAINT uc_arm_study - UNIQUE (id, study_id) + UNIQUE (id, study_id), + CONSTRAINT ratio_positive + CHECK (ratio > 0) ); CREATE TABLE stratum ( id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - value_type TEXT, + name VARCHAR(255) NOT NULL, + value_type VARCHAR(12), CONSTRAINT chk_value_type - CHECK (value_type IN ('factor', 'numeric', 'integer')) + CHECK (value_type IN ('factor', 'numeric')) ); CREATE TABLE stratum_in_study ( @@ -40,7 +45,9 @@ CREATE TABLE stratum_in_study ( REFERENCES stratum (id) ON DELETE CASCADE, CONSTRAINT fk_study FOREIGN KEY (study_id) - REFERENCES study (id) ON DELETE CASCADE + REFERENCES study (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum_study + UNIQUE (stratum_id, study_id) ); -- TODO: Add trigger to check for stratum value type = 'factor' @@ -54,16 +61,19 @@ CREATE TABLE factor_constraint ( UNIQUE (stratum_id, value) ); --- TODO: Add trigger to check for stratum value type = 'numeric' / 'integer' +-- TODO: Add trigger to check for stratum value type = 'numeric' CREATE TABLE numeric_constraint ( stratum_id INT NOT NULL, - min_value DOUBLE, - max_value DOUBLE, + min_value FLOAT, + max_value FLOAT, CONSTRAINT numeric_stratum FOREIGN KEY (stratum_id) REFERENCES stratum (id) ON DELETE CASCADE, CONSTRAINT uc_stratum - UNIQUE (stratum_id) + UNIQUE (stratum_id), + CONSTRAINT chk_min_max + -- NULL is ok in checks, no need to test for it + CHECK (min_value <= max_value) ); CREATE TABLE patient ( @@ -71,7 +81,52 @@ CREATE TABLE patient ( study_id INT NOT NULL, arm_id INT NOT NULL, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + rand_code VARCHAR(255), CONSTRAINT patient_arm_study FOREIGN KEY (arm_id, study_id) - REFERENCES arm (id, study_id) ON DELETE CASCADE + REFERENCES arm (id, study_id) ON DELETE CASCADE, + CONSTRAINT uc_study_code + UNIQUE (study_id, rand_code) ); + + +CREATE OR REPLACE FUNCTION check_fct_stratum() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + -- Checks that column value is correct + WHERE id = NEW.stratum_id AND value_type <> 'factor' + ) THEN + RAISE EXCEPTION 'Can''t set factor constraint for non-factor stratum.'; + END IF; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION check_num_stratum() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + -- Checks that column value is correct + WHERE id = NEW.stratum_id AND value_type <> 'numeric' + ) THEN + RAISE EXCEPTION 'Can''t set numeric constraint for non-numeric stratum.'; + END IF; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER stratum_fct_constraint +BEFORE INSERT +ON factor_constraint +FOR EACH STATEMENT +EXECUTE PROCEDURE check_fct_stratum(); + + +CREATE TRIGGER stratum_num_constraint +BEFORE INSERT +ON numeric_constraint +FOR EACH STATEMENT +EXECUTE PROCEDURE check_num_stratum(); From 6a15a52c5c066af35b71e408f99cbc78ee39e0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 12:46:38 +0100 Subject: [PATCH 020/240] add simple examples --- inst/postgres/01-initialize.sql | 14 ++++++++------ inst/postgres/02-examples.sql | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index d6c6600..5485347 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -93,13 +93,14 @@ CREATE TABLE patient ( CREATE OR REPLACE FUNCTION check_fct_stratum() RETURNS trigger AS $$ BEGIN - IF EXISTS ( + IF NOT EXISTS ( SELECT 1 FROM stratum -- Checks that column value is correct - WHERE id = NEW.stratum_id AND value_type <> 'factor' + WHERE id = NEW.stratum_id AND value_type = 'factor' ) THEN RAISE EXCEPTION 'Can''t set factor constraint for non-factor stratum.'; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -107,13 +108,14 @@ $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION check_num_stratum() RETURNS trigger AS $$ BEGIN - IF EXISTS ( + IF NOT EXISTS ( SELECT 1 FROM stratum -- Checks that column value is correct - WHERE id = NEW.stratum_id AND value_type <> 'numeric' + WHERE id = NEW.stratum_id AND value_type = 'numeric' ) THEN RAISE EXCEPTION 'Can''t set numeric constraint for non-numeric stratum.'; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql; @@ -121,12 +123,12 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER stratum_fct_constraint BEFORE INSERT ON factor_constraint -FOR EACH STATEMENT +FOR EACH ROW EXECUTE PROCEDURE check_fct_stratum(); CREATE TRIGGER stratum_num_constraint BEFORE INSERT ON numeric_constraint -FOR EACH STATEMENT +FOR EACH ROW EXECUTE PROCEDURE check_num_stratum(); diff --git a/inst/postgres/02-examples.sql b/inst/postgres/02-examples.sql index e69de29..a220b4a 100644 --- a/inst/postgres/02-examples.sql +++ b/inst/postgres/02-examples.sql @@ -0,0 +1,14 @@ +INSERT INTO method (name) +VALUES ('simple'); + +INSERT INTO stratum (name, value_type) +VALUES ('gender', 'factor'); + +INSERT INTO factor_constraint (stratum_id, value) +VALUES (1, 'X'); + +-- Trigger properly raises an error here +/* +INSERT INTO numeric_constraint (stratum_id) +VALUES (1); +*/ From 770e7cf24397ee312c9a6774b87b538eac44127a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 12:47:52 +0100 Subject: [PATCH 021/240] clean code slightly --- inst/postgres/01-initialize.sql | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 5485347..20895ee 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -50,7 +50,6 @@ CREATE TABLE stratum_in_study ( UNIQUE (stratum_id, study_id) ); --- TODO: Add trigger to check for stratum value type = 'factor' CREATE TABLE factor_constraint ( stratum_id INT NOT NULL, value TEXT NOT NULL, @@ -61,7 +60,6 @@ CREATE TABLE factor_constraint ( UNIQUE (stratum_id, value) ); --- TODO: Add trigger to check for stratum value type = 'numeric' CREATE TABLE numeric_constraint ( stratum_id INT NOT NULL, min_value FLOAT, @@ -121,14 +119,12 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER stratum_fct_constraint -BEFORE INSERT -ON factor_constraint +BEFORE INSERT ON factor_constraint FOR EACH ROW EXECUTE PROCEDURE check_fct_stratum(); CREATE TRIGGER stratum_num_constraint -BEFORE INSERT -ON numeric_constraint +BEFORE INSERT ON numeric_constraint FOR EACH ROW EXECUTE PROCEDURE check_num_stratum(); From 493586b4ecf9b86a6835636e346f5f6d2394c1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 13:50:45 +0100 Subject: [PATCH 022/240] store and check patient stratum values --- inst/postgres/01-initialize.sql | 111 +++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 20895ee..9d116f8 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -8,7 +8,7 @@ CREATE TABLE study ( identifier VARCHAR(12) NOT NULL, name VARCHAR(255) NOT NULL, method_id INT NOT NULL, - parameters JSON, + parameters JSONB, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT study_method FOREIGN KEY (method_id) @@ -52,7 +52,7 @@ CREATE TABLE stratum_in_study ( CREATE TABLE factor_constraint ( stratum_id INT NOT NULL, - value TEXT NOT NULL, + value VARCHAR(255) NOT NULL, CONSTRAINT factor_stratum FOREIGN KEY (stratum_id) REFERENCES stratum (id) ON DELETE CASCADE, @@ -87,6 +87,26 @@ CREATE TABLE patient ( UNIQUE (study_id, rand_code) ); +CREATE TABLE patient_stratum ( + patient_id INT NOT NULL, + stratum_id INT NOT NULL, + fct_value VARCHAR(255), + num_value FLOAT, + CONSTRAINT fk_patient + FOREIGN KEY (patient_id) + REFERENCES patient (id) ON DELETE CASCADE, + CONSTRAINT fk_stratum_2 + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT chk_value_exists + -- Either factor or numeric value must be given + CHECK (IS NOT NULL fct_value OR IS NOT NULL num_value), + CONSTRAINT chk_one_value_only + -- Can't give both factor and numeric value + CHECK (IS NULL fct_value OR IS NULL num_value) +); + +-- Stratum constraint checks CREATE OR REPLACE FUNCTION check_fct_stratum() RETURNS trigger AS $$ @@ -128,3 +148,90 @@ CREATE TRIGGER stratum_num_constraint BEFORE INSERT ON numeric_constraint FOR EACH ROW EXECUTE PROCEDURE check_num_stratum(); + +-- Patient stratum value checks + +CREATE OR REPLACE FUNCTION check_patient_stratum_study() +RETURNS trigger AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM patient AS p + INNER JOIN stratum_in_study AS s + USING (study_id) + WHERE s.stratum_id = NEW.stratum_id + ) THEN + RAISE EXCEPTION 'Stratum and patient must be assigned to the same study.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION check_fct_patient() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + WHERE id = NEW.stratum_id AND value_type = 'factor' + ) THEN + IF (IS NULL NEW.fct_value) THEN + RAISE EXCEPTION 'Factor stratum requires a factor value.'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM factor_constraint + WHERE stratum_id = NEW.stratum_id AND value = NEW.fct_value + ) THEN + RAISE EXCEPTION 'Factor value not specified as allowed.'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION check_num_patient() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + WHERE id = NEW.stratum_id AND value_type = 'numeric' + ) THEN + IF (IS NULL NEW.num_value) THEN + RAISE EXCEPTION 'Numeric stratum requires a numeric value.'; + END IF; + min_value := ( + SELECT min_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + IF IS NOT NULL min_value AND NEW.num_value < min_value + RAISE EXCEPTION 'New value is lower than minimum allowed value.'; + END IF; + max_value := ( + SELECT max_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + IF IS NOT NULL max_value AND NEW.num_value > max_value + RAISE EXCEPTION 'New value is greater than maximum allowed value.'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER patient_stratum_study_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_patient_stratum_study(); + + +CREATE TRIGGER patient_fct_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_fct_patient(); + + +CREATE TRIGGER patient_num_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_num_patient(); From bb372ce1eec0e214c6103ac992215938bb0da0f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 14:37:07 +0100 Subject: [PATCH 023/240] fix trigger code --- inst/postgres/01-initialize.sql | 39 ++++++++++++++++++--------------- inst/postgres/02-examples.sql | 18 ++++++++++++++- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 9d116f8..a0fe0c1 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -100,10 +100,10 @@ CREATE TABLE patient_stratum ( REFERENCES stratum (id) ON DELETE CASCADE, CONSTRAINT chk_value_exists -- Either factor or numeric value must be given - CHECK (IS NOT NULL fct_value OR IS NOT NULL num_value), + CHECK (fct_value IS NOT NULL OR num_value IS NOT NULL), CONSTRAINT chk_one_value_only -- Can't give both factor and numeric value - CHECK (IS NULL fct_value OR IS NULL num_value) + CHECK (fct_value IS NULL OR num_value IS NULL) ); -- Stratum constraint checks @@ -174,7 +174,7 @@ BEGIN SELECT 1 FROM stratum WHERE id = NEW.stratum_id AND value_type = 'factor' ) THEN - IF (IS NULL NEW.fct_value) THEN + IF (NEW.fct_value IS NULL) THEN RAISE EXCEPTION 'Factor stratum requires a factor value.'; END IF; IF NOT EXISTS ( @@ -196,23 +196,26 @@ BEGIN SELECT 1 FROM stratum WHERE id = NEW.stratum_id AND value_type = 'numeric' ) THEN - IF (IS NULL NEW.num_value) THEN + IF (NEW.num_value IS NULL) THEN RAISE EXCEPTION 'Numeric stratum requires a numeric value.'; END IF; - min_value := ( - SELECT min_value FROM numeric_constraint - WHERE stratum_id = NEW.stratum_id - ); - IF IS NOT NULL min_value AND NEW.num_value < min_value - RAISE EXCEPTION 'New value is lower than minimum allowed value.'; - END IF; - max_value := ( - SELECT max_value FROM numeric_constraint - WHERE stratum_id = NEW.stratum_id - ); - IF IS NOT NULL max_value AND NEW.num_value > max_value - RAISE EXCEPTION 'New value is greater than maximum allowed value.'; - END IF; + DECLARE + min_value FLOAT := ( + SELECT min_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + max_value FLOAT := ( + SELECT max_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + BEGIN + IF (min_value IS NOT NULL AND NEW.num_value < min_value) THEN + RAISE EXCEPTION 'New value is lower than minimum allowed value.'; + END IF; + IF (max_value IS NOT NULL AND NEW.num_value > max_value) THEN + RAISE EXCEPTION 'New value is greater than maximum allowed value.'; + END IF; + END; END IF; RETURN NEW; END; diff --git a/inst/postgres/02-examples.sql b/inst/postgres/02-examples.sql index a220b4a..d7d949b 100644 --- a/inst/postgres/02-examples.sql +++ b/inst/postgres/02-examples.sql @@ -1,11 +1,27 @@ INSERT INTO method (name) VALUES ('simple'); +INSERT INTO study (identifier, name, method_id, parameters) +VALUES ('TEST', 'Badanie testowe', 1, '{}'); + +INSERT INTO arm (study_id, name, ratio) +VALUES (1, 'placebo', 2), + (1, 'active', 1); + INSERT INTO stratum (name, value_type) VALUES ('gender', 'factor'); +INSERT INTO stratum_in_study (stratum_id, study_id) +VALUES (1, 1); + INSERT INTO factor_constraint (stratum_id, value) -VALUES (1, 'X'); +VALUES (1, 'F'), (1, 'M'); + +INSERT INTO patient (study_id, arm_id) +VALUES (1, 1); + +INSERT INTO patient_stratum (patient_id, stratum_id) +VALUES (1, 1); -- Trigger properly raises an error here /* From ff5ec268e98ae10da698e295b86bdbfe2e70ead8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Tue, 14 Nov 2023 15:49:44 +0100 Subject: [PATCH 024/240] add dbplyr to R (for DB tests) --- DESCRIPTION | 4 +- inst/postgres/01-initialize.sql | 2 +- renv.lock | 170 +++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 4 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 4183592..13419d3 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: unbiased Title: What the Package Does (One Line, Title Case) -Version: 0.0.0.9001 +Version: 0.0.0.9002 Authors@R: person("First", "Last", , "first.last@example.com", role = c("aut", "cre"), comment = c(ORCID = "YOUR-ORCID-ID")) @@ -8,10 +8,12 @@ Description: What the package does (one paragraph). License: MIT + file LICENSE Imports: checkmate, + dbplyr, plumber Suggests: callr, httr2, + RPostgres, testthat (>= 3.0.0), usethis, withr diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index a0fe0c1..e65a254 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -77,7 +77,7 @@ CREATE TABLE numeric_constraint ( CREATE TABLE patient ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, - arm_id INT NOT NULL, + arm_id INT, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), rand_code VARCHAR(255), CONSTRAINT patient_arm_study diff --git a/renv.lock b/renv.lock index d27d571..9a3c5da 100644 --- a/renv.lock +++ b/renv.lock @@ -9,6 +9,17 @@ ] }, "Packages": { + "DBI": { + "Package": "DBI", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "b2866e62bab9378c3cc9476a1954226b" + }, "R6": { "Package": "R6", "Version": "2.5.1", @@ -50,6 +61,18 @@ ], "Hash": "c39fbec8a30d23e721980b8afb31984c" }, + "blob": { + "Package": "blob", + "Version": "1.2.4", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "methods", + "rlang", + "vctrs" + ], + "Hash": "40415719b5a479b87949f3aa0aee737c" + }, "brio": { "Package": "brio", "Version": "1.1.3", @@ -93,6 +116,16 @@ ], "Hash": "89e6d8219950eac806ae0c489052048a" }, + "cpp11": { + "Package": "cpp11", + "Version": "0.4.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "707fae4bbf73697ec8d85f9d7076c061" + }, "crayon": { "Package": "crayon", "Version": "1.5.2", @@ -115,6 +148,34 @@ ], "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" }, + "dbplyr": { + "Package": "dbplyr", + "Version": "2.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "R6", + "blob", + "cli", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "utils", + "vctrs", + "withr" + ], + "Hash": "59351f28a81f0742720b85363c4fdd61" + }, "desc": { "Package": "desc", "Version": "1.4.2", @@ -155,6 +216,29 @@ ], "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "generics", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "rlang", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" + }, "ellipsis": { "Package": "ellipsis", "Version": "0.3.2", @@ -207,6 +291,17 @@ ], "Hash": "47b5f30c720c23999b913a1a635cf0bb" }, + "generics": { + "Package": "generics", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + }, "glue": { "Package": "glue", "Version": "1.6.2", @@ -463,6 +558,21 @@ ], "Hash": "709d852d33178db54b17c722e5b1e594" }, + "purrr": { + "Package": "purrr", + "Version": "1.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "lifecycle", + "magrittr", + "rlang", + "vctrs" + ], + "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" + }, "rappdirs": { "Package": "rappdirs", "Version": "0.3.3", @@ -534,6 +644,23 @@ ], "Hash": "ca8bd84263c77310739d2cf64d84d7c9" }, + "stringr": { + "Package": "stringr", + "Version": "1.5.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "stringi", + "vctrs" + ], + "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" + }, "swagger": { "Package": "swagger", "Version": "3.33.1", @@ -597,6 +724,45 @@ ], "Hash": "a84e2cc86d07289b3b6f5069df7a004c" }, + "tidyr": { + "Package": "tidyr", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "cpp11", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "e47debdc7ce599b070c8e78e8ac0cfcf" + }, + "tidyselect": { + "Package": "tidyselect", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang", + "vctrs", + "withr" + ], + "Hash": "79540e5fcd9e0435af547d885f184fd5" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", @@ -651,7 +817,7 @@ }, "withr": { "Package": "withr", - "Version": "2.5.1", + "Version": "2.5.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -660,7 +826,7 @@ "graphics", "stats" ], - "Hash": "d77c6f74be05c33164e33fbc85540cae" + "Hash": "4b25e70111b7d644322e9513f403a272" } } } From bf5c8157118f12fe4d6781ab39cab259b2cdd1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 11:23:03 +0100 Subject: [PATCH 025/240] simplify DB structure --- inst/postgres/01-initialize.sql | 44 +++++++++++++++------------------ inst/postgres/02-examples.sql | 11 +++------ 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index e65a254..e602a80 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -31,23 +31,14 @@ CREATE TABLE arm ( CREATE TABLE stratum ( id SERIAL PRIMARY KEY, + study_id INT NOT NULL, name VARCHAR(255) NOT NULL, value_type VARCHAR(12), - CONSTRAINT chk_value_type - CHECK (value_type IN ('factor', 'numeric')) -); - -CREATE TABLE stratum_in_study ( - stratum_id INT NOT NULL, - study_id INT NOT NULL, - CONSTRAINT fk_stratum - FOREIGN KEY (stratum_id) - REFERENCES stratum (id) ON DELETE CASCADE, CONSTRAINT fk_study FOREIGN KEY (study_id) REFERENCES study (id) ON DELETE CASCADE, - CONSTRAINT uc_stratum_study - UNIQUE (stratum_id, study_id) + CONSTRAINT chk_value_type + CHECK (value_type IN ('factor', 'numeric')) ); CREATE TABLE factor_constraint ( @@ -79,12 +70,11 @@ CREATE TABLE patient ( study_id INT NOT NULL, arm_id INT, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - rand_code VARCHAR(255), + used BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT patient_arm_study FOREIGN KEY (arm_id, study_id) - REFERENCES arm (id, study_id) ON DELETE CASCADE, - CONSTRAINT uc_study_code - UNIQUE (study_id, rand_code) + REFERENCES arm (id, study_id) ON DELETE CASCADE + -- TODO: check that USED only when arm not NULL ); CREATE TABLE patient_stratum ( @@ -154,14 +144,20 @@ EXECUTE PROCEDURE check_num_stratum(); CREATE OR REPLACE FUNCTION check_patient_stratum_study() RETURNS trigger AS $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM patient AS p - INNER JOIN stratum_in_study AS s - USING (study_id) - WHERE s.stratum_id = NEW.stratum_id - ) THEN - RAISE EXCEPTION 'Stratum and patient must be assigned to the same study.'; - END IF; + DECLARE + patient_study INT := ( + SELECT study_id FROM patient + WHERE id = NEW.patient_id + ); + stratum_study INT := ( + SELECT study_id FROM stratum + WHERE id = NEW.stratum_id + ); + BEGIN + IF (patient_study <> stratum_study) THEN + RAISE EXCEPTION 'Stratum and patient must be assigned to the same study.'; + END IF; + END; RETURN NEW; END; $$ LANGUAGE plpgsql; diff --git a/inst/postgres/02-examples.sql b/inst/postgres/02-examples.sql index d7d949b..ef60b34 100644 --- a/inst/postgres/02-examples.sql +++ b/inst/postgres/02-examples.sql @@ -8,11 +8,8 @@ INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), (1, 'active', 1); -INSERT INTO stratum (name, value_type) -VALUES ('gender', 'factor'); - -INSERT INTO stratum_in_study (stratum_id, study_id) -VALUES (1, 1); +INSERT INTO stratum (study_id, name, value_type) +VALUES (1, 'gender', 'factor'); INSERT INTO factor_constraint (stratum_id, value) VALUES (1, 'F'), (1, 'M'); @@ -20,8 +17,8 @@ VALUES (1, 'F'), (1, 'M'); INSERT INTO patient (study_id, arm_id) VALUES (1, 1); -INSERT INTO patient_stratum (patient_id, stratum_id) -VALUES (1, 1); +INSERT INTO patient_stratum (patient_id, stratum_id, fct_value) +VALUES (1, 1, 'F'); -- Trigger properly raises an error here /* From a0ab50b4a6b92dab8c3f3f5e630e247c0959fefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 12:00:02 +0100 Subject: [PATCH 026/240] add temporal extension --- docker-compose.test.yaml | 2 +- inst/postgres/01-initialize.sql | 16 ++++++++++++++-- inst/postgres/02-examples.sql | 4 ++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 86eef05..dcf855e 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -1,7 +1,7 @@ version: "3.9" services: db: - image: postgres:16.0 + image: eddhannay/alpine-postgres-temporal-tables:latest container_name: unbiased_db environment: - POSTGRES_PASSWORD=postgres diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index e602a80..0db2310 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,3 +1,5 @@ +CREATE EXTENSION temporal_tables; + CREATE TABLE method ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL @@ -69,8 +71,9 @@ CREATE TABLE patient ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, arm_id INT, - timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - used BOOLEAN NOT NULL DEFAULT FALSE, + used BOOLEAN NOT NULL DEFAULT false, + -- timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + sys_period TSTZRANGE NOT NULL, CONSTRAINT patient_arm_study FOREIGN KEY (arm_id, study_id) REFERENCES arm (id, study_id) ON DELETE CASCADE @@ -234,3 +237,12 @@ CREATE TRIGGER patient_num_constraint BEFORE INSERT ON patient_stratum FOR EACH ROW EXECUTE PROCEDURE check_num_patient(); + +-- Versioning + +CREATE TABLE patient_history (LIKE patient); + +CREATE TRIGGER patient_versioning +BEFORE INSERT OR UPDATE OR DELETE ON patient +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'patient_history', true); diff --git a/inst/postgres/02-examples.sql b/inst/postgres/02-examples.sql index ef60b34..1118e26 100644 --- a/inst/postgres/02-examples.sql +++ b/inst/postgres/02-examples.sql @@ -20,6 +20,10 @@ VALUES (1, 1); INSERT INTO patient_stratum (patient_id, stratum_id, fct_value) VALUES (1, 1, 'F'); +UPDATE patient +SET used = true +WHERE id = 1; + -- Trigger properly raises an error here /* INSERT INTO numeric_constraint (stratum_id) From 00a124bdc483582b7af0a3b1980650f3d28cf7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 12:12:51 +0100 Subject: [PATCH 027/240] add metadata storage --- inst/postgres/00-metadata.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 inst/postgres/00-metadata.sql diff --git a/inst/postgres/00-metadata.sql b/inst/postgres/00-metadata.sql new file mode 100644 index 0000000..c427cba --- /dev/null +++ b/inst/postgres/00-metadata.sql @@ -0,0 +1,7 @@ +CREATE TABLE settings ( + key TEXT NOT NULL, + value TEXT NOT NULL +); + +INSERT INTO settings (key, value) +VALUES ('schema_version', '0.0.0.9002'); From ba5e24c43d081d5edeb227e53836702f936a94b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 12:28:40 +0100 Subject: [PATCH 028/240] add versioning and reorganize SQL --- inst/postgres/01-initialize.sql | 17 +++---- inst/postgres/03-versioning.sql | 48 +++++++++++++++++++ .../{02-examples.sql => 10-examples.sql} | 0 3 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 inst/postgres/03-versioning.sql rename inst/postgres/{02-examples.sql => 10-examples.sql} (100%) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 0db2310..6de13c6 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -11,7 +11,8 @@ CREATE TABLE study ( name VARCHAR(255) NOT NULL, method_id INT NOT NULL, parameters JSONB, - timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + -- timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + sys_period TSTZRANGE NOT NULL, CONSTRAINT study_method FOREIGN KEY (method_id) REFERENCES method (id) @@ -22,6 +23,7 @@ CREATE TABLE arm ( study_id INT NOT NULL, name VARCHAR(255) NOT NULL, ratio INT NOT NULL DEFAULT 1, + sys_period TSTZRANGE NOT NULL, CONSTRAINT arm_study FOREIGN KEY (study_id) REFERENCES study (id) ON DELETE CASCADE, @@ -36,6 +38,7 @@ CREATE TABLE stratum ( study_id INT NOT NULL, name VARCHAR(255) NOT NULL, value_type VARCHAR(12), + sys_period TSTZRANGE NOT NULL, CONSTRAINT fk_study FOREIGN KEY (study_id) REFERENCES study (id) ON DELETE CASCADE, @@ -46,6 +49,7 @@ CREATE TABLE stratum ( CREATE TABLE factor_constraint ( stratum_id INT NOT NULL, value VARCHAR(255) NOT NULL, + sys_period TSTZRANGE NOT NULL, CONSTRAINT factor_stratum FOREIGN KEY (stratum_id) REFERENCES stratum (id) ON DELETE CASCADE, @@ -57,6 +61,7 @@ CREATE TABLE numeric_constraint ( stratum_id INT NOT NULL, min_value FLOAT, max_value FLOAT, + sys_period TSTZRANGE NOT NULL, CONSTRAINT numeric_stratum FOREIGN KEY (stratum_id) REFERENCES stratum (id) ON DELETE CASCADE, @@ -85,6 +90,7 @@ CREATE TABLE patient_stratum ( stratum_id INT NOT NULL, fct_value VARCHAR(255), num_value FLOAT, + sys_period TSTZRANGE NOT NULL, CONSTRAINT fk_patient FOREIGN KEY (patient_id) REFERENCES patient (id) ON DELETE CASCADE, @@ -237,12 +243,3 @@ CREATE TRIGGER patient_num_constraint BEFORE INSERT ON patient_stratum FOR EACH ROW EXECUTE PROCEDURE check_num_patient(); - --- Versioning - -CREATE TABLE patient_history (LIKE patient); - -CREATE TRIGGER patient_versioning -BEFORE INSERT OR UPDATE OR DELETE ON patient -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'patient_history', true); diff --git a/inst/postgres/03-versioning.sql b/inst/postgres/03-versioning.sql new file mode 100644 index 0000000..9572597 --- /dev/null +++ b/inst/postgres/03-versioning.sql @@ -0,0 +1,48 @@ +CREATE TABLE study_history (LIKE study); + +CREATE TRIGGER study_versioning +BEFORE INSERT OR UPDATE OR DELETE ON study +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'study_history', true); + +CREATE TABLE arm_history (LIKE arm); + +CREATE TRIGGER arm_versioning +BEFORE INSERT OR UPDATE OR DELETE ON arm +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'arm_history', true); + +CREATE TABLE stratum_history (LIKE stratum); + +CREATE TRIGGER stratum_versioning +BEFORE INSERT OR UPDATE OR DELETE ON stratum +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'stratum_history', true); + +CREATE TABLE factor_constraint_history (LIKE factor_constraint); + +CREATE TRIGGER fct_constraint_versioning +BEFORE INSERT OR UPDATE OR DELETE ON factor_constraint +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'factor_constraint_history', true); + +CREATE TABLE numeric_constraint_history (LIKE numeric_constraint); + +CREATE TRIGGER num_constraint_versioning +BEFORE INSERT OR UPDATE OR DELETE ON numeric_constraint +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'numeric_constraint_history', true); + +CREATE TABLE patient_history (LIKE patient); + +CREATE TRIGGER patient_versioning +BEFORE INSERT OR UPDATE OR DELETE ON patient +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'patient_history', true); + +CREATE TABLE patient_stratum_history (LIKE patient_stratum); + +CREATE TRIGGER patient_stratum_versioning +BEFORE INSERT OR UPDATE OR DELETE ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'patient_stratum_history', true); diff --git a/inst/postgres/02-examples.sql b/inst/postgres/10-examples.sql similarity index 100% rename from inst/postgres/02-examples.sql rename to inst/postgres/10-examples.sql From 3eac5d6a4bf156b8c1e0e98643f0f2f98dcda59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 12:37:46 +0100 Subject: [PATCH 029/240] add method history after all --- inst/postgres/03-versioning.sql | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/inst/postgres/03-versioning.sql b/inst/postgres/03-versioning.sql index 9572597..a10d6de 100644 --- a/inst/postgres/03-versioning.sql +++ b/inst/postgres/03-versioning.sql @@ -1,3 +1,10 @@ +CREATE TABLE method_history (LIKE method); + +CREATE TRIGGER method_versioning +BEFORE INSERT OR UPDATE OR DELETE ON method +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'method_history', true); + CREATE TABLE study_history (LIKE study); CREATE TRIGGER study_versioning From e1c108357d721b1991b439ac493a2f2b0c376d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 12:47:15 +0100 Subject: [PATCH 030/240] fix some details --- inst/postgres/01-initialize.sql | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 6de13c6..fe81719 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -2,7 +2,8 @@ CREATE EXTENSION temporal_tables; CREATE TABLE method ( id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL + name VARCHAR(255) NOT NULL, + sys_period TSTZRANGE NOT NULL ); CREATE TABLE study ( @@ -81,8 +82,9 @@ CREATE TABLE patient ( sys_period TSTZRANGE NOT NULL, CONSTRAINT patient_arm_study FOREIGN KEY (arm_id, study_id) - REFERENCES arm (id, study_id) ON DELETE CASCADE - -- TODO: check that USED only when arm not NULL + REFERENCES arm (id, study_id) ON DELETE CASCADE, + CONSTRAINT used_with_arm + CHECK (NOT used OR arm_id IS NOT NULL) ); CREATE TABLE patient_stratum ( From ea36edb473ddea68ec96cfb7437e7bba194e0412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 14:13:01 +0100 Subject: [PATCH 031/240] add simple DB tests --- Dockerfile | 4 +- renv.lock | 88 ++++++++++++++++++++++++++++++++++++++ tests/testthat/setup-CI.R | 3 ++ tests/testthat/setup-api.R | 1 + tests/testthat/test-DB.R | 37 ++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/testthat/setup-CI.R create mode 100644 tests/testthat/test-DB.R diff --git a/Dockerfile b/Dockerfile index 8d72954..1dbe6e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ RUN apt update && apt-get install -y --no-install-recommends \ # httpuv libz-dev \ # sodium - libsodium-dev + libsodium-dev \ + # RPostgres + libpq-dev libssl-dev ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/renv.lock b/renv.lock index 9a3c5da..9e42a59 100644 --- a/renv.lock +++ b/renv.lock @@ -30,6 +30,25 @@ ], "Hash": "470851b6d5d0ac559e9d01bb352b4021" }, + "RPostgres": { + "Package": "RPostgres", + "Version": "1.4.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "bit64", + "blob", + "cpp11", + "hms", + "lubridate", + "methods", + "plogr", + "withr" + ], + "Hash": "a3ccabc3de4657c14185c91f3e6d4b60" + }, "Rcpp": { "Package": "Rcpp", "Version": "1.0.11", @@ -61,6 +80,30 @@ ], "Hash": "c39fbec8a30d23e721980b8afb31984c" }, + "bit": { + "Package": "bit", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "d242abec29412ce988848d0294b208fd" + }, + "bit64": { + "Package": "bit64", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "bit", + "methods", + "stats", + "utils" + ], + "Hash": "9fe98599ca456d6552421db0d6772d8f" + }, "blob": { "Package": "blob", "Version": "1.2.4", @@ -313,6 +356,20 @@ ], "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" }, + "hms": { + "Package": "hms", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "lifecycle", + "methods", + "pkgconfig", + "rlang", + "vctrs" + ], + "Hash": "b59377caa7ed00fa41808342002138f9" + }, "httpuv": { "Package": "httpuv", "Version": "1.6.11", @@ -381,6 +438,19 @@ ], "Hash": "001cecbeac1cff9301bdc3775ee46a86" }, + "lubridate": { + "Package": "lubridate", + "Version": "1.9.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "generics", + "methods", + "timechange" + ], + "Hash": "680ad542fbcf801442c83a6ac5a2126c" + }, "magrittr": { "Package": "magrittr", "Version": "2.0.3", @@ -477,6 +547,13 @@ ], "Hash": "903d68319ae9923fb2e2ee7fa8230b91" }, + "plogr": { + "Package": "plogr", + "Version": "0.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "09eb987710984fc2905c7129c7d85e65" + }, "plumber": { "Package": "plumber", "Version": "1.2.1", @@ -763,6 +840,17 @@ ], "Hash": "79540e5fcd9e0435af547d885f184fd5" }, + "timechange": { + "Package": "timechange", + "Version": "0.2.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "8548b44f79a35ba1791308b61e6012d7" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", diff --git a/tests/testthat/setup-CI.R b/tests/testthat/setup-CI.R new file mode 100644 index 0000000..4d76a08 --- /dev/null +++ b/tests/testthat/setup-CI.R @@ -0,0 +1,3 @@ +is_CI <- function() { + isTRUE(as.logical(Sys.getenv("CI"))) +} diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 3f0b73c..dccbeb0 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -1,4 +1,5 @@ library(checkmate) +library(dbplyr) library(httr2) api_url <- "http://api:3838" diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R new file mode 100644 index 0000000..1f2c52d --- /dev/null +++ b/tests/testthat/test-DB.R @@ -0,0 +1,37 @@ +skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") + +# Define connection ---- +conn <- DBI::dbConnect( + RPostgres::Postgres(), + dbname = "postgres", + host = "db", + port = 5432, + user = "postgres", + password = "postgres" +) + +on.exit({ + DBI::dbDisconnect(conn) +}) + +# Setup constants ---- +versioned_tables <- c( + "method", "study", "arm", "stratum", "factor_constraint", + "numeric_constraint", "patient", "patient_stratum" +) +nonversioned_tables <- c("settings") + +# Test values ---- +test_that("database contains base tables", { + expect_contains( + DBI::dbListTables(conn), + c(versioned_tables, nonversioned_tables) + ) +}) + +test_that("database contains history tables", { + expect_contains( + DBI::dbListTables(conn), + glue::glue("{versioned_tables}_history") + ) +}) From 22a7dbd1977be54a62f60d87a036fd7563462cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 14:30:33 +0100 Subject: [PATCH 032/240] change service name --- docker-compose.test.yaml | 6 +++--- tests/testthat/test-DB.R | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index dcf855e..ef098a2 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -1,8 +1,8 @@ version: "3.9" services: - db: + postgres: image: eddhannay/alpine-postgres-temporal-tables:latest - container_name: unbiased_db + container_name: unbiased_postgres environment: - POSTGRES_PASSWORD=postgres networks: @@ -15,7 +15,7 @@ services: image: unbiased container_name: unbiased_api depends_on: - - db + - postgres networks: - test_net tests: diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R index 1f2c52d..871d1c4 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB.R @@ -4,7 +4,7 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") conn <- DBI::dbConnect( RPostgres::Postgres(), dbname = "postgres", - host = "db", + host = "postgres", port = 5432, user = "postgres", password = "postgres" From 1f9d75c13ca3aeabbe67964b7c082083943c4fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 14:36:49 +0100 Subject: [PATCH 033/240] give Postgres more time to start --- tests/testthat/test-DB.R | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R index 871d1c4..f8aa5af 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB.R @@ -1,14 +1,21 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") # Define connection ---- -conn <- DBI::dbConnect( - RPostgres::Postgres(), - dbname = "postgres", - host = "postgres", - port = 5432, - user = "postgres", - password = "postgres" -) + + +conn <- try_again(5, { + # Some more time for Postgres to start + Sys.sleep(1) + + DBI::dbConnect( + RPostgres::Postgres(), + dbname = "postgres", + host = "postgres", + port = 5432, + user = "postgres", + password = "postgres" + ) +}) on.exit({ DBI::dbDisconnect(conn) From a94a425a2c3c1b973573294f19220e974b137c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 14:47:33 +0100 Subject: [PATCH 034/240] use insistently instead --- tests/testthat/test-DB.R | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R index f8aa5af..eab3ec2 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB.R @@ -1,12 +1,7 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") # Define connection ---- - - -conn <- try_again(5, { - # Some more time for Postgres to start - Sys.sleep(1) - +conn <- purrr::insistently(function() { DBI::dbConnect( RPostgres::Postgres(), dbname = "postgres", @@ -15,7 +10,7 @@ conn <- try_again(5, { user = "postgres", password = "postgres" ) -}) +}, rate = purrr::rate_delay(2, max_times = 5))() on.exit({ DBI::dbDisconnect(conn) From 27f78bebba70d2ae99cc62d8991102ae5cad6ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 15:23:01 +0100 Subject: [PATCH 035/240] add package version test against DB --- tests/testthat/setup-api.R | 1 + tests/testthat/test-DB.R | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index dccbeb0..fca5dde 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -1,4 +1,5 @@ library(checkmate) +library(dplyr) library(dbplyr) library(httr2) diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R index eab3ec2..64c00db 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB.R @@ -37,3 +37,13 @@ test_that("database contains history tables", { glue::glue("{versioned_tables}_history") ) }) + +test_that("database version is the same as package version", { + expect_identical( + tbl(conn, "settings") |> + filter(key == "schema_version") |> + pull(value), + packageVersion("unbiased") |> + as.character() + ) +}) From ba0dbf2ddb7c03ca4330beae322c60aca3f2dcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Wed, 15 Nov 2023 15:31:43 +0100 Subject: [PATCH 036/240] separate DB connection logic --- tests/testthat/setup-DB.R | 16 ++++++++++++++++ tests/testthat/test-DB.R | 16 ---------------- 2 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 tests/testthat/setup-DB.R diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R new file mode 100644 index 0000000..5f4b640 --- /dev/null +++ b/tests/testthat/setup-DB.R @@ -0,0 +1,16 @@ +skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") + +# Define connection ---- +conn <- purrr::insistently(function() { + DBI::dbConnect( + RPostgres::Postgres(), + dbname = "postgres", + host = "postgres", + port = 5432, + user = "postgres", + password = "postgres" + ) +}, rate = purrr::rate_delay(2, max_times = 5))() + +# Close DB connection upon exiting +withr::defer({ DBI::dbDisconnect(conn) }, teardown_env()) diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB.R index 64c00db..18a0c21 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB.R @@ -1,21 +1,5 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") -# Define connection ---- -conn <- purrr::insistently(function() { - DBI::dbConnect( - RPostgres::Postgres(), - dbname = "postgres", - host = "postgres", - port = 5432, - user = "postgres", - password = "postgres" - ) -}, rate = purrr::rate_delay(2, max_times = 5))() - -on.exit({ - DBI::dbDisconnect(conn) -}) - # Setup constants ---- versioned_tables <- c( "method", "study", "arm", "stratum", "factor_constraint", From 985550630bde121352817e47ad07741a5595c8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 16 Nov 2023 10:52:57 +0100 Subject: [PATCH 037/240] test study table --- tests/testthat/{test-DB.R => test-DB-0.R} | 2 + tests/testthat/test-DB-study.R | 73 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) rename tests/testthat/{test-DB.R => test-DB-0.R} (88%) create mode 100644 tests/testthat/test-DB-study.R diff --git a/tests/testthat/test-DB.R b/tests/testthat/test-DB-0.R similarity index 88% rename from tests/testthat/test-DB.R rename to tests/testthat/test-DB-0.R index 18a0c21..9a9d087 100644 --- a/tests/testthat/test-DB.R +++ b/tests/testthat/test-DB-0.R @@ -1,3 +1,5 @@ +# Named with '0' to make sure that this one runs first because it validates +# basic properties of the database skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") # Setup constants ---- diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R new file mode 100644 index 0000000..80ebc2d --- /dev/null +++ b/tests/testthat/test-DB-study.R @@ -0,0 +1,73 @@ +skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") + +test_that("there's a study named 'Badanie testowe' in 'study' table", { + expect_contains( + tbl(conn, "study") |> + pull(name), + "Badanie testowe" + ) +}) + +test_that("study named 'Badanie testowe' has an identifier 'TEST'", { + expect_identical( + tbl(conn, "study") |> + filter(name == "Badanie testowe") |> + pull(identifier), + "TEST" + ) +}) + +test_that("it is enough to provide a name, an identifier, and a method id", { + expect_no_error({ + tbl(conn, "study") |> + rows_append( + tibble( + identifier = "FINE", + name = "Correctly working study", + method_id = 1 + ), + copy = TRUE, in_place = TRUE + ) + }) +}) + +new_study_id <- tbl(conn, "study") |> + filter(identifier == "FINE") |> + pull(id) + +test_that("can't insert a study that references a non-existing method", { + expect_error({ + tbl(conn, "study") |> + rows_append( + tibble( + identifier = "error", + name = "Exception-throwing study", + method_id = 28 + ), + copy = TRUE, in_place = TRUE + ) + }) +}) + +test_that("deleting archivizes a study", { + expect_no_error({ + tbl(conn, "study") |> + rows_delete( + tibble(id = new_study_id), + copy = TRUE, in_place = TRUE, unmatched = "ignore" + ) + }) + + expect_identical( + tbl(conn, "study_history") |> + filter(id == new_study_id) |> + select(-parameters, -sys_period) |> + collect(), + tibble( + id = new_study_id, + identifier = "FINE", + name = "Correctly working study", + method_id = 1L + ) + ) +}) From 716409e47353dae9ca054ab455391a99a59d474d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 16 Nov 2023 14:23:54 +0100 Subject: [PATCH 038/240] separate API and DB starts --- Dockerfile | 2 +- NAMESPACE | 2 + R/run_api.R | 24 +++++++++++ R/run_db.R | 41 +++++++++++++++++++ docker-compose.test.yaml | 11 +++++ inst/postgres/00-metadata.sql | 2 + inst/postgres/01-initialize.sql | 2 - inst/postgres/10-values.sql | 2 + .../{10-examples.sql => 90-examples.sql} | 3 -- man/run_unbiased.Rd | 21 ++++++++++ man/run_unbiased_db.Rd | 17 ++++++++ tests/testthat/setup-DB.R | 11 +---- 12 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 R/run_api.R create mode 100644 R/run_db.R create mode 100644 inst/postgres/10-values.sql rename inst/postgres/{10-examples.sql => 90-examples.sql} (93%) create mode 100644 man/run_unbiased.Rd create mode 100644 man/run_unbiased_db.Rd diff --git a/Dockerfile b/Dockerfile index 1dbe6e5..64e84d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,4 @@ EXPOSE 3838 ARG github_sha ENV GITHUB_SHA=${github_sha} -CMD ["R", "-e", "plumber::plumb(dir = fs::path_package('unbiased', 'api')) |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] +CMD ["R", "-e", "unbiased::run_unbiased()"] diff --git a/NAMESPACE b/NAMESPACE index d37053d..9a8c214 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,3 +1,5 @@ # Generated by roxygen2: do not edit by hand export(randomize_simple) +export(run_unbiased) +export(run_unbiased_db) diff --git a/R/run_api.R b/R/run_api.R new file mode 100644 index 0000000..a85df4f --- /dev/null +++ b/R/run_api.R @@ -0,0 +1,24 @@ +#' Run API +#' +#' @description +#' Starts \pkg{unbiased} API. +#' +#' @param host `character(1)`\cr +#' Host URL. +#' @param port `integer(1)`\cr +#' Port to serve API under. +#' +#' @return Function called to serve the API in the caller thread. +#' +#' @export +run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { + assignInMyNamespace("CONN", connect_to_db()) + + on.exit({ + DBI::dbDisconnect(CONN) + assignInMyNamespace("CONN", NULL) + }) + + plumber::plumb(dir = fs::path_package("unbiased", "api")) |> + plumber::pr_run(host = host, port = port, ...) +} diff --git a/R/run_db.R b/R/run_db.R new file mode 100644 index 0000000..9d0dadd --- /dev/null +++ b/R/run_db.R @@ -0,0 +1,41 @@ +CONN <- NULL + +#' Run local DB +#' +#' @description +#' Starts a Docker container containing a Postgres database for unbiased API +#' storage and sets environment variables to allow API to connect. Do not run +#' if an external Postgres database exists already, set appropriate env vars +#' instead. +#' +#' @return Return code describing success or lack thereof. +#' +#' @export +run_unbiased_db <- function() { + Sys.setenv(POSTGRES_DB = "postgres") + Sys.setenv(POSTGRES_HOST = "postgres") + Sys.setenv(POSTGRES_PORT = 5432) + Sys.setenv(POSTGRES_USER = "postgres") + Sys.setenv(POSTGRES_PASSWORD = "postgres") + + system(glue::glue( + "docker run", + "-e POSTGRES_PASSWORD={Sys.getenv('POSTGRES_PASSWORD')}", + "-p {Sys.getenv('POSTGRES_PORT')}:5432", + "-v ./inst/postgres/:/docker-entrypoint-initdb.d/", + "-d --name unbiased_db_local", + "eddhannay/alpine-postgres-temporal-tables:latest", + .sep = " " + )) +} + +connect_to_db <- purrr::insistently(function() { + DBI::dbConnect( + RPostgres::Postgres(), + dbname = Sys.getenv("POSTGRES_DB"), + host = Sys.getenv("POSTGRES_HOST"), + port = Sys.getenv("POSTGRES_PORT"), + user = Sys.getenv("POSTGRES_USER"), + password = Sys.getenv("POSTGRES_PASSWORD") + ) +}, rate = purrr::rate_delay(2, max_times = 5)) diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index ef098a2..0eeec63 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -16,6 +16,12 @@ services: container_name: unbiased_api depends_on: - postgres + environment: + - POSTGRES_DB=postgres + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres networks: - test_net tests: @@ -25,6 +31,11 @@ services: - api environment: - CI=true + - POSTGRES_DB=postgres + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres networks: - test_net command: R -e "testthat::test_package('unbiased')" diff --git a/inst/postgres/00-metadata.sql b/inst/postgres/00-metadata.sql index c427cba..02b7776 100644 --- a/inst/postgres/00-metadata.sql +++ b/inst/postgres/00-metadata.sql @@ -1,3 +1,5 @@ +CREATE EXTENSION temporal_tables; + CREATE TABLE settings ( key TEXT NOT NULL, value TEXT NOT NULL diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index fe81719..1f6fc60 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,5 +1,3 @@ -CREATE EXTENSION temporal_tables; - CREATE TABLE method ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, diff --git a/inst/postgres/10-values.sql b/inst/postgres/10-values.sql new file mode 100644 index 0000000..65b25e5 --- /dev/null +++ b/inst/postgres/10-values.sql @@ -0,0 +1,2 @@ +INSERT INTO method (name) +VALUES ('simple'), ('blocked'); diff --git a/inst/postgres/10-examples.sql b/inst/postgres/90-examples.sql similarity index 93% rename from inst/postgres/10-examples.sql rename to inst/postgres/90-examples.sql index 1118e26..8d1498b 100644 --- a/inst/postgres/10-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,6 +1,3 @@ -INSERT INTO method (name) -VALUES ('simple'); - INSERT INTO study (identifier, name, method_id, parameters) VALUES ('TEST', 'Badanie testowe', 1, '{}'); diff --git a/man/run_unbiased.Rd b/man/run_unbiased.Rd new file mode 100644 index 0000000..b6a1478 --- /dev/null +++ b/man/run_unbiased.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run_api.R +\name{run_unbiased} +\alias{run_unbiased} +\title{Run API} +\usage{ +run_unbiased(host = "0.0.0.0", port = 3838, ...) +} +\arguments{ +\item{host}{\code{character(1)}\cr +Host URL.} + +\item{port}{\code{integer(1)}\cr +Port to serve API under.} +} +\value{ +Function called to serve the API in the caller thread. +} +\description{ +Starts \pkg{unbiased} API. +} diff --git a/man/run_unbiased_db.Rd b/man/run_unbiased_db.Rd new file mode 100644 index 0000000..b73d63f --- /dev/null +++ b/man/run_unbiased_db.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run_db.R +\name{run_unbiased_db} +\alias{run_unbiased_db} +\title{Run local DB} +\usage{ +run_unbiased_db() +} +\value{ +Return code describing success or lack thereof. +} +\description{ +Starts a Docker container containing a Postgres database for unbiased API +storage and sets environment variables to allow API to connect. Do not run +if an external Postgres database exists already, set appropriate env vars +instead. +} diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R index 5f4b640..7db3d14 100644 --- a/tests/testthat/setup-DB.R +++ b/tests/testthat/setup-DB.R @@ -1,16 +1,7 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") # Define connection ---- -conn <- purrr::insistently(function() { - DBI::dbConnect( - RPostgres::Postgres(), - dbname = "postgres", - host = "postgres", - port = 5432, - user = "postgres", - password = "postgres" - ) -}, rate = purrr::rate_delay(2, max_times = 5))() +conn <- connect_to_db() # Close DB connection upon exiting withr::defer({ DBI::dbDisconnect(conn) }, teardown_env()) From 17e233df1e016265ba06a22b83b8643a26e922e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Thu, 16 Nov 2023 14:50:16 +0100 Subject: [PATCH 039/240] mostly write DB insertions for study --- R/study-define.R | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 R/study-define.R diff --git a/R/study-define.R b/R/study-define.R new file mode 100644 index 0000000..6180951 --- /dev/null +++ b/R/study-define.R @@ -0,0 +1,78 @@ +define_study <- function(name, identifier, method, arms, + strata = list(), + parameters = NULL, + ratio = rep(1, times = length(arms))) { + # Assertions + method_id <- tbl(CONN, "method") |> + filter(name == !!method) |> + pull(id) + assert_int(method_id) + + assert_integerish(ratio, lower = 0, len = length(arms)) + + # Actual code + study_id <- tbl(CONN, "study") |> + rows_insert( + tibble( + identifier = identifier, + name = name, + method_id = method_id, + parameters = jsonlite::toJSON(parameters) + ), + copy = TRUE, in_place = TRUE, returning = id + ) |> + get_returned_rows() + + purrr::walk2(arms, ratio, function(arm, prop) { + tbl(CONN, "arm") |> + rows_insert( + tibble( + study_id = study_id, + name = arm, + ratio = prop + ), + copy = TRUE, in_place = TRUE + ) + }) + + purrr::iwalk(strata, function(stratum, name) { + if (is.numeric(stratum)) { + # Numeric case + stratum_id <- tbl(CONN, "stratum") |> + rows_insert( + tibble( + study_id = study_id, + name = name, + value_type = "numeric" + ), + copy = TRUE, in_place = TRUE, returning = id + ) |> + get_returned_rows() + + # TODO: how to set min/max values? + } else { + # Factor case + stratum_id <- tbl(CONN, "stratum") |> + rows_insert( + tibble( + study_id = study_id, + name = name, + value_type = "factor" + ), + copy = TRUE, in_place = TRUE, returning = id + ) |> + get_returned_rows() + + purrr::walk(stratum, function(value) { + tbl(CONN, "factor_constraint") |> + rows_insert( + tibble( + stratum_id = stratum_id, + value = value + ), + copy = TRUE, in_place = TRUE + ) + }) + } + }) +} From e62152d2972a71c62455e9b9c675e2b81fd760d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 11:20:25 +0100 Subject: [PATCH 040/240] create working study submit endpoint --- NAMESPACE | 2 ++ R/randomize-simple.R | 6 ++---- R/run_db.R | 6 ++++-- R/study-define.R | 21 ++++++++++++--------- R/unbiased-package.R | 9 +++++++++ inst/api/plumber.R | 25 +++++++++++++++++++++++++ man/unbiased-package.Rd | 15 +++++++++++++++ 7 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 R/unbiased-package.R create mode 100644 man/unbiased-package.Rd diff --git a/NAMESPACE b/NAMESPACE index 9a8c214..ed34d40 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,3 +3,5 @@ export(randomize_simple) export(run_unbiased) export(run_unbiased_db) +import(checkmate) +import(dplyr) diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 1c6dadd..3c342dc 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -16,10 +16,8 @@ #' #' @export randomize_simple <- function(arms, ratio) { - checkmate::assert_character( - arms, any.missing = FALSE, unique = TRUE, min.chars = 1 - ) - checkmate::assert_numeric( + assert_character(arms, any.missing = FALSE, unique = TRUE, min.chars = 1) + assert_numeric( ratio, any.missing = FALSE, lower = 0, finite = TRUE, len = length(arms) ) diff --git a/R/run_db.R b/R/run_db.R index 9d0dadd..6b6fe68 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -13,7 +13,7 @@ CONN <- NULL #' @export run_unbiased_db <- function() { Sys.setenv(POSTGRES_DB = "postgres") - Sys.setenv(POSTGRES_HOST = "postgres") + Sys.setenv(POSTGRES_HOST = "127.0.0.1") Sys.setenv(POSTGRES_PORT = 5432) Sys.setenv(POSTGRES_USER = "postgres") Sys.setenv(POSTGRES_PASSWORD = "postgres") @@ -22,7 +22,9 @@ run_unbiased_db <- function() { "docker run", "-e POSTGRES_PASSWORD={Sys.getenv('POSTGRES_PASSWORD')}", "-p {Sys.getenv('POSTGRES_PORT')}:5432", - "-v ./inst/postgres/:/docker-entrypoint-initdb.d/", + # Docker engine v23+ allows relative paths on host, so it'd be simply + # ./inst/postgres, but v23 was pretty new when I was writing this + "-v {fs::path_wd('inst', 'postgres')}:/docker-entrypoint-initdb.d/", "-d --name unbiased_db_local", "eddhannay/alpine-postgres-temporal-tables:latest", .sep = " " diff --git a/R/study-define.R b/R/study-define.R index 6180951..065264d 100644 --- a/R/study-define.R +++ b/R/study-define.R @@ -12,20 +12,21 @@ define_study <- function(name, identifier, method, arms, # Actual code study_id <- tbl(CONN, "study") |> - rows_insert( + rows_append( tibble( identifier = identifier, name = name, method_id = method_id, - parameters = jsonlite::toJSON(parameters) + parameters = jsonlite::toJSON(parameters, auto_unbox = FALSE) ), copy = TRUE, in_place = TRUE, returning = id ) |> - get_returned_rows() + dbplyr::get_returned_rows() |> + pull(id) purrr::walk2(arms, ratio, function(arm, prop) { tbl(CONN, "arm") |> - rows_insert( + rows_append( tibble( study_id = study_id, name = arm, @@ -39,7 +40,7 @@ define_study <- function(name, identifier, method, arms, if (is.numeric(stratum)) { # Numeric case stratum_id <- tbl(CONN, "stratum") |> - rows_insert( + rows_append( tibble( study_id = study_id, name = name, @@ -47,13 +48,14 @@ define_study <- function(name, identifier, method, arms, ), copy = TRUE, in_place = TRUE, returning = id ) |> - get_returned_rows() + dbplyr::get_returned_rows() |> + pull(id) # TODO: how to set min/max values? } else { # Factor case stratum_id <- tbl(CONN, "stratum") |> - rows_insert( + rows_append( tibble( study_id = study_id, name = name, @@ -61,11 +63,12 @@ define_study <- function(name, identifier, method, arms, ), copy = TRUE, in_place = TRUE, returning = id ) |> - get_returned_rows() + dbplyr::get_returned_rows() |> + pull(id) purrr::walk(stratum, function(value) { tbl(CONN, "factor_constraint") |> - rows_insert( + rows_append( tibble( stratum_id = stratum_id, value = value diff --git a/R/unbiased-package.R b/R/unbiased-package.R new file mode 100644 index 0000000..0bca4eb --- /dev/null +++ b/R/unbiased-package.R @@ -0,0 +1,9 @@ +#' @import checkmate +#' @import dplyr +#' +#' @keywords internal +"_PACKAGE" + +## usethis namespace: start +## usethis namespace: end +NULL diff --git a/inst/api/plumber.R b/inst/api/plumber.R index e271b38..7b32d32 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -6,6 +6,31 @@ function(api) { plumber::pr_mount("/meta", meta) } +#* Define study to randomize +#* +#* @param identifier:str Study code, at most 12 characters. +#* @param name:str Full study name. +#* @param method:str Randomization method to apply. +#* @param arms:[str] Arm names to use. +#* @param ratio:[int] Arm ratios, must be the same length as arm names. +#* @param strata:object List of character vectors, each list element being a stratum and each string being a possible stratum value. Could possibly take a numeric structure as well instead of a character vector, e.g. `{"min": 1, "max": 10}`. It just needs handling by checking whether the inner list is named or not, I'd say. +#* @param parameters:object Parameters to pass to randomization. +#* +#* @post /study +function(identifier, name, method, arms, ratio, strata, parameters, req, res) { + # Coerce types (plumber doesn't do that) + ratio <- as.integer(ratio) + + # Assertions + + + # Define study + unbiased:::define_study( + name, identifier, method, arms, + strata = strata, parameters = parameters, ratio = ratio + ) +} + #* Randomize one patient #* #* @param strata:object diff --git a/man/unbiased-package.Rd b/man/unbiased-package.Rd new file mode 100644 index 0000000..f21c076 --- /dev/null +++ b/man/unbiased-package.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/unbiased-package.R +\docType{package} +\name{unbiased-package} +\alias{unbiased} +\alias{unbiased-package} +\title{unbiased: What the Package Does (One Line, Title Case)} +\description{ +What the package does (one paragraph). +} +\author{ +\strong{Maintainer}: First Last \email{first.last@example.com} (\href{https://orcid.org/YOUR-ORCID-ID}{ORCID}) + +} +\keyword{internal} From a2f57ffe74393e31910803c9042114f93235cb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:01:15 +0100 Subject: [PATCH 041/240] assertions and docs for define_study --- R/study-define.R | 62 +++++++++++++++++++++++++++++++++++++++++++--- inst/api/plumber.R | 2 +- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/R/study-define.R b/R/study-define.R index 065264d..8b151d5 100644 --- a/R/study-define.R +++ b/R/study-define.R @@ -1,15 +1,69 @@ -define_study <- function(name, identifier, method, arms, +#' Define study +#' +#' @description +#' Creates a study with specified parameters and publishes it to the DB. +#' +#' @param name `character(1)`\cr +#' Full study name. +#' @param identifier `character(1)`\cr +#' Study code, at most 12 characters. +#' @param arms `character()`\cr +#' Arm names to use. +#' @param method `character(1)`\cr +#' Randomization method to apply. +#' @param ratio `integer()`\cr +#' Arm ratios, must be positive and the same length as arm names. +#' @param strata `list()`\cr +#' List of character vectors, each list element being a stratum and each string +#' being a possible stratum value. Could possibly take a numeric structure as +#' well instead of a character vector, e.g. `list(min = 1, max = 10)`. It just +#' needs handling by checking whether the inner list is named or not, I'd say. +#' @param parameters `list()`\cr +#' Parameters to pass to randomization. +#' +#' @return This function is called for the side effect of updating the DB. +#' +#' @examples +#' \dontrun{ +#' define_study( +#' "DEMO", "Demonstrational study", c("placebo", "active"), +#' method = "simple", +#' strata = list(gender = c("F", "M"), working = c("yes", "no")) +#' ) +#' } +#' +#' @export +define_study <- function(name, identifier, arms, + method = c("simple", "block"), strata = list(), parameters = NULL, ratio = rep(1, times = length(arms))) { + method <- match.arg(method) + # Assertions + assert_string(name) + assert_string(identifier, max.chars = 12) + + assert_character( + arms, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE + ) + assert_integerish(ratio, lower = 0, any.missing = FALSE, len = length(arms)) + + assert_list(strata, names = "unique", any.missing = FALSE) + purrr::walk(strata, function(stratum) { + # TODO: when allowing numeric strata, change the assertions here + assert_character( + stratum, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE + ) + }) + + assert_list(parameters, names = "unique", null.ok = TRUE) + method_id <- tbl(CONN, "method") |> filter(name == !!method) |> pull(id) assert_int(method_id) - assert_integerish(ratio, lower = 0, len = length(arms)) - # Actual code study_id <- tbl(CONN, "study") |> rows_append( @@ -71,7 +125,7 @@ define_study <- function(name, identifier, method, arms, rows_append( tibble( stratum_id = stratum_id, - value = value + value = as.character(value) ), copy = TRUE, in_place = TRUE ) diff --git a/inst/api/plumber.R b/inst/api/plumber.R index 7b32d32..e6bff5d 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -26,7 +26,7 @@ function(identifier, name, method, arms, ratio, strata, parameters, req, res) { # Define study unbiased:::define_study( - name, identifier, method, arms, + name, identifier, arms, method, strata = strata, parameters = parameters, ratio = ratio ) } From 7bbb444ecd0d6aa53f0aff2f5fa46ab33de24814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:01:24 +0100 Subject: [PATCH 042/240] recompile package --- NAMESPACE | 1 + man/define_study.Rd | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 man/define_study.Rd diff --git a/NAMESPACE b/NAMESPACE index ed34d40..54b270b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(define_study) export(randomize_simple) export(run_unbiased) export(run_unbiased_db) diff --git a/man/define_study.Rd b/man/define_study.Rd new file mode 100644 index 0000000..2876268 --- /dev/null +++ b/man/define_study.Rd @@ -0,0 +1,57 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/study-define.R +\name{define_study} +\alias{define_study} +\title{Define study} +\usage{ +define_study( + name, + identifier, + arms, + method = c("simple", "block"), + strata = list(), + parameters = NULL, + ratio = rep(1, times = length(arms)) +) +} +\arguments{ +\item{name}{\code{character(1)}\cr +Full study name.} + +\item{identifier}{\code{character(1)}\cr +Study code, at most 12 characters.} + +\item{arms}{\code{character()}\cr +Arm names to use.} + +\item{method}{\code{character(1)}\cr +Randomization method to apply.} + +\item{strata}{\code{list()}\cr +List of character vectors, each list element being a stratum and each string +being a possible stratum value. Could possibly take a numeric structure as +well instead of a character vector, e.g. \code{list(min = 1, max = 10)}. It just +needs handling by checking whether the inner list is named or not, I'd say.} + +\item{parameters}{\code{list()}\cr +Parameters to pass to randomization.} + +\item{ratio}{\code{integer()}\cr +Arm ratios, must be positive and the same length as arm names.} +} +\value{ +This function is called for the side effect of updating the DB. +} +\description{ +Creates a study with specified parameters and publishes it to the DB. +} +\examples{ +\dontrun{ +define_study( + "DEMO", "Demonstrational study", c("placebo", "active"), + method = "simple", + strata = list(gender = c("F", "M"), working = c("yes", "no")) +) +} + +} From 734f6554be13c75bfc6188fe66b65bcb84072fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:03:07 +0100 Subject: [PATCH 043/240] fix setup skip --- tests/testthat/setup-DB.R | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R index 7db3d14..c1a23fe 100644 --- a/tests/testthat/setup-DB.R +++ b/tests/testthat/setup-DB.R @@ -1,7 +1,7 @@ -skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") +if (is_CI()) { + # Define connection ---- + conn <- connect_to_db() -# Define connection ---- -conn <- connect_to_db() - -# Close DB connection upon exiting -withr::defer({ DBI::dbDisconnect(conn) }, teardown_env()) + # Close DB connection upon exiting + withr::defer({ DBI::dbDisconnect(conn) }, teardown_env()) +} From 9744f0b6f69993db6b02de6a2cd54e06878583b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:06:33 +0100 Subject: [PATCH 044/240] update NEWS --- NEWS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.md b/NEWS.md index de79bdc..2676c6e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,4 @@ # unbiased (development version) * Initialized package structure. +* Implemented study definition endpoint (`POST /study`). From f050380a008fe7946f567e39e80ff1551d8b5637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:16:35 +0100 Subject: [PATCH 045/240] setup pkgdown --- .Rbuildignore | 3 +++ .github/workflows/pkgdown.yaml | 48 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + DESCRIPTION | 1 + _pkgdown.yml | 4 +++ 5 files changed, 57 insertions(+) create mode 100644 .github/workflows/pkgdown.yaml create mode 100644 _pkgdown.yml diff --git a/.Rbuildignore b/.Rbuildignore index d6786a1..7129571 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -4,3 +4,6 @@ ^unbiased\.Rproj$ ^\.Rproj\.user$ ^LICENSE\.md$ +^_pkgdown\.yml$ +^docs$ +^pkgdown$ diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml new file mode 100644 index 0000000..ed7650c --- /dev/null +++ b/.github/workflows/pkgdown.yaml @@ -0,0 +1,48 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + release: + types: [published] + workflow_dispatch: + +name: pkgdown + +jobs: + pkgdown: + runs-on: ubuntu-latest + # Only restrict concurrency for non-PR jobs + concurrency: + group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }} + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::pkgdown, local::. + needs: website + + - name: Build site + run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + shell: Rscript {0} + + - name: Deploy to GitHub pages 🚀 + if: github.event_name != 'pull_request' + uses: JamesIves/github-pages-deploy-action@v4.4.1 + with: + clean: false + branch: gh-pages + folder: docs diff --git a/.gitignore b/.gitignore index 7cc8b6a..b27cb91 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ po/*~ # RStudio Connect folder rsconnect/ .Rproj.user +docs diff --git a/DESCRIPTION b/DESCRIPTION index 4183592..b8e53e2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -19,3 +19,4 @@ Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) RoxygenNote: 7.2.3 +URL: https://ttscience.github.io/unbiased/ diff --git a/_pkgdown.yml b/_pkgdown.yml new file mode 100644 index 0000000..c4c0d89 --- /dev/null +++ b/_pkgdown.yml @@ -0,0 +1,4 @@ +url: https://ttscience.github.io/unbiased/ +template: + bootstrap: 5 + From 8a384750f66350b9ddf173152c7fc89c3e2f363c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 12:52:28 +0100 Subject: [PATCH 046/240] log args too --- R/run_db.R | 1 + inst/api/plumber.R | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/R/run_db.R b/R/run_db.R index 6b6fe68..6e61ba2 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -12,6 +12,7 @@ CONN <- NULL #' #' @export run_unbiased_db <- function() { + Sys.setenv(GITHUB_SHA = system("git rev-parse HEAD", intern = TRUE)) Sys.setenv(POSTGRES_DB = "postgres") Sys.setenv(POSTGRES_HOST = "127.0.0.1") Sys.setenv(POSTGRES_PORT = 5432) diff --git a/inst/api/plumber.R b/inst/api/plumber.R index e6bff5d..52ea590 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -6,6 +6,22 @@ function(api) { plumber::pr_mount("/meta", meta) } +#* Log request data +#* +#* @filter logger +function(req) { + cat( + "[QUERY]", as.character(Sys.time()), "-", + req$REQUEST_METHOD, req$PATH_INFO, "-", + req$HTTP_USER_AGENT, "@", req$REMOTE_ADDR, "\n" + ) + purrr::imap(req$args, function(arg, arg_name) { + cat("[ARG]", arg_name, "=", as.character(arg), "\n") + }) + + plumber::forward() +} + #* Define study to randomize #* #* @param identifier:str Study code, at most 12 characters. From 0a34f9db120c08d40956315ac38ab9bd8b5e223f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 13:50:00 +0100 Subject: [PATCH 047/240] implement study list and details --- R/study-details.R | 48 +++++++++++++++++++++++++++++++++ R/study-list.R | 13 +++++++++ inst/api/plumber.R | 23 +++++++++++++++- inst/postgres/01-initialize.sql | 2 +- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 R/study-details.R create mode 100644 R/study-list.R diff --git a/R/study-details.R b/R/study-details.R new file mode 100644 index 0000000..09a730d --- /dev/null +++ b/R/study-details.R @@ -0,0 +1,48 @@ +read_study_details <- function(study_id) { + study <- tbl(CONN, "study") |> + filter(id == study_id) |> + select(id, name, identifier, method_id, parameters) |> + left_join( + tbl(CONN, "method") |> + select(id, method = name), + join_by(method_id == id) + ) |> + select(-method_id) |> + collect() |> + mutate(parameters = list(jsonlite::fromJSON(parameters))) + + arms <- tbl(CONN, "arm") |> + filter(study_id == study_id) |> + select(name, ratio) |> + collect() + + strata <- tbl(CONN, "stratum") |> + filter(study_id == study_id) |> + select(id, name, value_type) |> + collect() |> + mutate(values = list(read_stratum_values(id, value_type)), .by = id) |> + select(-id) + + mutate( + study, + arms = list(arms), + strata = list(strata) + ) +} + +read_stratum_values <- function(stratum_id, value_type) { + switch( + value_type, + "factor" = { + tbl(CONN, "factor_constraint") |> + filter(stratum_id == stratum_id) |> + pull(value) + }, + "numeric" = { + tbl(CONN, "numeric_constraint") |> + filter(stratum_id == stratum_id) |> + select(min_value, max_value) |> + collect() + } + ) +} diff --git a/R/study-list.R b/R/study-list.R new file mode 100644 index 0000000..83933be --- /dev/null +++ b/R/study-list.R @@ -0,0 +1,13 @@ +list_studies <- function() { + tbl(CONN, "study") |> + select(id, identifier, name, timestamp) |> + arrange(desc(timestamp)) |> + collect() +} + +study_exists <- function(study_id) { + row_id <- tbl(CONN, "study") |> + filter(id == !!study_id) |> + pull(id) + test_int(row_id) +} diff --git a/inst/api/plumber.R b/inst/api/plumber.R index 52ea590..06db896 100644 --- a/inst/api/plumber.R +++ b/inst/api/plumber.R @@ -47,11 +47,32 @@ function(identifier, name, method, arms, ratio, strata, parameters, req, res) { ) } +#* Get available studies +#* +#* @get /study +function(req, res) { + unbiased:::list_studies() +} + +#* Get study details +#* +#* @get /study/ +function(study_id, req, res) { + study_id <- as.integer(study_id) + + if (!unbiased:::study_exists(study_id)) { + res$status <- 404 + return(list(error = glue::glue("Study {study_id} does not exist."))) + } + + unbiased:::read_study_details(study_id) +} + #* Randomize one patient #* #* @param strata:object #* -#* @get /study//randomize +#* @get /study//randomize function(strata, req, res) { # Check whether study with study_id exists, if not, return error diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index 1f6fc60..2626d58 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -10,7 +10,7 @@ CREATE TABLE study ( name VARCHAR(255) NOT NULL, method_id INT NOT NULL, parameters JSONB, - -- timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), sys_period TSTZRANGE NOT NULL, CONSTRAINT study_method FOREIGN KEY (method_id) From 4fb887d33a30f84300a4dfbc8767b4ee7381a591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 14:00:29 +0100 Subject: [PATCH 048/240] document new functions --- NAMESPACE | 3 +++ R/study-details.R | 44 ++++++++++++++++++++++++--------------- R/study-list.R | 19 +++++++++++++++++ man/list_studies.Rd | 14 +++++++++++++ man/read_study_details.Rd | 19 +++++++++++++++++ man/study_exists.Rd | 18 ++++++++++++++++ 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 man/list_studies.Rd create mode 100644 man/read_study_details.Rd create mode 100644 man/study_exists.Rd diff --git a/NAMESPACE b/NAMESPACE index 54b270b..8a67879 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,8 +1,11 @@ # Generated by roxygen2: do not edit by hand export(define_study) +export(list_studies) export(randomize_simple) +export(read_study_details) export(run_unbiased) export(run_unbiased_db) +export(study_exists) import(checkmate) import(dplyr) diff --git a/R/study-details.R b/R/study-details.R index 09a730d..26c5b37 100644 --- a/R/study-details.R +++ b/R/study-details.R @@ -1,16 +1,16 @@ +#' Read study details +#' +#' @description +#' Queries the DB for the study parameters, including declared arms and strata. +#' +#' @param study_id `integer(1)`\cr +#' ID of the study. +#' +#' @return A tibble with study details, containing potentially complex columns, +#' like `arms`. +#' +#' @export read_study_details <- function(study_id) { - study <- tbl(CONN, "study") |> - filter(id == study_id) |> - select(id, name, identifier, method_id, parameters) |> - left_join( - tbl(CONN, "method") |> - select(id, method = name), - join_by(method_id == id) - ) |> - select(-method_id) |> - collect() |> - mutate(parameters = list(jsonlite::fromJSON(parameters))) - arms <- tbl(CONN, "arm") |> filter(study_id == study_id) |> select(name, ratio) |> @@ -23,11 +23,21 @@ read_study_details <- function(study_id) { mutate(values = list(read_stratum_values(id, value_type)), .by = id) |> select(-id) - mutate( - study, - arms = list(arms), - strata = list(strata) - ) + tbl(CONN, "study") |> + filter(id == study_id) |> + select(id, name, identifier, method_id, parameters) |> + left_join( + tbl(CONN, "method") |> + select(id, method = name), + join_by(method_id == id) + ) |> + select(-method_id) |> + collect() |> + mutate( + parameters = list(jsonlite::fromJSON(parameters)), + arms = list(arms), + strata = list(strata) + ) } read_stratum_values <- function(stratum_id, value_type) { diff --git a/R/study-list.R b/R/study-list.R index 83933be..8ce057e 100644 --- a/R/study-list.R +++ b/R/study-list.R @@ -1,3 +1,11 @@ +#' List available studies +#' +#' @description +#' Queries the DB for the basic information about existing studies. +#' +#' @return A tibble with basic study info, including ID. +#' +#' @export list_studies <- function() { tbl(CONN, "study") |> select(id, identifier, name, timestamp) |> @@ -5,6 +13,17 @@ list_studies <- function() { collect() } +#' Validate study existence +#' +#' @description +#' Checks the database for the existence of given ID. +#' +#' @param study_id `integer(1)`\cr +#' ID of the study. +#' +#' @return `TRUE` or `FALSE`, depending whether given ID exists in the DB. +#' +#' @export study_exists <- function(study_id) { row_id <- tbl(CONN, "study") |> filter(id == !!study_id) |> diff --git a/man/list_studies.Rd b/man/list_studies.Rd new file mode 100644 index 0000000..c11cbb3 --- /dev/null +++ b/man/list_studies.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/study-list.R +\name{list_studies} +\alias{list_studies} +\title{List available studies} +\usage{ +list_studies() +} +\value{ +A tibble with basic study info, including ID. +} +\description{ +Queries the DB for the basic information about existing studies. +} diff --git a/man/read_study_details.Rd b/man/read_study_details.Rd new file mode 100644 index 0000000..1659dac --- /dev/null +++ b/man/read_study_details.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/study-details.R +\name{read_study_details} +\alias{read_study_details} +\title{Read study details} +\usage{ +read_study_details(study_id) +} +\arguments{ +\item{study_id}{\code{integer(1)}\cr +ID of the study.} +} +\value{ +A tibble with study details, containing potentially complex columns, +like \code{arms}. +} +\description{ +Queries the DB for the study parameters, including declared arms and strata. +} diff --git a/man/study_exists.Rd b/man/study_exists.Rd new file mode 100644 index 0000000..66196fc --- /dev/null +++ b/man/study_exists.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/study-list.R +\name{study_exists} +\alias{study_exists} +\title{Validate study existence} +\usage{ +study_exists(study_id) +} +\arguments{ +\item{study_id}{\code{integer(1)}\cr +ID of the study.} +} +\value{ +\code{TRUE} or \code{FALSE}, depending whether given ID exists in the DB. +} +\description{ +Checks the database for the existence of given ID. +} From 8db4afe6e2c75ba7ea466278c66c85164f6b9f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 14:02:32 +0100 Subject: [PATCH 049/240] increase version --- DESCRIPTION | 2 +- NEWS.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 13419d3..8083c09 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: unbiased Title: What the Package Does (One Line, Title Case) -Version: 0.0.0.9002 +Version: 0.0.0.9003 Authors@R: person("First", "Last", , "first.last@example.com", role = c("aut", "cre"), comment = c(ORCID = "YOUR-ORCID-ID")) diff --git a/NEWS.md b/NEWS.md index 2676c6e..0b60b14 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,3 +2,4 @@ * Initialized package structure. * Implemented study definition endpoint (`POST /study`). +* Implemented study details endpoints (`GET /study`, `GET /study/`). From 9cb740f50a1678dcff905a9014dc1feb6c83b840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 15:11:34 +0100 Subject: [PATCH 050/240] inject variables --- R/study-details.R | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/R/study-details.R b/R/study-details.R index 26c5b37..6bf16a2 100644 --- a/R/study-details.R +++ b/R/study-details.R @@ -12,19 +12,19 @@ #' @export read_study_details <- function(study_id) { arms <- tbl(CONN, "arm") |> - filter(study_id == study_id) |> + filter(study_id == !!study_id) |> select(name, ratio) |> collect() strata <- tbl(CONN, "stratum") |> - filter(study_id == study_id) |> + filter(study_id == !!study_id) |> select(id, name, value_type) |> collect() |> mutate(values = list(read_stratum_values(id, value_type)), .by = id) |> select(-id) tbl(CONN, "study") |> - filter(id == study_id) |> + filter(id == !!study_id) |> select(id, name, identifier, method_id, parameters) |> left_join( tbl(CONN, "method") |> @@ -45,12 +45,12 @@ read_stratum_values <- function(stratum_id, value_type) { value_type, "factor" = { tbl(CONN, "factor_constraint") |> - filter(stratum_id == stratum_id) |> + filter(stratum_id == !!stratum_id) |> pull(value) }, "numeric" = { tbl(CONN, "numeric_constraint") |> - filter(stratum_id == stratum_id) |> + filter(stratum_id == !!stratum_id) |> select(min_value, max_value) |> collect() } From bb2001c5bdad80d53e5ab1aa9915801a3d300982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 15:12:12 +0100 Subject: [PATCH 051/240] return study ID upon insert --- R/study-define.R | 2 ++ 1 file changed, 2 insertions(+) diff --git a/R/study-define.R b/R/study-define.R index 8b151d5..76840aa 100644 --- a/R/study-define.R +++ b/R/study-define.R @@ -132,4 +132,6 @@ define_study <- function(name, identifier, arms, }) } }) + + study_id } From dcf4f01c6076878e708837b2702d029da109783c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 15:25:02 +0100 Subject: [PATCH 052/240] fix parameter added back --- tests/testthat/test-DB-study.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 80ebc2d..6d08430 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -61,7 +61,7 @@ test_that("deleting archivizes a study", { expect_identical( tbl(conn, "study_history") |> filter(id == new_study_id) |> - select(-parameters, -sys_period) |> + select(-parameters, -sys_period, -timestamp) |> collect(), tibble( id = new_study_id, From e271d995b6818f11878548b62afb8e8f0ec471db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20B=C4=85ka=C5=82a?= Date: Fri, 17 Nov 2023 15:26:40 +0100 Subject: [PATCH 053/240] increment DB schema version --- inst/postgres/00-metadata.sql | 2 +- tests/testthat/test-DB-0.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/inst/postgres/00-metadata.sql b/inst/postgres/00-metadata.sql index 02b7776..c0fcc37 100644 --- a/inst/postgres/00-metadata.sql +++ b/inst/postgres/00-metadata.sql @@ -6,4 +6,4 @@ CREATE TABLE settings ( ); INSERT INTO settings (key, value) -VALUES ('schema_version', '0.0.0.9002'); +VALUES ('schema_version', '0.0.0.9003'); diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index 9a9d087..a63d90d 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -24,7 +24,7 @@ test_that("database contains history tables", { ) }) -test_that("database version is the same as package version", { +test_that("database version is the same as package version (did you update /inst/postgres/00-metadata.sql?)", { expect_identical( tbl(conn, "settings") |> filter(key == "schema_version") |> From 26050ed0313c04e39a08fbede2bb6df9b438bcb7 Mon Sep 17 00:00:00 2001 From: Ola Date: Mon, 11 Dec 2023 14:52:01 +0000 Subject: [PATCH 054/240] add tests for simple randomization: one-sample prop tests for two and more groups with equal and unequal ratio --- tests/testthat/test-randomize-simple.R | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/testthat/test-randomize-simple.R b/tests/testthat/test-randomize-simple.R index 6b94c89..20d7c37 100644 --- a/tests/testthat/test-randomize-simple.R +++ b/tests/testthat/test-randomize-simple.R @@ -33,3 +33,69 @@ test_that("incorrect parameters raise an exception", { # Empty arm name expect_error(randomize_simple(c("llama", ""), c(2, 3))) }) + +test_that("proportions are kept (allocation 1:1)", { + function_result <- sapply(1:100, function(x) randomize_simple(c("armA", "armB"), c(1,1))) + x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 0.5, conf.level = 0.95, + correct = FALSE) + y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 0.5, conf.level = 0.95, + correct = FALSE) + + # precision 0.01 + expect_gt(x$p.value, 0.01) + if (TRUE) { + expect_gt(y$p.value, 0.01) + } + +}) + +test_that("proportions are kept (allocation 2:1)", { + function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB"), c(2,1))) + x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 2/3, conf.level = 0.95, + correct = FALSE) + y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/3, conf.level = 0.95, + correct = FALSE) + x + y + # precision 0.01 + expect_gt(x$p.value, 0.01) + if (TRUE) { + expect_gt(y$p.value, 0.01) + } +}) + +test_that("proportions are kept (allocation 1:1:1)", { + function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB", "armC"), c(1,1,1))) + x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 1/3, conf.level = 0.95, + correct = FALSE) + y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/3, conf.level = 0.95, + correct = FALSE) + z <- prop.test(x = sum(function_result == "armC"), n = length(function_result), p = 1/3, conf.level = 0.95, + correct = FALSE) + # precision 0.01 + expect_gt(x$p.value, 0.01) + if (TRUE) { + expect_gt(y$p.value, 0.01) + } + if (TRUE) { + expect_gt(z$p.value, 0.01) + } +}) + +test_that("proportions are kept (allocation 1:2:1)", { + function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB", "armC"), c(1,2,1))) + x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 1/4, conf.level = 0.95, + correct = FALSE) + y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/2, conf.level = 0.95, + correct = FALSE) + z <- prop.test(x = sum(function_result == "armC"), n = length(function_result), p = 1/4, conf.level = 0.95, + correct = FALSE) + # precision 0.01 + expect_gt(x$p.value, 0.01) + if (TRUE) { + expect_gt(y$p.value, 0.01) + } + if (TRUE) { + expect_gt(z$p.value, 0.01) + } +}) From 1d7c568e3b05cc4ad4cf95f671fcd62bc781a165 Mon Sep 17 00:00:00 2001 From: Ola Date: Mon, 11 Dec 2023 14:55:10 +0000 Subject: [PATCH 055/240] correct number of patients --- tests/testthat/test-randomize-simple.R | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/testthat/test-randomize-simple.R b/tests/testthat/test-randomize-simple.R index 20d7c37..8d31e7a 100644 --- a/tests/testthat/test-randomize-simple.R +++ b/tests/testthat/test-randomize-simple.R @@ -35,7 +35,7 @@ test_that("incorrect parameters raise an exception", { }) test_that("proportions are kept (allocation 1:1)", { - function_result <- sapply(1:100, function(x) randomize_simple(c("armA", "armB"), c(1,1))) + function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB"), c(1,1))) x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 0.5, conf.level = 0.95, correct = FALSE) y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 0.5, conf.level = 0.95, @@ -55,8 +55,6 @@ test_that("proportions are kept (allocation 2:1)", { correct = FALSE) y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/3, conf.level = 0.95, correct = FALSE) - x - y # precision 0.01 expect_gt(x$p.value, 0.01) if (TRUE) { From c91fd590f03ac801f23cded17423ab919687313c Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Wed, 13 Dec 2023 17:46:09 +0000 Subject: [PATCH 056/240] moved API to standard location, bumped up renv to R 4.2.3, removed "hello world", still WIP --- inst/{api => plumber/unbiased_api}/meta.R | 14 ++++++++++---- inst/{api => plumber/unbiased_api}/plumber.R | 10 +--------- renv.lock | 2 +- 3 files changed, 12 insertions(+), 14 deletions(-) rename inst/{api => plumber/unbiased_api}/meta.R (52%) rename inst/{api => plumber/unbiased_api}/plumber.R (81%) diff --git a/inst/api/meta.R b/inst/plumber/unbiased_api/meta.R similarity index 52% rename from inst/api/meta.R rename to inst/plumber/unbiased_api/meta.R index 87f6820..1983922 100644 --- a/inst/api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -1,10 +1,16 @@ #* Github commit SHA -#* +#* #* Each release of the API is based on some Github commit. This endpoint allows #* the user to easily check the SHA of the deployed API version. -#* +#* #* @get /sha #* @serializer unboxedJSON -function() { - Sys.getenv("GITHUB_SHA", unset = "") +function(res) { + sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") + if (sha == "NULL") { + res$status <- 404 + return(c(error = "SHA not found")) + } else { + return(sha) + } } diff --git a/inst/api/plumber.R b/inst/plumber/unbiased_api/plumber.R similarity index 81% rename from inst/api/plumber.R rename to inst/plumber/unbiased_api/plumber.R index e271b38..91199dd 100644 --- a/inst/api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -10,7 +10,7 @@ function(api) { #* #* @param strata:object #* -#* @get /study//randomize +#* @post /study//randomize function(strata, req, res) { # Check whether study with study_id exists, if not, return error @@ -31,11 +31,3 @@ function(strata, req, res) { # block = do.call(unbiased:::randomize_blocked, c(params, strata = strata)) ) } - -#* Return hello world -#* -#* @get /simple/hello -#* @serializer unboxedJSON -function() { - unbiased:::call_hello_world() -} diff --git a/renv.lock b/renv.lock index 595416c..57fcd7f 100644 --- a/renv.lock +++ b/renv.lock @@ -1,6 +1,6 @@ { "R": { - "Version": "4.2.1", + "Version": "4.2.3", "Repositories": [ { "Name": "CRAN", From 2270a58783e19cf45cc7c31fb3ab658dfac0484e Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 15:25:55 +0000 Subject: [PATCH 057/240] removed dummy function --- R/hello_world.R | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 R/hello_world.R diff --git a/R/hello_world.R b/R/hello_world.R deleted file mode 100644 index 5eed94e..0000000 --- a/R/hello_world.R +++ /dev/null @@ -1,3 +0,0 @@ -call_hello_world <- function() { - "Hello TTSI!" -} From f85c8f1de7245d6537bfb590a581aac56e1eacfa Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 16:03:28 +0000 Subject: [PATCH 058/240] Removed dependency on 7yo dockerfile, added our own; fixed API path in test setup --- .github/workflows/R-CMD-check.yaml | 7 +++++-- Dockerfile.postgres | 13 +++++++++++++ docker-compose.test.yaml | 2 +- tests/testthat/setup-api.R | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.postgres diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 0d365c6..08a141c 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -6,7 +6,7 @@ on: pull_request: branches: [main, devel] -name: R-CMD-check +name: Tests jobs: R-CMD-check: @@ -26,8 +26,11 @@ jobs: - uses: r-lib/actions/setup-pandoc@v2 - - name: Build image + - name: Build API image run: docker build -t unbiased --build-arg github_sha=${{ github.sha }} . + - name: Build custom PostgreSQL image + run: docker build -t temporal_postgres -f Dockerfile.postgres . + - name: Run tests run: docker compose -f "docker-compose.test.yaml" up --abort-on-container-exit --exit-code-from tests diff --git a/Dockerfile.postgres b/Dockerfile.postgres new file mode 100644 index 0000000..1dc5522 --- /dev/null +++ b/Dockerfile.postgres @@ -0,0 +1,13 @@ +# Start with the official PostgreSQL image based on Debian +FROM postgres:16 + +# Run package updates and install necessary packages +RUN apt-get update \ + # Install PostgreSQL development headers and PGXN client + && apt-get install -y \ + postgresql-server-dev-16 \ + pgxnclient \ + # Install the 'temporal_tables' extension using PGXN + && pgxn install temporal_tables \ + # Clear apt cache to reduce image size + && rm -rf /var/lib/apt/lists/* diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index ef098a2..99b0395 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -1,7 +1,7 @@ version: "3.9" services: postgres: - image: eddhannay/alpine-postgres-temporal-tables:latest + image: temporal_postgres container_name: unbiased_postgres environment: - POSTGRES_PASSWORD=postgres diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index fca5dde..518a338 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -19,7 +19,7 @@ if (!isTRUE(as.logical(Sys.getenv("CI")))) { api <- callr::r_bg(\(path) { # 1. Set path to `path` # 2. Build a plumber API - plumber::plumb(dir = fs::path_package("unbiased", "api")) |> + plumber::plumb_api("unbiased", "api") |> plumber::pr_run(port = 3838) }, args = list(path = api_path)) From e1ef4ead54ccf726c724c2f05fee105c70c8568f Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 16:18:41 +0000 Subject: [PATCH 059/240] fixed dockerfile dependencies --- Dockerfile.postgres | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile.postgres b/Dockerfile.postgres index 1dc5522..b30d73b 100644 --- a/Dockerfile.postgres +++ b/Dockerfile.postgres @@ -7,6 +7,8 @@ RUN apt-get update \ && apt-get install -y \ postgresql-server-dev-16 \ pgxnclient \ + make \ + gcc \ # Install the 'temporal_tables' extension using PGXN && pgxn install temporal_tables \ # Clear apt cache to reduce image size From 8c41b4965e664cf5f011cb49f2218a59c5931f33 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 16:27:46 +0000 Subject: [PATCH 060/240] bugfixed test startup --- tests/testthat/setup-api.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 518a338..97c6e43 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -19,7 +19,7 @@ if (!isTRUE(as.logical(Sys.getenv("CI")))) { api <- callr::r_bg(\(path) { # 1. Set path to `path` # 2. Build a plumber API - plumber::plumb_api("unbiased", "api") |> + plumber::plumb_api("unbiased", "unbiased_api") |> plumber::pr_run(port = 3838) }, args = list(path = api_path)) From 2f059e8561fcc1ab3eab20e49a0048e0716702ee Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 16:53:06 +0000 Subject: [PATCH 061/240] Fixed R version, API launch command --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1dbe6e5..61578fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rocker/r-ver:4.2.1 +FROM rocker/r-ver:4.2.3 WORKDIR /src/unbiased @@ -33,4 +33,4 @@ EXPOSE 3838 ARG github_sha ENV GITHUB_SHA=${github_sha} -CMD ["R", "-e", "plumber::plumb(dir = fs::path_package('unbiased', 'api')) |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] +CMD ["R", "-e", "plumber::plumb_api('unbiased', 'unbiased_api') |> plumber::pr_run(host = '0.0.0.0', port = 3838)"] From 705edb5166489ebd149363d6e8975c685c0403d1 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 15 Dec 2023 17:01:19 +0000 Subject: [PATCH 062/240] removed redundant test --- tests/testthat/test-E2E-hello.R | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 tests/testthat/test-E2E-hello.R diff --git a/tests/testthat/test-E2E-hello.R b/tests/testthat/test-E2E-hello.R deleted file mode 100644 index 714faf8..0000000 --- a/tests/testthat/test-E2E-hello.R +++ /dev/null @@ -1,9 +0,0 @@ -test_that("hello world endpoint returns the message", { - response <- request(api_url) |> - req_url_path("simple", "hello") |> - req_method("GET") |> - req_perform() |> - resp_body_json() - - expect_identical(response, "Hello TTSI!") -}) From e176ccdddf165577bcd6f795db71d6820379e15f Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 19 Dec 2023 18:13:25 +0000 Subject: [PATCH 063/240] added comments to SQL, added additional DB tests --- inst/postgres/00-metadata.sql | 2 + inst/postgres/01-initialize.sql | 151 +++++++++++++++++++++++++++----- tests/testthat/test-DB-study.R | 135 +++++++++++++++++++++++++++- 3 files changed, 267 insertions(+), 21 deletions(-) diff --git a/inst/postgres/00-metadata.sql b/inst/postgres/00-metadata.sql index c427cba..26cb5f1 100644 --- a/inst/postgres/00-metadata.sql +++ b/inst/postgres/00-metadata.sql @@ -1,7 +1,9 @@ +-- Create a table for storing application settings CREATE TABLE settings ( key TEXT NOT NULL, value TEXT NOT NULL ); +-- Insert initial schema version setting if it doesn't exist INSERT INTO settings (key, value) VALUES ('schema_version', '0.0.0.9002'); diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index fe81719..adbb055 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,11 +1,25 @@ CREATE EXTENSION temporal_tables; +-- Table: method +-- Purpose: Holds the available randomization methods used in clinical studies. +-- Each method is uniquely identified by an auto-incrementing ID. +-- The 'name' column stores the name of the randomization method. +-- The 'sys_period' column, of type TSTZRANGE, is used for temporal versioning, +-- tracking the period during which each record is considered valid and current. CREATE TABLE method ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, sys_period TSTZRANGE NOT NULL ); +-- Table: study +-- Purpose: Stores information about various studies conducted. +-- 'id' is an auto-incrementing primary key uniquely identifying each study. +-- 'identifier' is a unique, short textual identifier for the study (max 12 characters). +-- 'name' provides the full name or title of the study. +-- 'method_id' is a foreign key linking to the 'method' table, indicating the randomization method used in the study. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'study_method' constraint ensures referential integrity, linking each study to a valid randomization method. CREATE TABLE study ( id SERIAL PRIMARY KEY, identifier VARCHAR(12) NOT NULL, @@ -19,6 +33,18 @@ CREATE TABLE study ( REFERENCES method (id) ); +-- Table: arm +-- Purpose: Represents the treatment arms within each study. +-- 'id' is an auto-incrementing primary key that uniquely identifies each arm. +-- 'study_id' is a foreign key that links each arm to its corresponding study. +-- 'name' provides a descriptive name for the treatment arm. +-- 'ratio' specifies the proportion of patients allocated to this arm. It defaults to 1 and must always be positive. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'arm_study' foreign key constraint ensures that each arm is associated with a valid study. +-- The 'uc_arm_study' unique constraint ensures that each combination of 'id' and 'study_id' is unique, +-- which is important for maintaining data integrity across studies. +-- The 'ratio_positive' check constraint ensures that the ratio is always greater than 0, +-- maintaining logical consistency in the patient allocation process. CREATE TABLE arm ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, @@ -34,6 +60,20 @@ CREATE TABLE arm ( CHECK (ratio > 0) ); +-- Table: stratum +-- Purpose: Defines the strata for patient categorization within each study. +-- 'id' is an auto-incrementing primary key that uniquely identifies each stratum. +-- 'study_id' is a foreign key that links the stratum to a specific study. +-- 'name' provides a descriptive name for the stratum, such as a particular demographic or clinical characteristic. +-- 'value_type' indicates the type of value the stratum represents, limited to two types: 'factor' or 'numeric'. +-- 'factor' represents categorical data, while 'numeric' represents numerical data. +-- This distinction is crucial as it informs the data validation logic applied in the system. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'fk_study' foreign key constraint ensures that each stratum is associated with a valid study and cascades deletions. +-- The 'chk_value_type' check constraint ensures that the 'value_type' field only contains allowed values ('factor' or 'numeric'), +-- enforcing data integrity and consistency in the type of stratum values. +-- Subsequent validation checks in the system (like 'check_fct_stratum') use the 'value_type' field to ensure data integrity, +-- by verifying that constraints on data (factor or numeric) align with the stratum type. CREATE TABLE stratum ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, @@ -47,6 +87,30 @@ CREATE TABLE stratum ( CHECK (value_type IN ('factor', 'numeric')) ); +-- Table: stratum_level +-- Purpose: Keeps allowed stratum factor levels +-- 'id' is an auto-incrementing primary key that uniquely identifies each stratum. +-- 'level' level label, has to be unique within stratum +CREATE TABLE stratum_level ( + stratum_id INT NOT NULL, + level VARCHAR(255) NOT NULL, + CONSTRAINT fk_stratum_level + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum_level + UNIQUE (stratum_id, level) +); + +-- Table: factor_constraint +-- Purpose: Defines constraints for strata of the 'factor' type in studies. +-- This table stores allowable values for each factor stratum, ensuring data consistency and integrity. +-- 'stratum_id' is a foreign key that links the constraint to a specific stratum in the 'stratum' table. +-- 'value' represents the specific allowable value for the factor stratum. +-- This could be a categorical label like 'male' or 'female' for a gender stratum, for example. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'factor_stratum' foreign key constraint ensures that each constraint is associated with a valid factor type stratum. +-- The 'uc_stratum_value' unique constraint ensures that each combination of 'stratum_id' and 'value' is unique within the table. +-- This prevents duplicate entries for the same stratum and value, maintaining the integrity of the constraint data. CREATE TABLE factor_constraint ( stratum_id INT NOT NULL, value VARCHAR(255) NOT NULL, @@ -58,6 +122,21 @@ CREATE TABLE factor_constraint ( UNIQUE (stratum_id, value) ); +-- Table: numeric_constraint +-- Purpose: Specifies constraints for strata of the 'numeric' type in studies. +-- This table defines the permissible range (minimum and maximum values) for each numeric stratum. +-- 'stratum_id' is a foreign key that links the constraint to a specific numeric stratum in the 'stratum' table. +-- 'min_value' and 'max_value' define the allowable range for the stratum's numeric values. +-- For example, if the stratum represents age, 'min_value' and 'max_value' might define the age range for a study group. +-- Either of these columns can be NULL, indicating that there is no lower or upper bound, respectively. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'numeric_stratum' foreign key constraint ensures that each constraint is associated with a valid numeric type stratum. +-- The 'uc_stratum' unique constraint ensures that there is only one constraint entry per 'stratum_id'. +-- The 'chk_min_max' check constraint ensures that 'min_value' is always less than or equal to 'max_value', +-- maintaining logical consistency. If either value is NULL, the check constraint still holds valid as per SQL standards. +CREATE TABLE numeric_constraint ( + stratum_id INT NOT NULL, + min_value FLOAT, CREATE TABLE numeric_constraint ( stratum_id INT NOT NULL, min_value FLOAT, @@ -73,6 +152,26 @@ CREATE TABLE numeric_constraint ( CHECK (min_value <= max_value) ); +-- Table: patient +-- Purpose: Represents individual patients participating in the studies. +-- 'id' is an auto-incrementing primary key that uniquely identifies each patient. +-- 'study_id' is a foreign key linking the patient to a specific study. +-- 'arm_id' is an optional foreign key that links the patient to a specific treatment arm within the study. +-- For instance, in methods like simple randomization, 'arm_id' is assigned as patients are randomized. +-- Conversely, in methods such as block randomization, 'arm_id' might be pre-assigned based on a predetermined randomization list. +-- This flexible approach allows for accommodating various randomization methods and their unique requirements. +-- 'used' is a boolean flag indicating the state of the patient in the randomization process. +-- In methods like simple randomization, patients are entered into this table only when they are randomized, +-- meaning 'used' will always be true for these entries, as there are no pre-plans in this method. +-- For other methods, such as block randomization, 'used' is utilized to mark patients as 'used' +-- according to a pre-planned randomization list, accommodating pre-assignment in these scenarios. +-- This design allows the system to adapt to different randomization strategies effectively. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'patient_arm_study' foreign key constraint ensures referential integrity between patients, studies, and arms. +-- It also cascades deletions to maintain consistency when a study or arm is deleted. +-- The 'used_with_arm' check constraint ensures logical consistency by allowing 'used' to be true only if the patient +-- is assigned to an arm (i.e., 'arm_id' is not NULL). +-- This prevents scenarios where a patient is marked as used but not assigned to any treatment arm. CREATE TABLE patient ( id SERIAL PRIMARY KEY, study_id INT NOT NULL, @@ -87,6 +186,20 @@ CREATE TABLE patient ( CHECK (NOT used OR arm_id IS NOT NULL) ); +-- Table: patient_stratum +-- Purpose: Associates patients with specific strata and records the corresponding stratum values. +-- 'patient_id' is a foreign key that links to the 'patient' table, identifying the patient. +-- 'stratum_id' is a foreign key that links to the 'stratum' table, identifying the stratum to which the patient belongs. +-- 'fct_value' stores the categorical (factor) value for the patient in the corresponding stratum, if applicable. +-- 'num_value' stores the numerical value for the patient in the corresponding stratum, if applicable. +-- For example, if a stratum represents a demographic category, 'fct_value' might be used; +-- if it represents a measurable characteristic like age, 'num_value' might be used. +-- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. +-- The 'fk_patient' and 'fk_stratum_2' foreign key constraints link each patient-stratum pairing to the respective tables. +-- The 'chk_value_exists' check constraint ensures that either a factor or numeric value is provided for each record, +-- aligning with the nature of the stratum. +-- The 'chk_one_value_only' check constraint ensures that each record has either a factor or a numeric value, but not both, +-- maintaining the integrity of the data by ensuring it matches the stratum type (factor or numeric). CREATE TABLE patient_stratum ( patient_id INT NOT NULL, stratum_id INT NOT NULL, @@ -105,6 +218,8 @@ CREATE TABLE patient_stratum ( CONSTRAINT chk_one_value_only -- Can't give both factor and numeric value CHECK (fct_value IS NULL OR num_value IS NULL) + CONSTRAINT uc_patient_stratum + UNIQUE (patient_id, stratum_id) ); -- Stratum constraint checks @@ -123,6 +238,11 @@ BEGIN END; $$ LANGUAGE plpgsql; +CREATE TRIGGER stratum_fct_constraint +BEFORE INSERT ON factor_constraint +FOR EACH ROW +EXECUTE PROCEDURE check_fct_stratum(); + CREATE OR REPLACE FUNCTION check_num_stratum() RETURNS trigger AS $$ @@ -138,13 +258,6 @@ BEGIN END; $$ LANGUAGE plpgsql; - -CREATE TRIGGER stratum_fct_constraint -BEFORE INSERT ON factor_constraint -FOR EACH ROW -EXECUTE PROCEDURE check_fct_stratum(); - - CREATE TRIGGER stratum_num_constraint BEFORE INSERT ON numeric_constraint FOR EACH ROW @@ -152,6 +265,7 @@ EXECUTE PROCEDURE check_num_stratum(); -- Patient stratum value checks +-- Ensure that patients and strata are assigned to the same study. CREATE OR REPLACE FUNCTION check_patient_stratum_study() RETURNS trigger AS $$ BEGIN @@ -173,7 +287,12 @@ BEGIN END; $$ LANGUAGE plpgsql; +CREATE TRIGGER patient_stratum_study_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_patient_stratum_study(); +-- Validate and enforce factor stratum values. CREATE OR REPLACE FUNCTION check_fct_patient() RETURNS trigger AS $$ BEGIN @@ -195,7 +314,12 @@ BEGIN END; $$ LANGUAGE plpgsql; +CREATE TRIGGER patient_fct_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_fct_patient(); +-- Validate and enforce numeric stratum values within specified constraints. CREATE OR REPLACE FUNCTION check_num_patient() RETURNS trigger AS $$ BEGIN @@ -228,19 +352,6 @@ BEGIN END; $$ LANGUAGE plpgsql; - -CREATE TRIGGER patient_stratum_study_constraint -BEFORE INSERT ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE check_patient_stratum_study(); - - -CREATE TRIGGER patient_fct_constraint -BEFORE INSERT ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE check_fct_patient(); - - CREATE TRIGGER patient_num_constraint BEFORE INSERT ON patient_stratum FOR EACH ROW diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 80ebc2d..1af66a4 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -46,7 +46,7 @@ test_that("can't insert a study that references a non-existing method", { ), copy = TRUE, in_place = TRUE ) - }) + }, regexp = "violates foreign key constraint") }) test_that("deleting archivizes a study", { @@ -71,3 +71,136 @@ test_that("deleting archivizes a study", { ) ) }) + +test_that("can't push arm with negative ratio", { + expect_error({ + tbl(conn, "arm") |> + rows_append( + tibble( + study_id = 1, + name = "Exception-throwing arm", + ratio = -1 + ), + copy = TRUE, in_place = TRUE + ) + }, regexp = "violates check constraint") +}) + +test_that("can't push stratum other than factor or numeric", { + expect_error({ + tbl(conn, "stratum") |> + rows_append( + tibble( + study_id = 1, + name = "failing stratum", + value_type = "array" + ), + copy = TRUE, in_place = TRUE + ) + }, regexp = "violates check constraint") +}) + +test_that("can't push stratum level outside of defined levels", { + # create a new patient + return <- + expect_no_error({ + tbl(conn, "patient") |> + rows_append( + tibble(study_id = 1, + arm_id = 1, + used = TRUE), + copy = TRUE, in_place = TRUE, returning = id + ) |> + dbplyr::get_returned_rows() + }) + + added_petient_id <- return$id + + expect_error({ + tbl(conn, "patient_stratum") |> + rows_append( + tibble(patient_id = added_petient_id, + stratum_id = 1, + fct_value = "Female"), + copy = TRUE, in_place = TRUE + ) + }, regexp = "Factor value not specified as allowed") + + # add legal value + expect_no_error({ + tbl(conn, "patient_stratum") |> + rows_append( + tibble(patient_id = added_petient_id, + stratum_id = 1, + fct_value = "F"), + copy = TRUE, in_place = TRUE + ) + }) +}) + +test_that("numerical constraints are enforced", { + return <- + expect_no_error({ + tbl(conn, "stratum") |> + rows_append( + tibble(study_id = 1, + name = "age", + value_type = "numeric"), + copy = TRUE, in_place = TRUE, returning = id + ) |> + dbplyr::get_returned_rows() + }) + + added_stratum_id <- return$id + + expect_no_error({ + tbl(conn, "numeric_constraint") |> + rows_append( + tibble(stratum_id = added_stratum_id, + min_value = 18, + max_value = 64), + copy = TRUE, in_place = TRUE + ) + }) + + expect_no_error({ + tbl(conn, "patient_stratum") |> + rows_append( + tibble(patient_id = added_petient_id, + stratum_id = added_stratum_id, + num_value = 23), + copy = TRUE, in_place = TRUE + ) + }) + + # but you cannot add two values for one patient one stratum + expect_error({ + tbl(conn, "patient_stratum") |> + rows_append( + tibble(patient_id = added_petient_id, + stratum_id = added_stratum_id, + num_value = 24), + copy = TRUE, in_place = TRUE + ) + }, regexp = "duplicate key value violates unique constraint") + + expect_no_error({ + tbl(conn, "patient_stratum") |> + rows_delete( + tibble(patient_id = added_petient_id, + stratum_id = added_stratum_id), + copy = TRUE, unmatched = "ignore" + ) + }) + + # and you can't add an illegal value + expect_error({ + tbl(conn, "patient_stratum") |> + rows_append( + tibble(patient_id = added_petient_id, + stratum_id = added_stratum_id, + num_value = 16), + copy = TRUE, in_place = TRUE + ) + }, regexp = "New value is lower than minimum") +}) From 34a3bc86b4e069c2375380362a2be57a316bcd88 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 19 Dec 2023 18:22:56 +0000 Subject: [PATCH 064/240] bugfix --- inst/postgres/01-initialize.sql | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index adbb055..4fe0818 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -134,9 +134,6 @@ CREATE TABLE factor_constraint ( -- The 'uc_stratum' unique constraint ensures that there is only one constraint entry per 'stratum_id'. -- The 'chk_min_max' check constraint ensures that 'min_value' is always less than or equal to 'max_value', -- maintaining logical consistency. If either value is NULL, the check constraint still holds valid as per SQL standards. -CREATE TABLE numeric_constraint ( - stratum_id INT NOT NULL, - min_value FLOAT, CREATE TABLE numeric_constraint ( stratum_id INT NOT NULL, min_value FLOAT, @@ -217,7 +214,7 @@ CREATE TABLE patient_stratum ( CHECK (fct_value IS NOT NULL OR num_value IS NOT NULL), CONSTRAINT chk_one_value_only -- Can't give both factor and numeric value - CHECK (fct_value IS NULL OR num_value IS NULL) + CHECK (fct_value IS NULL OR num_value IS NULL), CONSTRAINT uc_patient_stratum UNIQUE (patient_id, stratum_id) ); From 16d6d2b9b194f337011ab4b10348ab40ed615f31 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 19 Dec 2023 18:26:09 +0000 Subject: [PATCH 065/240] bugifx --- tests/testthat/test-DB-study.R | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 1af66a4..21fc3fc 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -114,12 +114,12 @@ test_that("can't push stratum level outside of defined levels", { dbplyr::get_returned_rows() }) - added_petient_id <- return$id + added_patient_id <<- return$id expect_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = 1, fct_value = "Female"), copy = TRUE, in_place = TRUE @@ -130,7 +130,7 @@ test_that("can't push stratum level outside of defined levels", { expect_no_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = 1, fct_value = "F"), copy = TRUE, in_place = TRUE @@ -166,7 +166,7 @@ test_that("numerical constraints are enforced", { expect_no_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 23), copy = TRUE, in_place = TRUE @@ -177,7 +177,7 @@ test_that("numerical constraints are enforced", { expect_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 24), copy = TRUE, in_place = TRUE @@ -187,7 +187,7 @@ test_that("numerical constraints are enforced", { expect_no_error({ tbl(conn, "patient_stratum") |> rows_delete( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = added_stratum_id), copy = TRUE, unmatched = "ignore" ) @@ -197,7 +197,7 @@ test_that("numerical constraints are enforced", { expect_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_petient_id, + tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 16), copy = TRUE, in_place = TRUE From f79c274bd6b4056d7275ea18cae8d4a0db4d19bc Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 19 Dec 2023 18:39:19 +0000 Subject: [PATCH 066/240] bugfixed tests --- tests/testthat/test-DB-study.R | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 21fc3fc..8b063e2 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -163,44 +163,36 @@ test_that("numerical constraints are enforced", { ) }) - expect_no_error({ + # and you can't add an illegal value + expect_error({ tbl(conn, "patient_stratum") |> rows_append( tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, - num_value = 23), + num_value = 16), copy = TRUE, in_place = TRUE ) - }) + }, regexp = "New value is lower than minimum") - # but you cannot add two values for one patient one stratum - expect_error({ + # you can add valid value + expect_no_error({ tbl(conn, "patient_stratum") |> rows_append( tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, - num_value = 24), + num_value = 23), copy = TRUE, in_place = TRUE ) - }, regexp = "duplicate key value violates unique constraint") - - expect_no_error({ - tbl(conn, "patient_stratum") |> - rows_delete( - tibble(patient_id = added_patient_id, - stratum_id = added_stratum_id), - copy = TRUE, unmatched = "ignore" - ) }) - # and you can't add an illegal value + # but you cannot add two values for one patient one stratum expect_error({ tbl(conn, "patient_stratum") |> rows_append( tibble(patient_id = added_patient_id, stratum_id = added_stratum_id, - num_value = 16), + num_value = 24), copy = TRUE, in_place = TRUE ) - }, regexp = "New value is lower than minimum") + }, regexp = "duplicate key value violates unique constraint") }) From 97e8cd2408c1a6f8de74a66ce2bfeb1daad11087 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 21 Dec 2023 10:42:01 +0000 Subject: [PATCH 067/240] WIP: core function for dynamic randomization, added usethat and styler to packages, tests (stub) --- NAMESPACE | 1 + R/randomize-dynamic.R | 169 +++++++++++++ R/randomize-simple.R | 26 +- man/randomize_dynamic.Rd | 80 ++++++ man/randomize_simple.Rd | 5 +- man/unbiased-package.Rd | 7 + renv.lock | 310 +++++++++++++++++++++++- tests/testthat/test-randomize-dynamic.R | 24 ++ 8 files changed, 612 insertions(+), 10 deletions(-) create mode 100644 R/randomize-dynamic.R create mode 100644 man/randomize_dynamic.Rd create mode 100644 tests/testthat/test-randomize-dynamic.R diff --git a/NAMESPACE b/NAMESPACE index 8a67879..59bbbd0 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,7 @@ export(define_study) export(list_studies) +export(randomize_dynamic) export(randomize_simple) export(read_study_details) export(run_unbiased) diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R new file mode 100644 index 0000000..64374b9 --- /dev/null +++ b/R/randomize-dynamic.R @@ -0,0 +1,169 @@ +#' Dynamic randomization +#' +#' @inheritParams randomize_simple +#' +#' @param current_state `data.frame()`\cr +#' table of covariates and current arm assignments in column `arm` +#' @param weights `numeric()`\cr +#' vector of positive weights, equal in length to number of covariates, +#' numbered after covariates, defaults to equal weights +#' @param method `character()`\cr +#' Function used to compute within-arm variability, must be one of: +#' `sd`, `var`, `range`, defaults to `var` +#' @param p `numeric()`\cr +#' single value, proportion of randomness (0, 1) in the randomization +#' vs determinism, defaults to 85% deterministic +#' +#' @return `character()`\cr +#' name of the arm assigned to the patient +#' @examples +#' n_at_the_moment <- 3 +#' arms <- c("control", "active low", "active high") +#' sex <- sample(seq(1, 0), +#' 3, +#' replace = TRUE, +#' prob = c(0.4, 0.6) +#' ) +#' diabetes <- sample(c("diabetes", "no diabetes"), nsample, replace = TRUE, prob = c(0.2, 0.8)) +#' mat_of_covars <- cbind(c1, c2) +#' colnames(mat_of_covars) <- c("Sex", "Diabetes") +#' covar_class <- c("ordinal", "numeric", "ordinal", "numeric") +#' wght <- c(1 / 4, 1 / 4, 1 / 4, 1 / 4) +#' +#' resrand <- integer() +#' init_pat <- length(c1) +#' resrand[1:init_pat] <- sample(arms, +#' init_pat, +#' replace = TRUE, +#' prob = ratio / sum(ratio) +#' ) +#' +#' randomize_dynamic( +#' covariates = mat_of_covars, +#' patnum = init_pat + 1, +#' weights = wght, +#' ratio = ratio, +#' no_of_trt = no_of_arms, +#' arms = arms, +#' current_state = resrand, +#' p = 0.85, +#' init_patnum = init_pat +#' ) +#' +#' @export +randomize_dynamic <- + function(arms, + current_state, + weights, + ratio, + method = "var", + p = 0.85) { + browser() + # Assertions + + assert_choice( + method, + choices = c("range", "var", "sd") + ) + assert_data_frame( + covariates, + any.missing = FALSE, + min.cols = 1, + null.ok = TRUE + ) + assert_numeric( + weights, + any.missing = FALSE, + len = arms, + null.ok = FALSE, + lower = 0, + finite = TRUE, + all.missing = FALSE + ) + assert_names( + names(weights), + must.include = + colnames(covariates)[colnames(covariates) != "arms"] + ) + assert_integer( + ratio, + any.missing = FALSE, + len = ncol(covariates) - 1, + null.ok = FALSE, + lower = 0, + finite = TRUE, + all.missing = FALSE, + names = "named" + ) + assert_names( + names(ratio), + must.include = arms + ) + assert_number( + p, + na.ok = FALSE, + lower = 0, + upper = 1, + null.ok = FALSE + ) + + + # Computations + + n_at_the_moment <- nrow(covariates) + n_arms <- length(arms) + init_patnum <- patnum - 1 + + if (patnum < init_patnum) { + res <- NA + } else { + find_trt_group <- apply( # similarity matrix y - randomized patient, x - rest + covariates[1:(patnum - 1), , drop = FALSE], 1, + function(x, y) { + as.numeric(x == y) + }, covariates[patnum, ] + ) + + n_duplicate_trt_group <- matrix(0, ncol(covariates), no_of_trt) + + n_duplicate_trt_group <- sapply(1:no_of_trt, function(x) { + apply( # sum of similar variants + as.matrix( + find_trt_group[, current_state[1:(patnum - 1)] == arms[x]] + ), 1, sum + ) + }) + + imbalance <- sapply(1:no_of_trt, function(x) { + tmp <- n_duplicate_trt_group + tmp[, x] <- tmp[, x] + 1 + num_lvl <- tmp %*% diag(1 / ratio) + imb_margin <- apply(num_lvl, 1, get(method)) # switch range, sd, var + if (method == "range") { + imb_margin <- imb_margin[2, ] - imb_margin[1, ] + } + sum(weights %*% imb_margin) + }) + } + + high_prob <- arms[which.min(imbalance)] # trt_mini + low_prob <- arms[-high_prob] + + res <- + if (length(high_prob) < no_of_trt) { + res <- + sample(c(high_prob, low_prob), 1, + prob = c( + rep(p / length(high_prob), length(high_prob)), + rep( + (1 - p) / length(low_prob), + length(low_prob) + ) + ) + ) + } else { + res <- + sample(arms, 1, prob = rep(1 / no_of_trt, no_of_trt)) + } + return(res) + } diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 3c342dc..d1c3dfb 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -5,9 +5,10 @@ #' regardless of already performed assignments. #' #' @param arms `character()`\cr -#' Arm names. -#' @param ratio `numeric()`\cr -#' Ratio of patient assignment to each arm. Must be the same length as `arms`. +#' Arm names. +#' @param ratio `integer()`\cr +#' Vector of positive integers, equal in length to number of arms, +#' named after arms, defaults to equal weight #' #' @return Selected arm assignment. #' @@ -16,9 +17,22 @@ #' #' @export randomize_simple <- function(arms, ratio) { - assert_character(arms, any.missing = FALSE, unique = TRUE, min.chars = 1) - assert_numeric( - ratio, any.missing = FALSE, lower = 0, finite = TRUE, len = length(arms) + assert_character( + arms, + any.missing = FALSE, + unique = TRUE, + min.chars = 1) + assert_integer( + ratio, + any.missing = FALSE, + lower = 0, + finite = TRUE, + len = length(arms), + names = "named" + ) + assert_names( + names(ratio), + must.include = arms ) sample(arms, 1, prob = ratio) diff --git a/man/randomize_dynamic.Rd b/man/randomize_dynamic.Rd new file mode 100644 index 0000000..9182024 --- /dev/null +++ b/man/randomize_dynamic.Rd @@ -0,0 +1,80 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/randomize-dynamic.R +\name{randomize_dynamic} +\alias{randomize_dynamic} +\title{Dynamic randomization} +\usage{ +randomize_dynamic( + arms, + current_state, + weights, + ratio, + method = "var", + p = 0.85 +) +} +\arguments{ +\item{arms}{\code{character()}\cr +Arm names.} + +\item{current_state}{\code{data.frame()}\cr +table of covariates and current arm assignments in column \code{arm}} + +\item{weights}{\code{numeric()}\cr +vector of positive weights, equal in length to number of covariates, +numbered after covariates, defaults to equal weights} + +\item{ratio}{\code{integer()}\cr +Vector of positive integers, equal in length to number of arms, +named after arms, defaults to equal weight} + +\item{method}{\code{character()}\cr +Function used to compute within-arm variability, must be one of: +\code{sd}, \code{var}, \code{range}, defaults to \code{var}} + +\item{p}{\code{numeric()}\cr +single value, proportion of randomness (0, 1) in the randomization +vs determinism, defaults to 85\% deterministic} +} +\value{ +\code{character()}\cr +name of the arm assigned to the patient +} +\description{ +Dynamic randomization +} +\examples{ +n_at_the_moment <- 3 +arms <- c("control", "active low", "active high") +sex <- sample(seq(1, 0), + 3, + replace = TRUE, + prob = c(0.4, 0.6) +) +diabetes <- sample(c("diabetes", "no diabetes"), nsample, replace = TRUE, prob = c(0.2, 0.8)) +mat_of_covars <- cbind(c1, c2) +colnames(mat_of_covars) <- c("Sex", "Diabetes") +covar_class <- c("ordinal", "numeric", "ordinal", "numeric") +wght <- c(1 / 4, 1 / 4, 1 / 4, 1 / 4) + +resrand <- integer() +init_pat <- length(c1) +resrand[1:init_pat] <- sample(arms, + init_pat, + replace = TRUE, + prob = ratio / sum(ratio) +) + +randomize_dynamic( + covariates = mat_of_covars, + patnum = init_pat + 1, + weights = wght, + ratio = ratio, + no_of_trt = no_of_arms, + arms = arms, + current_state = resrand, + p = 0.85, + init_patnum = init_pat +) + +} diff --git a/man/randomize_simple.Rd b/man/randomize_simple.Rd index 2a24ef8..2da166f 100644 --- a/man/randomize_simple.Rd +++ b/man/randomize_simple.Rd @@ -10,8 +10,9 @@ randomize_simple(arms, ratio) \item{arms}{\code{character()}\cr Arm names.} -\item{ratio}{\code{numeric()}\cr -Ratio of patient assignment to each arm. Must be the same length as \code{arms}.} +\item{ratio}{\code{integer()}\cr +Vector of positive integers, equal in length to number of arms, +named after arms, defaults to equal weight} } \value{ Selected arm assignment. diff --git a/man/unbiased-package.Rd b/man/unbiased-package.Rd index f21c076..14c1651 100644 --- a/man/unbiased-package.Rd +++ b/man/unbiased-package.Rd @@ -7,6 +7,13 @@ \title{unbiased: What the Package Does (One Line, Title Case)} \description{ What the package does (one paragraph). +} +\seealso{ +Useful links: +\itemize{ + \item \url{https://ttscience.github.io/unbiased/} +} + } \author{ \strong{Maintainer}: First Last \email{first.last@example.com} (\href{https://orcid.org/YOUR-ORCID-ID}{ORCID}) diff --git a/renv.lock b/renv.lock index b2abc3d..029e446 100644 --- a/renv.lock +++ b/renv.lock @@ -80,6 +80,16 @@ ], "Hash": "c39fbec8a30d23e721980b8afb31984c" }, + "base64enc": { + "Package": "base64enc", + "Version": "0.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "543776ae6848fde2f48ff3816d0628bc" + }, "bit": { "Package": "bit", "Version": "4.0.5", @@ -123,6 +133,38 @@ "Repository": "RSPM", "Hash": "976cf154dfb043c012d87cddd8bca363" }, + "bslib": { + "Package": "bslib", + "Version": "0.6.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "cachem", + "grDevices", + "htmltools", + "jquerylib", + "jsonlite", + "lifecycle", + "memoise", + "mime", + "rlang", + "sass" + ], + "Hash": "c0d8599494bc7fb408cd206bbdd9cab0" + }, + "cachem": { + "Package": "cachem", + "Version": "1.0.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "fastmap", + "rlang" + ], + "Hash": "c35768291560ce302c0a6589f92e837d" + }, "callr": { "Package": "callr", "Version": "3.7.3", @@ -259,6 +301,26 @@ ], "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" }, + "downlit": { + "Package": "downlit", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "brio", + "desc", + "digest", + "evaluate", + "fansi", + "memoise", + "rlang", + "vctrs", + "withr", + "yaml" + ], + "Hash": "14fa1f248b60ed67e1f5418391a17b14" + }, "dplyr": { "Package": "dplyr", "Version": "1.1.3", @@ -323,6 +385,18 @@ "Repository": "CRAN", "Hash": "f7736a18de97dea803bde0a2daaafb27" }, + "fontawesome": { + "Package": "fontawesome", + "Version": "0.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmltools", + "rlang" + ], + "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" + }, "fs": { "Package": "fs", "Version": "1.6.3", @@ -356,6 +430,17 @@ ], "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" }, + "highr": { + "Package": "highr", + "Version": "0.10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "xfun" + ], + "Hash": "06230136b2d2b9ba5805e1963fa6e890" + }, "hms": { "Package": "hms", "Version": "1.1.3", @@ -370,6 +455,23 @@ ], "Hash": "b59377caa7ed00fa41808342002138f9" }, + "htmltools": { + "Package": "htmltools", + "Version": "0.5.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "digest", + "ellipsis", + "fastmap", + "grDevices", + "rlang", + "utils" + ], + "Hash": "2d7b3857980e0e0d0a1fd6f11928ab0f" + }, "httpuv": { "Package": "httpuv", "Version": "1.6.11", @@ -385,9 +487,24 @@ ], "Hash": "838602f54e32c1a0f8cc80708cefcefa" }, + "httr": { + "Package": "httr", + "Version": "1.4.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "curl", + "jsonlite", + "mime", + "openssl" + ], + "Hash": "ac107251d9d9fd72f0ca8049988f1d7f" + }, "httr2": { "Package": "httr2", - "Version": "0.2.3", + "Version": "1.0.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -396,13 +513,25 @@ "cli", "curl", "glue", + "lifecycle", "magrittr", "openssl", "rappdirs", "rlang", + "vctrs", "withr" ], - "Hash": "193bb297368afbbb42dc85784a46b36e" + "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" + }, + "jquerylib": { + "Package": "jquerylib", + "Version": "0.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools" + ], + "Hash": "5aab57a3bd297eee1c1d862735972182" }, "jsonlite": { "Package": "jsonlite", @@ -414,6 +543,22 @@ ], "Hash": "266a20443ca13c65688b2116d5220f76" }, + "knitr": { + "Package": "knitr", + "Version": "1.45", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "evaluate", + "highr", + "methods", + "tools", + "xfun", + "yaml" + ], + "Hash": "1ec462871063897135c1bcbe0fc8f07d" + }, "later": { "Package": "later", "Version": "1.3.1", @@ -461,6 +606,17 @@ ], "Hash": "7ce2733a9826b3aeb1775d56fd305472" }, + "memoise": { + "Package": "memoise", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cachem", + "rlang" + ], + "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" + }, "mime": { "Package": "mime", "Version": "0.12", @@ -526,6 +682,36 @@ ], "Hash": "01f28d4278f15c76cddbea05899c5d6f" }, + "pkgdown": { + "Package": "pkgdown", + "Version": "2.0.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bslib", + "callr", + "cli", + "desc", + "digest", + "downlit", + "fs", + "httr", + "jsonlite", + "magrittr", + "memoise", + "purrr", + "ragg", + "rlang", + "rmarkdown", + "tibble", + "whisker", + "withr", + "xml2", + "yaml" + ], + "Hash": "16fa15449c930bf3a7761d3c68f8abf9" + }, "pkgload": { "Package": "pkgload", "Version": "1.3.3", @@ -650,6 +836,17 @@ ], "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" }, + "ragg": { + "Package": "ragg", + "Version": "1.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "systemfonts", + "textshaping" + ], + "Hash": "90a1b8b7e518d7f90480d56453b4d062" + }, "rappdirs": { "Package": "rappdirs", "Version": "0.3.3", @@ -691,6 +888,30 @@ ], "Hash": "a85c767b55f0bf9b7ad16c6d7baee5bb" }, + "rmarkdown": { + "Package": "rmarkdown", + "Version": "2.25", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bslib", + "evaluate", + "fontawesome", + "htmltools", + "jquerylib", + "jsonlite", + "knitr", + "methods", + "stringr", + "tinytex", + "tools", + "utils", + "xfun", + "yaml" + ], + "Hash": "d65e35823c817f09f4de424fcdfa812a" + }, "rprojroot": { "Package": "rprojroot", "Version": "2.0.3", @@ -701,6 +922,20 @@ ], "Hash": "1de7ab598047a87bba48434ba35d497d" }, + "sass": { + "Package": "sass", + "Version": "0.4.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "fs", + "htmltools", + "rappdirs", + "rlang" + ], + "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" + }, "sodium": { "Package": "sodium", "Version": "1.3.0", @@ -752,6 +987,17 @@ "Repository": "RSPM", "Hash": "3a1be13d68d47a8cd0bfd74739ca1555" }, + "systemfonts": { + "Package": "systemfonts", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "15b594369e70b975ba9f064295983499" + }, "testthat": { "Package": "testthat", "Version": "3.2.0", @@ -782,6 +1028,18 @@ ], "Hash": "877508719fcb8c9525eccdadf07a5102" }, + "textshaping": { + "Package": "textshaping", + "Version": "0.3.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11", + "systemfonts" + ], + "Hash": "997aac9ad649e0ef3b97f96cddd5622b" + }, "tibble": { "Package": "tibble", "Version": "3.2.1", @@ -851,6 +1109,16 @@ ], "Hash": "8548b44f79a35ba1791308b61e6012d7" }, + "tinytex": { + "Package": "tinytex", + "Version": "0.49", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "xfun" + ], + "Hash": "5ac22900ae0f386e54f1c307eca7d843" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", @@ -903,6 +1171,13 @@ ], "Hash": "75d8b5b05fe22659b54076563f83f26a" }, + "whisker": { + "Package": "whisker", + "Version": "0.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "c6abfa47a46d281a7d5159d0a8891e88" + }, "withr": { "Package": "withr", "Version": "2.5.2", @@ -915,6 +1190,37 @@ "stats" ], "Hash": "4b25e70111b7d644322e9513f403a272" + }, + "xfun": { + "Package": "xfun", + "Version": "0.41", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "stats", + "tools" + ], + "Hash": "460a5e0fe46a80ef87424ad216028014" + }, + "xml2": { + "Package": "xml2", + "Version": "1.3.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "methods", + "rlang" + ], + "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" + }, + "yaml": { + "Package": "yaml", + "Version": "2.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "29240487a071f535f5e5d5a323b7afbd" } } } diff --git a/tests/testthat/test-randomize-dynamic.R b/tests/testthat/test-randomize-dynamic.R new file mode 100644 index 0000000..e261cfe --- /dev/null +++ b/tests/testthat/test-randomize-dynamic.R @@ -0,0 +1,24 @@ +testthat("You can call function and it returns arm", { + n_at_the_moment <- 3 + arms <- c("control", "active low", "active high") + sex <- sample(c("F", "M"), + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.6) + ) + diabetes <- + sample(c("diabetes", "no diabetes"), + n_at_the_moment, + replace = TRUE, + prob = c(0.2, 0.8) + ) + arm <- + sample(arms, + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.4, 0.2) + ) + covar_df <- data.frame(sex, diabetes, arm) + + randomize_dynamic(arms = arms, current_state = covar_df) +}) From 871df52f68efc959371b9e4fc19e98348990cb3c Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 21 Dec 2023 12:22:16 +0000 Subject: [PATCH 068/240] Working piece, test lacking --- R/randomize-dynamic.R | 150 ++++++++++++++---------- R/randomize-simple.R | 11 +- man/randomize_dynamic.Rd | 3 +- tests/testthat/test-randomize-dynamic.R | 9 +- 4 files changed, 108 insertions(+), 65 deletions(-) diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index 64374b9..814c6c6 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -3,7 +3,8 @@ #' @inheritParams randomize_simple #' #' @param current_state `data.frame()`\cr -#' table of covariates and current arm assignments in column `arm` +#' table of covariates and current arm assignments in column `arm`, +#' last row contains the new patient with empty string for `arm` #' @param weights `numeric()`\cr #' vector of positive weights, equal in length to number of covariates, #' numbered after covariates, defaults to equal weights @@ -58,23 +59,53 @@ randomize_dynamic <- ratio, method = "var", p = 0.85) { - browser() # Assertions + assert_character( + arms, + min.len = 2, + min.chars = 1) assert_choice( method, choices = c("range", "var", "sd") ) assert_data_frame( - covariates, + current_state, any.missing = FALSE, - min.cols = 1, - null.ok = TRUE + min.cols = 2, + min.rows = 1, + null.ok = FALSE + ) + assert_names( + colnames(current_state), + must.include = "arm" ) + assert_character( + current_state$arm[nrow(current_state)], + max.chars = 0) + n_covariates <- + (ncol(current_state) - 1) + n_arms <- + length(arms) + + assert_subset( + unique(current_state$arm), + choices = c(arms, "") + ) + # Validate argument presence and revert to defaults if not provided + if (rlang::is_missing(ratio)) { + ratio <- rep(1L, n_arms) + names(ratio) <- arms + } + if (rlang::is_missing(weights)) { + weights <- rep(1/n_covariates, n_covariates) + names(weights) <- colnames(current_state)[colnames(current_state) != "arm"] + } + assert_numeric( weights, any.missing = FALSE, - len = arms, + len = n_covariates, null.ok = FALSE, lower = 0, finite = TRUE, @@ -83,15 +114,14 @@ randomize_dynamic <- assert_names( names(weights), must.include = - colnames(covariates)[colnames(covariates) != "arms"] + colnames(current_state)[colnames(current_state) != "arm"] ) assert_integer( ratio, any.missing = FALSE, - len = ncol(covariates) - 1, + len = n_arms, null.ok = FALSE, lower = 0, - finite = TRUE, all.missing = FALSE, names = "named" ) @@ -107,63 +137,65 @@ randomize_dynamic <- null.ok = FALSE ) - # Computations - n_at_the_moment <- nrow(covariates) - n_arms <- length(arms) - init_patnum <- patnum - 1 + n_at_the_moment <- nrow(current_state) - 1 - if (patnum < init_patnum) { - res <- NA - } else { - find_trt_group <- apply( # similarity matrix y - randomized patient, x - rest - covariates[1:(patnum - 1), , drop = FALSE], 1, - function(x, y) { - as.numeric(x == y) - }, covariates[patnum, ] - ) + if (n_at_the_moment == 0) { + return(randomize_simple(arms, ratio)) + } - n_duplicate_trt_group <- matrix(0, ncol(covariates), no_of_trt) + browser() - n_duplicate_trt_group <- sapply(1:no_of_trt, function(x) { - apply( # sum of similar variants - as.matrix( - find_trt_group[, current_state[1:(patnum - 1)] == arms[x]] - ), 1, sum - ) - }) + current_state |> + dplyr::filter(arm != "") |> + dplyr::transmute() - imbalance <- sapply(1:no_of_trt, function(x) { - tmp <- n_duplicate_trt_group - tmp[, x] <- tmp[, x] + 1 - num_lvl <- tmp %*% diag(1 / ratio) - imb_margin <- apply(num_lvl, 1, get(method)) # switch range, sd, var - if (method == "range") { - imb_margin <- imb_margin[2, ] - imb_margin[1, ] - } - sum(weights %*% imb_margin) - }) - } + covariate_similarity <- apply( + current_state[-nrow(current_state), names(current_state) != "arm"], 1, + function(x, y) { + x == y + }, current_state[nrow(current_state), names(current_state) != "arm"] + ) - high_prob <- arms[which.min(imbalance)] # trt_mini - low_prob <- arms[-high_prob] + rownames(covariate_similarity) <- + names(current_state)[names(current_state) != "arm"] - res <- - if (length(high_prob) < no_of_trt) { - res <- - sample(c(high_prob, low_prob), 1, - prob = c( - rep(p / length(high_prob), length(high_prob)), - rep( - (1 - p) / length(low_prob), - length(low_prob) - ) - ) - ) - } else { - res <- - sample(arms, 1, prob = rep(1 / no_of_trt, no_of_trt)) + arms_similarity <- sapply(arms, function(x) { + apply( # sum of similar variants + as.matrix( + covariate_similarity[, current_state$arm[1:n_at_the_moment] == x] + ), 1, sum + ) + }) + + imbalance <- sapply(arms, function(x) { + arms_similarity[, which(colnames(arms_similarity) == x)] <- + arms_similarity[, which(colnames(arms_similarity) == x)] + 1 + num_lvl <- arms_similarity %*% diag(1 / ratio) + covariate_imbalance <- apply(num_lvl, 1, get(method)) # range, sd, var + if (method == "range") { + covariate_imbalance <- covariate_imbalance[2, ] - + covariate_imbalance[1, ] } - return(res) + sum(weights %*% covariate_imbalance) + }) + + high_prob_arms <- names(which.min(imbalance)) + low_prob_arms <- arms[!arms %in% high_prob_arms] + + if (length(high_prob_arms) == n_arms) { + return(randomize_simple(arms, ratio)) + } + + sample( + c(high_prob_arms, low_prob_arms), 1, + prob = c( + rep(p / length(high_prob_arms), length(high_prob_arms)), + rep( + (1 - p) / length(low_prob_arms), + length(low_prob_arms) + ) + ) + ) } diff --git a/R/randomize-simple.R b/R/randomize-simple.R index d1c3dfb..6e63d9b 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -17,16 +17,25 @@ #' #' @export randomize_simple <- function(arms, ratio) { + # Validate argument presence and revert to defaults if not provided + if (rlang::is_missing(ratio)) { + ratio <- rep(1, rep(length(arms))) + names(ratio) <- arms + } + + # Argument assertions assert_character( arms, any.missing = FALSE, unique = TRUE, min.chars = 1) + if (condition) { + + } assert_integer( ratio, any.missing = FALSE, lower = 0, - finite = TRUE, len = length(arms), names = "named" ) diff --git a/man/randomize_dynamic.Rd b/man/randomize_dynamic.Rd index 9182024..9de23e6 100644 --- a/man/randomize_dynamic.Rd +++ b/man/randomize_dynamic.Rd @@ -18,7 +18,8 @@ randomize_dynamic( Arm names.} \item{current_state}{\code{data.frame()}\cr -table of covariates and current arm assignments in column \code{arm}} +table of covariates and current arm assignments in column \code{arm}, +last row contains the new patient with empty string for \code{arm}} \item{weights}{\code{numeric()}\cr vector of positive weights, equal in length to number of covariates, diff --git a/tests/testthat/test-randomize-dynamic.R b/tests/testthat/test-randomize-dynamic.R index e261cfe..a53ff4c 100644 --- a/tests/testthat/test-randomize-dynamic.R +++ b/tests/testthat/test-randomize-dynamic.R @@ -1,14 +1,14 @@ testthat("You can call function and it returns arm", { - n_at_the_moment <- 3 + n_at_the_moment <- 10 arms <- c("control", "active low", "active high") sex <- sample(c("F", "M"), - n_at_the_moment, + n_at_the_moment + 1, replace = TRUE, prob = c(0.4, 0.6) ) diabetes <- sample(c("diabetes", "no diabetes"), - n_at_the_moment, + n_at_the_moment + 1, replace = TRUE, prob = c(0.2, 0.8) ) @@ -17,7 +17,8 @@ testthat("You can call function and it returns arm", { n_at_the_moment, replace = TRUE, prob = c(0.4, 0.4, 0.2) - ) + ) |> + c("") covar_df <- data.frame(sex, diabetes, arm) randomize_dynamic(arms = arms, current_state = covar_df) From 675f32240012351c6533b4b570236c9fc2d3fcbc Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 21 Dec 2023 12:34:16 +0000 Subject: [PATCH 069/240] docs (WIP) --- R/randomize-dynamic.R | 87 ++++++++++++++++++++++++-------------- man/randomize_dynamic.Rd | 90 ++++++++++++++++++++++++++-------------- 2 files changed, 116 insertions(+), 61 deletions(-) diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index 814c6c6..44e3ab0 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -1,4 +1,39 @@ -#' Dynamic randomization +#' Randomize Dynamic Algorithm for Patient Allocation +#' +#' The `randomize_dynamic` function implements the dynamic randomization +#' algorithm using the minimization method proposed by Pocock (Pocock and Simon +#' 1975). It requires defining basic study parameters: the number of arms (k), +#' covariate values, patient allocation ratios (\(a_{k}\)), weights for the +#' covariates (\(w_{i}\)), and the maximum probability of assigning a patient to +#' the group with the smallest total unbalance multiplied by the respective +#' weights (\(G_{k}\)). As the total unbalance for the first patient is the same +#' regardless of the assigned arm, this patient is randomly allocated to a given +#' arm. Subsequent patients are randomized based on the calculation of the +#' unbalance depending on the selected method: "range", "var" (variance), or +#' "sd" (standard deviation). In the case of two arms, the "range" method is +#' equivalent to the "sd" method. +#' +#' Initially, the algorithm creates a matrix of results comparing a newly +#' randomized patient with the current balance of patients based on the defined +#' covariates (C). In the next step, for each arm and specified covariate, +#' various scenarios of patient allocation are calculated. The existing results +#' (n) are updated with the new patient, and then, considering the ratio +#' coefficients, the results are divided by the specific allocation ratio +#' (\(a_{k}\)). Depending on the method, the total unbalance is then calculated, +#' taking into account the allocation (\(a_{k}\)) and the number of covariates, +#' where i = 1,2,…,C. +#' +#' - `range`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack RANGE(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), +#' - `var`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack VAR(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), +#' - `sd`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack SD(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\) +#' +#' Based on the number of defined arms (K), the minimum value of \(G_{k}\) +#' (defined as the weighted sum of the level-based imbalance) selects the arm to +#' which the patient will be assigned with a predefined probability. The +#' probability that a patient will be assigned to any other arm will then be +#' \(\frac{(1 - p)}{(K - 1)}\) for each of the remaining arms. +#' +#' @references Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. #' #' @inheritParams randomize_simple #' @@ -18,38 +53,30 @@ #' @return `character()`\cr #' name of the arm assigned to the patient #' @examples -#' n_at_the_moment <- 3 +#' n_at_the_moment <- 10 #' arms <- c("control", "active low", "active high") -#' sex <- sample(seq(1, 0), -#' 3, -#' replace = TRUE, -#' prob = c(0.4, 0.6) -#' ) -#' diabetes <- sample(c("diabetes", "no diabetes"), nsample, replace = TRUE, prob = c(0.2, 0.8)) -#' mat_of_covars <- cbind(c1, c2) -#' colnames(mat_of_covars) <- c("Sex", "Diabetes") -#' covar_class <- c("ordinal", "numeric", "ordinal", "numeric") -#' wght <- c(1 / 4, 1 / 4, 1 / 4, 1 / 4) -#' -#' resrand <- integer() -#' init_pat <- length(c1) -#' resrand[1:init_pat] <- sample(arms, -#' init_pat, -#' replace = TRUE, -#' prob = ratio / sum(ratio) +#' sex <- sample(c("F", "M"), +#' n_at_the_moment + 1, +#' replace = TRUE, +#' prob = c(0.4, 0.6) #' ) +#' diabetes <- +#' sample(c("diabetes", "no diabetes"), +#' n_at_the_moment + 1, +#' replace = TRUE, +#' prob = c(0.2, 0.8) +#' ) +#' arm <- +#' sample(arms, +#' n_at_the_moment, +#' replace = TRUE, +#' prob = c(0.4, 0.4, 0.2) +#' ) |> +#' c("") +#' covar_df <- data.frame(sex, diabetes, arm) +#' covar_df #' -#' randomize_dynamic( -#' covariates = mat_of_covars, -#' patnum = init_pat + 1, -#' weights = wght, -#' ratio = ratio, -#' no_of_trt = no_of_arms, -#' arms = arms, -#' current_state = resrand, -#' p = 0.85, -#' init_patnum = init_pat -#' ) +#' randomize_dynamic(arms = arms, current_state = covar_df) #' #' @export randomize_dynamic <- diff --git a/man/randomize_dynamic.Rd b/man/randomize_dynamic.Rd index 9de23e6..0b1ddfc 100644 --- a/man/randomize_dynamic.Rd +++ b/man/randomize_dynamic.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/randomize-dynamic.R \name{randomize_dynamic} \alias{randomize_dynamic} -\title{Dynamic randomization} +\title{Randomize Dynamic Algorithm for Patient Allocation} \usage{ randomize_dynamic( arms, @@ -42,40 +42,68 @@ vs determinism, defaults to 85\% deterministic} name of the arm assigned to the patient } \description{ -Dynamic randomization +The \code{randomize_dynamic} function implements the dynamic randomization +algorithm using the minimization method proposed by Pocock (Pocock and Simon +1975). It requires defining basic study parameters: the number of arms (k), +covariate values, patient allocation ratios (\(a_{k}\)), weights for the +covariates (\(w_{i}\)), and the maximum probability of assigning a patient to +the group with the smallest total unbalance multiplied by the respective +weights (\(G_{k}\)). As the total unbalance for the first patient is the same +regardless of the assigned arm, this patient is randomly allocated to a given +arm. Subsequent patients are randomized based on the calculation of the +unbalance depending on the selected method: "range", "var" (variance), or +"sd" (standard deviation). In the case of two arms, the "range" method is +equivalent to the "sd" method. +} +\details{ +Initially, the algorithm creates a matrix of results comparing a newly +randomized patient with the current balance of patients based on the defined +covariates (C). In the next step, for each arm and specified covariate, +various scenarios of patient allocation are calculated. The existing results +(n) are updated with the new patient, and then, considering the ratio +coefficients, the results are divided by the specific allocation ratio +(\(a_{k}\)). Depending on the method, the total unbalance is then calculated, +taking into account the allocation (\(a_{k}\)) and the number of covariates, +where i = 1,2,…,C. +\itemize{ +\item \code{range}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack RANGE(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), +\item \code{var}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack VAR(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), +\item \code{sd}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack SD(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\) +} + +Based on the number of defined arms (K), the minimum value of \(G_{k}\) +(defined as the weighted sum of the level-based imbalance) selects the arm to +which the patient will be assigned with a predefined probability. The +probability that a patient will be assigned to any other arm will then be +\(\frac{(1 - p)}{(K - 1)}\) for each of the remaining arms. } \examples{ -n_at_the_moment <- 3 +n_at_the_moment <- 10 arms <- c("control", "active low", "active high") -sex <- sample(seq(1, 0), - 3, - replace = TRUE, - prob = c(0.4, 0.6) -) -diabetes <- sample(c("diabetes", "no diabetes"), nsample, replace = TRUE, prob = c(0.2, 0.8)) -mat_of_covars <- cbind(c1, c2) -colnames(mat_of_covars) <- c("Sex", "Diabetes") -covar_class <- c("ordinal", "numeric", "ordinal", "numeric") -wght <- c(1 / 4, 1 / 4, 1 / 4, 1 / 4) - -resrand <- integer() -init_pat <- length(c1) -resrand[1:init_pat] <- sample(arms, - init_pat, - replace = TRUE, - prob = ratio / sum(ratio) +sex <- sample(c("F", "M"), + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.4, 0.6) ) +diabetes <- + sample(c("diabetes", "no diabetes"), + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.2, 0.8) + ) +arm <- + sample(arms, + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.4, 0.2) + ) |> + c("") +covar_df <- data.frame(sex, diabetes, arm) +covar_df -randomize_dynamic( - covariates = mat_of_covars, - patnum = init_pat + 1, - weights = wght, - ratio = ratio, - no_of_trt = no_of_arms, - arms = arms, - current_state = resrand, - p = 0.85, - init_patnum = init_pat -) +randomize_dynamic(arms = arms, current_state = covar_df) } +\references{ +Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. +} From 07c3bc1a2fe687c7ee5d7114501274de4e24f8b6 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 21 Dec 2023 13:03:06 +0000 Subject: [PATCH 070/240] Docs (WIP) --- DESCRIPTION | 4 +++- NAMESPACE | 1 + R/randomize-dynamic.R | 3 +-- R/unbiased-package.R | 1 + man/randomize_dynamic.Rd | 1 + renv.lock | 7 +++++++ 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index c1c8db2..a594f39 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -9,7 +9,8 @@ License: MIT + file LICENSE Imports: checkmate, dbplyr, - plumber + plumber, + mathjaxr Suggests: callr, httr2, @@ -17,6 +18,7 @@ Suggests: testthat (>= 3.0.0), usethis, withr +RdMacros: mathjaxr Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) diff --git a/NAMESPACE b/NAMESPACE index 59bbbd0..3967f3b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -10,3 +10,4 @@ export(run_unbiased_db) export(study_exists) import(checkmate) import(dplyr) +import(mathjaxr) diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index 44e3ab0..9c52bcd 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -1,5 +1,6 @@ #' Randomize Dynamic Algorithm for Patient Allocation #' +#' \loadmathjax #' The `randomize_dynamic` function implements the dynamic randomization #' algorithm using the minimization method proposed by Pocock (Pocock and Simon #' 1975). It requires defining basic study parameters: the number of arms (k), @@ -172,8 +173,6 @@ randomize_dynamic <- return(randomize_simple(arms, ratio)) } - browser() - current_state |> dplyr::filter(arm != "") |> dplyr::transmute() diff --git a/R/unbiased-package.R b/R/unbiased-package.R index 0bca4eb..8e6f8db 100644 --- a/R/unbiased-package.R +++ b/R/unbiased-package.R @@ -1,5 +1,6 @@ #' @import checkmate #' @import dplyr +#' @import mathjaxr #' #' @keywords internal "_PACKAGE" diff --git a/man/randomize_dynamic.Rd b/man/randomize_dynamic.Rd index 0b1ddfc..2c93093 100644 --- a/man/randomize_dynamic.Rd +++ b/man/randomize_dynamic.Rd @@ -42,6 +42,7 @@ vs determinism, defaults to 85\% deterministic} name of the arm assigned to the patient } \description{ +\loadmathjax The \code{randomize_dynamic} function implements the dynamic randomization algorithm using the minimization method proposed by Pocock (Pocock and Simon 1975). It requires defining basic study parameters: the number of arms (k), diff --git a/renv.lock b/renv.lock index 029e446..8a21f3f 100644 --- a/renv.lock +++ b/renv.lock @@ -606,6 +606,13 @@ ], "Hash": "7ce2733a9826b3aeb1775d56fd305472" }, + "mathjaxr": { + "Package": "mathjaxr", + "Version": "1.6-0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "87da6ccdcee6077a7d5719406bf3ae45" + }, "memoise": { "Package": "memoise", "Version": "2.0.1", From e821f23d06277d223f702c8493c34ef07f448a15 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 21 Dec 2023 17:00:19 +0000 Subject: [PATCH 071/240] WIP: in changes --- DESCRIPTION | 3 +- R/helpers.R | 26 ++++++ R/randomize-dynamic.R | 65 +++++++------- R/randomize-simple.R | 10 +-- renv.lock | 5 +- tests/testthat/test-randomize-dynamic.R | 111 +++++++++++++++++++----- tests/testthat/test-randomize-simple.R | 87 ++++++------------- 7 files changed, 185 insertions(+), 122 deletions(-) create mode 100644 R/helpers.R diff --git a/DESCRIPTION b/DESCRIPTION index a594f39..e213a3c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,7 +10,8 @@ Imports: checkmate, dbplyr, plumber, - mathjaxr + mathjaxr, + tibble Suggests: callr, httr2, diff --git a/R/helpers.R b/R/helpers.R new file mode 100644 index 0000000..535a971 --- /dev/null +++ b/R/helpers.R @@ -0,0 +1,26 @@ +#' Compare rows of two dataframes +#' +#' Takes dataframe B (presumably with one row / patient) and compares it to all +#' rows of A (presumably already randomized patietns) +#' +#' @param A data.frame with all patients +#' @param B data.frame with new patient +#' +#' @return data.frame with columns as in A and B, filled with TRUE if there is +#' match in covariate and FALSE if not +#' +#' @examples +compare_rows <- function(A, B) { + # Find common column names + common_cols <- intersect(names(A), names(B)) + + # Compare each common column of A with B + comparisons <- lapply(common_cols, function(col) { + A[[col]] == B[[col]] + }) + + # Combine the comparisons into a new dataframe + C <- data.frame(comparisons) + names(C) <- common_cols + tibble::as_tibble(C) +} diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index 9c52bcd..f3bb073 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -74,7 +74,7 @@ #' prob = c(0.4, 0.4, 0.2) #' ) |> #' c("") -#' covar_df <- data.frame(sex, diabetes, arm) +#' covar_df <- tibble(sex, diabetes, arm) #' covar_df #' #' randomize_dynamic(arms = arms, current_state = covar_df) @@ -88,7 +88,6 @@ randomize_dynamic <- method = "var", p = 0.85) { # Assertions - assert_character( arms, min.len = 2, @@ -97,7 +96,7 @@ randomize_dynamic <- method, choices = c("range", "var", "sd") ) - assert_data_frame( + assert_tibble( current_state, any.missing = FALSE, min.cols = 2, @@ -110,7 +109,8 @@ randomize_dynamic <- ) assert_character( current_state$arm[nrow(current_state)], - max.chars = 0) + max.chars = 0, .var.name = "Last value of 'arm'") + n_covariates <- (ncol(current_state) - 1) n_arms <- @@ -118,7 +118,8 @@ randomize_dynamic <- assert_subset( unique(current_state$arm), - choices = c(arms, "") + choices = c(arms, ""), + .var.name = "'arm' variable in dataframe" ) # Validate argument presence and revert to defaults if not provided if (rlang::is_missing(ratio)) { @@ -168,46 +169,50 @@ randomize_dynamic <- # Computations n_at_the_moment <- nrow(current_state) - 1 + covariate_names <- names(current_state)[names(current_state) != "arm"] if (n_at_the_moment == 0) { return(randomize_simple(arms, ratio)) } - current_state |> - dplyr::filter(arm != "") |> - dplyr::transmute() - - covariate_similarity <- apply( - current_state[-nrow(current_state), names(current_state) != "arm"], 1, - function(x, y) { - x == y - }, current_state[nrow(current_state), names(current_state) != "arm"] - ) + arms_similarity <- + # compare new subject to all old subjects + compare_rows( + current_state[-nrow(current_state), names(current_state) != "arm"], + current_state[nrow(current_state), names(current_state) != "arm"] + ) |> + split(current_state$arm[1:n_at_the_moment]) |> # split by arm + lapply(colSums) |> # and compute sumber of similarities in each arm + dplyr::bind_rows(.id = "arm") - rownames(covariate_similarity) <- - names(current_state)[names(current_state) != "arm"] - - arms_similarity <- sapply(arms, function(x) { - apply( # sum of similar variants - as.matrix( - covariate_similarity[, current_state$arm[1:n_at_the_moment] == x] - ), 1, sum - ) - }) + # arms_similarity <- sapply(arms, function(x) { + # # for each arm + # apply( + # # for each covariate within arm (row of covariate_similarity) + # as.matrix( + # covariate_similarity[, current_state$arm[1:n_at_the_moment] == x] + # ), 1, sum # compute sum of similarities + # ) + # }) |> + # as.matrix() imbalance <- sapply(arms, function(x) { - arms_similarity[, which(colnames(arms_similarity) == x)] <- - arms_similarity[, which(colnames(arms_similarity) == x)] + 1 - num_lvl <- arms_similarity %*% diag(1 / ratio) + browser() + arms_similarity |> + # compute scenario where each arm (x) gets new subject + dplyr::mutate(dplyr::across(dplyr::where(is.numeric), + ~ dplyr::if_else(arm == x, .x + 1, .x))) + # tu skończyłem, teraz przeważyć i dalej + num_lvl <- arms_similarity %*% diag(1 / ratio[arms]) covariate_imbalance <- apply(num_lvl, 1, get(method)) # range, sd, var if (method == "range") { covariate_imbalance <- covariate_imbalance[2, ] - covariate_imbalance[1, ] } - sum(weights %*% covariate_imbalance) + sum(weights[covariate_names] %*% covariate_imbalance) }) - high_prob_arms <- names(which.min(imbalance)) + high_prob_arms <- names(which(imbalance == min(imbalance))) low_prob_arms <- arms[!arms %in% high_prob_arms] if (length(high_prob_arms) == n_arms) { diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 6e63d9b..9624260 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -7,8 +7,8 @@ #' @param arms `character()`\cr #' Arm names. #' @param ratio `integer()`\cr -#' Vector of positive integers, equal in length to number of arms, -#' named after arms, defaults to equal weight +#' Vector of positive integers (0 is allowed), equal in length to number +#' of arms, named after arms, defaults to equal weight #' #' @return Selected arm assignment. #' @@ -19,7 +19,7 @@ randomize_simple <- function(arms, ratio) { # Validate argument presence and revert to defaults if not provided if (rlang::is_missing(ratio)) { - ratio <- rep(1, rep(length(arms))) + ratio <- rep(1L, rep(length(arms))) names(ratio) <- arms } @@ -29,9 +29,7 @@ randomize_simple <- function(arms, ratio) { any.missing = FALSE, unique = TRUE, min.chars = 1) - if (condition) { - } assert_integer( ratio, any.missing = FALSE, @@ -44,5 +42,5 @@ randomize_simple <- function(arms, ratio) { must.include = arms ) - sample(arms, 1, prob = ratio) + sample(arms, 1, prob = ratio[arms]) } diff --git a/renv.lock b/renv.lock index 8a21f3f..95574c5 100644 --- a/renv.lock +++ b/renv.lock @@ -1007,7 +1007,7 @@ }, "testthat": { "Package": "testthat", - "Version": "3.2.0", + "Version": "3.2.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1018,7 +1018,6 @@ "cli", "desc", "digest", - "ellipsis", "evaluate", "jsonlite", "lifecycle", @@ -1033,7 +1032,7 @@ "waldo", "withr" ], - "Hash": "877508719fcb8c9525eccdadf07a5102" + "Hash": "4767a686ebe986e6cb01d075b3f09729" }, "textshaping": { "Package": "textshaping", diff --git a/tests/testthat/test-randomize-dynamic.R b/tests/testthat/test-randomize-dynamic.R index a53ff4c..3c5bed8 100644 --- a/tests/testthat/test-randomize-dynamic.R +++ b/tests/testthat/test-randomize-dynamic.R @@ -1,25 +1,92 @@ -testthat("You can call function and it returns arm", { - n_at_the_moment <- 10 - arms <- c("control", "active low", "active high") - sex <- sample(c("F", "M"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.4, 0.6) +set.seed(seed = "345345") +n_at_the_moment <- 10 +arms <- c("control", "active low", "active high") +sex <- sample(c("F", "M"), + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.4, 0.6) +) +diabetes <- + sample(c("diabetes", "no diabetes"), + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.2, 0.8) ) - diabetes <- - sample(c("diabetes", "no diabetes"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.2, 0.8) - ) - arm <- - sample(arms, - n_at_the_moment, - replace = TRUE, - prob = c(0.4, 0.4, 0.2) - ) |> - c("") - covar_df <- data.frame(sex, diabetes, arm) +arm <- + sample(arms, + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.4, 0.2) + ) |> + c("") +covar_df <- tibble::tibble(sex, diabetes, arm) - randomize_dynamic(arms = arms, current_state = covar_df) +test_that("You can call function and it returns arm", { + expect_subset( + randomize_dynamic(arms = arms, current_state = covar_df), choices = arms + ) +}) + +test_that("Assertions work", { + expect_error(randomize_dynamic(arms = c(1, 2), current_state = covar_df), + regexp = "Must be of type 'character'") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + method = "nonexistent"), + regexp = "Must be element of set .'range','var','sd'., but is 'nonexistent'") + expect_error(randomize_dynamic(arms = arms, current_state = "5 patietns OK"), + regexp = "Assertion on 'current_state' failed: Must be a tibble, not character") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df[, 1:2]), + regexp = "Names must include the elements .'arm'.") + # Last subject already randomized + expect_error(randomize_dynamic(arms = arms, current_state = covar_df[1:3,]), + regexp = "must have at most 0 characters") + expect_error(randomize_dynamic(arms = c("foo", "bar"), + current_state = covar_df), + regexp = "Must be a subset of .'foo','bar',''.") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + weights = c("sex" = -1, "diabetes" = 2)), + regexp = "Element 1 is not >= 0") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + weights = c("wrong" = 1, "diabetes" = 2)), + regexp = "is missing elements .'sex'.") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + ratio = c("control" = 1, + "active low" = 2, + "active high" = 1)), + regexp = "Must be of type 'integer', not 'double'") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + ratio = c("control" = 1L, + "active high" = 1L)), + regexp = "Must have length 3, but has length 2") + expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + p = 12), + regexp = "Assertion on 'p' failed: Element 1 is not <= 1") +}) + +test_that("Function randomizes first patient randomly", { + randomized <- + sapply(1:100, function(x) { + randomize_dynamic(arms = arms, + current_state = covar_df[nrow(covar_df), ]) + }) + test <- prop.test(x = sum(randomized == "control"), + n = length(randomized), + p = 1/3, + conf.level = 0.95, + correct = FALSE) + expect_gt(test$p.value, 0.01) +}) + +test_that("Function randomizes second patient deterministically", { + arms <- c("A", "B") + situation <- tibble::tibble(sex = c("F", "F"), + arm = c("A", "")) + randomized <- + sapply(1:100, function(x) { + randomize_dynamic(arms = arms, + current_state = situation, + p = 1) + }) + + expect_gt(test$p.value, 0.01) }) diff --git a/tests/testthat/test-randomize-simple.R b/tests/testthat/test-randomize-simple.R index 8d31e7a..51f2486 100644 --- a/tests/testthat/test-randomize-simple.R +++ b/tests/testthat/test-randomize-simple.R @@ -1,99 +1,66 @@ test_that("returns a single string", { expect_vector( - randomize_simple(c("active", "placebo"), c(2, 1)), + randomize_simple(c("active", "placebo"), + c("active" = 2L, "placebo" = 1L)), ptype = character(), size = 1 ) }) test_that("returns one of the arms", { - arms <- c("arm 1", "arm 2") + arms <- c("active", "placebo") expect_subset( - randomize_simple(arms, c(1, 1)), + randomize_simple(arms), arms ) }) test_that("ratio equal to 0 means that this arm is never assigned", { expect_identical( - randomize_simple(c("yes", "no"), c(1, 0)), + randomize_simple(c("yes", "no"), c("yes" = 2L, "no" = 0L)), "yes" ) }) test_that("incorrect parameters raise an exception", { # Incorrect arm type - expect_error(randomize_simple(c(7, 4), c(1, 2))) + expect_error(randomize_simple(c(7, 4))) # Incorrect ratio type expect_error(randomize_simple(c("roof", "basement"), c("high", "low"))) # Lengths not matching - expect_error(randomize_simple(c("Paris", "Barcelona"), c(1, 2, 1))) + expect_error(randomize_simple(c("Paris", "Barcelona"), + c("Paris" = 1L, "Barcelona" = 2L, "Warsaw" = 1L))) # Missing value - expect_error(randomize_simple(c("yen", NA), c(1, 1))) + expect_error(randomize_simple(c("yen", NA))) # Empty arm name - expect_error(randomize_simple(c("llama", ""), c(2, 3))) + expect_error(randomize_simple(c("llama", ""))) + # Doubled arm name + expect_error(randomize_simple(c("llama", "llama"))) }) test_that("proportions are kept (allocation 1:1)", { - function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB"), c(1,1))) - x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 0.5, conf.level = 0.95, - correct = FALSE) - y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 0.5, conf.level = 0.95, + randomizations <- + sapply(1:1000, function(x) randomize_simple(c("armA", "armB"))) + x <- prop.test(x = sum(randomizations == "armA"), + n = length(randomizations), + p = 0.5, + conf.level = 0.95, correct = FALSE) # precision 0.01 expect_gt(x$p.value, 0.01) - if (TRUE) { - expect_gt(y$p.value, 0.01) - } - }) -test_that("proportions are kept (allocation 2:1)", { - function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB"), c(2,1))) - x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 2/3, conf.level = 0.95, - correct = FALSE) - y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/3, conf.level = 0.95, - correct = FALSE) - # precision 0.01 - expect_gt(x$p.value, 0.01) - if (TRUE) { - expect_gt(y$p.value, 0.01) - } -}) - -test_that("proportions are kept (allocation 1:1:1)", { - function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB", "armC"), c(1,1,1))) - x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 1/3, conf.level = 0.95, - correct = FALSE) - y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/3, conf.level = 0.95, - correct = FALSE) - z <- prop.test(x = sum(function_result == "armC"), n = length(function_result), p = 1/3, conf.level = 0.95, - correct = FALSE) - # precision 0.01 - expect_gt(x$p.value, 0.01) - if (TRUE) { - expect_gt(y$p.value, 0.01) - } - if (TRUE) { - expect_gt(z$p.value, 0.01) - } -}) - -test_that("proportions are kept (allocation 1:2:1)", { - function_result <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB", "armC"), c(1,2,1))) - x <- prop.test(x = sum(function_result == "armA"), n = length(function_result), p = 1/4, conf.level = 0.95, - correct = FALSE) - y <- prop.test(x = sum(function_result == "armB"), n = length(function_result), p = 1/2, conf.level = 0.95, - correct = FALSE) - z <- prop.test(x = sum(function_result == "armC"), n = length(function_result), p = 1/4, conf.level = 0.95, +test_that("proportions are kept (allocation 2:1), even if ratio is in reverse", { + function_result <- sapply(1:1000, function(x) { + randomize_simple(c("armA", "armB"), c("armB" = 1L,"armA" = 2L)) + } + ) + x <- prop.test(x = sum(function_result == "armA"), + n = length(function_result), + p = 2/3, + conf.level = 0.95, correct = FALSE) # precision 0.01 expect_gt(x$p.value, 0.01) - if (TRUE) { - expect_gt(y$p.value, 0.01) - } - if (TRUE) { - expect_gt(z$p.value, 0.01) - } }) From ff8e81928d02e8d1dd00ea0993cea5856ed6612c Mon Sep 17 00:00:00 2001 From: Ola Date: Wed, 3 Jan 2024 13:51:41 +0000 Subject: [PATCH 072/240] add text about adaptive randomization algorithm --- R/randomize-dynamic.R | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index f3bb073..74eb64b 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -2,12 +2,12 @@ #' #' \loadmathjax #' The `randomize_dynamic` function implements the dynamic randomization -#' algorithm using the minimization method proposed by Pocock (Pocock and Simon -#' 1975). It requires defining basic study parameters: the number of arms (k), -#' covariate values, patient allocation ratios (\(a_{k}\)), weights for the -#' covariates (\(w_{i}\)), and the maximum probability of assigning a patient to -#' the group with the smallest total unbalance multiplied by the respective -#' weights (\(G_{k}\)). As the total unbalance for the first patient is the same +#' algorithm using the minimization method proposed by Pocock (Pocock and Simon, +#' 1975). It requires defining basic study parameters: the number of arms (K), +#' number of covariates (C), patient allocation ratios (\(a_{k}\)) (where k = 1,2,…., K), +#' weights for the covariates (\(w_{i}\)) (where i = 1,2,…., C), and the maximum probability (p) +#' of assigning a patient to the group with the smallest total unbalance multiplied by +#' the respective weights (\(G_{k}\)). As the total unbalance for the first patient is the same #' regardless of the assigned arm, this patient is randomly allocated to a given #' arm. Subsequent patients are randomized based on the calculation of the #' unbalance depending on the selected method: "range", "var" (variance), or @@ -16,25 +16,22 @@ #' #' Initially, the algorithm creates a matrix of results comparing a newly #' randomized patient with the current balance of patients based on the defined -#' covariates (C). In the next step, for each arm and specified covariate, +#' covariates. In the next step, for each arm and specified covariate, #' various scenarios of patient allocation are calculated. The existing results #' (n) are updated with the new patient, and then, considering the ratio -#' coefficients, the results are divided by the specific allocation ratio -#' (\(a_{k}\)). Depending on the method, the total unbalance is then calculated, -#' taking into account the allocation (\(a_{k}\)) and the number of covariates, -#' where i = 1,2,…,C. -#' -#' - `range`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack RANGE(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), -#' - `var`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack VAR(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), -#' - `sd`: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack SD(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\) -#' -#' Based on the number of defined arms (K), the minimum value of \(G_{k}\) +#' coefficients, the results are divided by the specific allocation ratio. +#' Depending on the method, the total unbalance is then calculated, +#' taking into account the weights, and the number of covariates using one +#' of three methods (“sd”, “range”, “var”). +#' Based on the number of defined arms, the minimum value of (\(G_{k}\)) #' (defined as the weighted sum of the level-based imbalance) selects the arm to -#' which the patient will be assigned with a predefined probability. The -#' probability that a patient will be assigned to any other arm will then be -#' \(\frac{(1 - p)}{(K - 1)}\) for each of the remaining arms. -#' +#' which the patient will be assigned with a predefined probability (p). The +#' probability that a patient will be assigned to any other arm will then be equal (1-p)/(K-1) +#' for each of the remaining arms. + #' @references Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. +#' @references Minirand Package: Man Jin [aut, cre], Adam Polis [aut], Jonathan Hartzel [aut]. (https://CRAN.R-project.org/package=Minirand) +#' @note This function's implementation is a refactored adaptation of the codebase from the 'Minirand' package. #' #' @inheritParams randomize_simple #' From c8e392e0d817e3002f02d00fcc7f1af5a7486016 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 4 Jan 2024 14:44:31 +0000 Subject: [PATCH 073/240] bigfixed randomized_dynamic added tibble and tidyr to requirements, --- DESCRIPTION | 3 +- R/helpers.R | 26 -------- R/randomize-dynamic.R | 84 +++++++++++++++++-------- tests/testthat/test-randomize-dynamic.R | 22 ++++++- 4 files changed, 78 insertions(+), 57 deletions(-) delete mode 100644 R/helpers.R diff --git a/DESCRIPTION b/DESCRIPTION index e213a3c..9c014e0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -11,7 +11,8 @@ Imports: dbplyr, plumber, mathjaxr, - tibble + tibble, + tidyr Suggests: callr, httr2, diff --git a/R/helpers.R b/R/helpers.R deleted file mode 100644 index 535a971..0000000 --- a/R/helpers.R +++ /dev/null @@ -1,26 +0,0 @@ -#' Compare rows of two dataframes -#' -#' Takes dataframe B (presumably with one row / patient) and compares it to all -#' rows of A (presumably already randomized patietns) -#' -#' @param A data.frame with all patients -#' @param B data.frame with new patient -#' -#' @return data.frame with columns as in A and B, filled with TRUE if there is -#' match in covariate and FALSE if not -#' -#' @examples -compare_rows <- function(A, B) { - # Find common column names - common_cols <- intersect(names(A), names(B)) - - # Compare each common column of A with B - comparisons <- lapply(common_cols, function(col) { - A[[col]] == B[[col]] - }) - - # Combine the comparisons into a new dataframe - C <- data.frame(comparisons) - names(C) <- common_cols - tibble::as_tibble(C) -} diff --git a/R/randomize-dynamic.R b/R/randomize-dynamic.R index f3bb073..75699b0 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-dynamic.R @@ -1,3 +1,32 @@ +#' Compare rows of two dataframes +#' +#' Takes dataframe B (presumably with one row / patient) and compares it to all +#' rows of A (presumably already randomized patietns) +#' +#' @param A data.frame with all patients +#' @param B data.frame with new patient +#' +#' @return data.frame with columns as in A and B, filled with TRUE if there is +#' match in covariate and FALSE if not +#' +#' @examples +compare_rows <- function(A, B) { + # Find common column names + common_cols <- intersect(names(A), names(B)) + + # Compare each common column of A with B + comparisons <- lapply(common_cols, function(col) { + A[[col]] == B[[col]] + }) + + # Combine the comparisons into a new dataframe + C <- data.frame(comparisons) + names(C) <- common_cols + tibble::as_tibble(C) +} + + + #' Randomize Dynamic Algorithm for Patient Allocation #' #' \loadmathjax @@ -38,7 +67,7 @@ #' #' @inheritParams randomize_simple #' -#' @param current_state `data.frame()`\cr +#' @param current_state `tibble()`\cr #' table of covariates and current arm assignments in column `arm`, #' last row contains the new patient with empty string for `arm` #' @param weights `numeric()`\cr @@ -91,7 +120,8 @@ randomize_dynamic <- assert_character( arms, min.len = 2, - min.chars = 1) + min.chars = 1, + unique = TRUE) assert_choice( method, choices = c("range", "var", "sd") @@ -167,7 +197,6 @@ randomize_dynamic <- ) # Computations - n_at_the_moment <- nrow(current_state) - 1 covariate_names <- names(current_state)[names(current_state) != "arm"] @@ -182,34 +211,33 @@ randomize_dynamic <- current_state[nrow(current_state), names(current_state) != "arm"] ) |> split(current_state$arm[1:n_at_the_moment]) |> # split by arm - lapply(colSums) |> # and compute sumber of similarities in each arm - dplyr::bind_rows(.id = "arm") - - # arms_similarity <- sapply(arms, function(x) { - # # for each arm - # apply( - # # for each covariate within arm (row of covariate_similarity) - # as.matrix( - # covariate_similarity[, current_state$arm[1:n_at_the_moment] == x] - # ), 1, sum # compute sum of similarities - # ) - # }) |> - # as.matrix() + lapply(colSums) |> # and compute number of similarities in each arm + dplyr::bind_rows(.id = "arm") |> + # make sure that every arm has a metric, even if not present in data yet + tidyr::complete(arm = arms) |> + dplyr::mutate(dplyr::across(dplyr::where(is.numeric), + ~ tidyr::replace_na(.x, 0))) + + # Define a custom range function + range <- function(x) { + max(x, na.rm = TRUE) - min(x, na.rm = TRUE) + } imbalance <- sapply(arms, function(x) { - browser() arms_similarity |> # compute scenario where each arm (x) gets new subject dplyr::mutate(dplyr::across(dplyr::where(is.numeric), - ~ dplyr::if_else(arm == x, .x + 1, .x))) - # tu skończyłem, teraz przeważyć i dalej - num_lvl <- arms_similarity %*% diag(1 / ratio[arms]) - covariate_imbalance <- apply(num_lvl, 1, get(method)) # range, sd, var - if (method == "range") { - covariate_imbalance <- covariate_imbalance[2, ] - - covariate_imbalance[1, ] - } - sum(weights[covariate_names] %*% covariate_imbalance) + ~ dplyr::if_else(arm == x, .x + 1, .x) * + ratio[arm])) |> + # compute dispersion across each covariate + dplyr::summarise(dplyr::across(dplyr::where(is.numeric), + ~ get(method)(.x))) |> + # multiply each covariate dispersion by covariate weight + dplyr::mutate(dplyr::across(dplyr::everything(), + ~ . * weights[dplyr::cur_column()])) |> + # sum all covariate outcomes + dplyr::summarize(total = sum(dplyr::c_across(dplyr::everything()))) |> + dplyr::pull(total) }) high_prob_arms <- names(which(imbalance == min(imbalance))) @@ -222,7 +250,9 @@ randomize_dynamic <- sample( c(high_prob_arms, low_prob_arms), 1, prob = c( - rep(p / length(high_prob_arms), length(high_prob_arms)), + rep( + p / length(high_prob_arms), + length(high_prob_arms)), rep( (1 - p) / length(low_prob_arms), length(low_prob_arms) diff --git a/tests/testthat/test-randomize-dynamic.R b/tests/testthat/test-randomize-dynamic.R index 3c5bed8..9497a95 100644 --- a/tests/testthat/test-randomize-dynamic.R +++ b/tests/testthat/test-randomize-dynamic.R @@ -74,19 +74,35 @@ test_that("Function randomizes first patient randomly", { p = 1/3, conf.level = 0.95, correct = FALSE) - expect_gt(test$p.value, 0.01) + expect_gt(test$p.value, 0.05) }) test_that("Function randomizes second patient deterministically", { arms <- c("A", "B") situation <- tibble::tibble(sex = c("F", "F"), arm = c("A", "")) + randomized <- + randomize_dynamic(arms = arms, + current_state = situation, + p = 1) + + expect_equal(randomized, "B") +}) + +test_that("Setting proportion of randomness works", { + arms <- c("A", "B") + situation <- tibble::tibble(sex = c("F", "F"), + arm = c("A", "")) + randomized <- sapply(1:100, function(x) { randomize_dynamic(arms = arms, current_state = situation, - p = 1) + p = 0.60) }) + # 60% to minimization arm (B) 40% to other arm (in this case A) + + test <- prop.test(table(randomized), p = 0.4, correct = FALSE) - expect_gt(test$p.value, 0.01) + expect_gt(test$p.value, 0.05) }) From 7435968f37f18f23cb62e50f00f4100f9fc811aa Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Mon, 8 Jan 2024 17:38:44 +0000 Subject: [PATCH 074/240] Changes after code review Changed dynamic randomization to randomize_minimisation_pocock, fixed description file (authors, title, description, imports), fixed implicit checkmate calls --- DESCRIPTION | 37 ++++++++-- NAMESPACE | 2 +- ...amic.R => randomize-minimisation-pocock.R} | 40 ++++++----- R/randomize-simple.R | 8 +-- man/compare_rows.Rd | 21 ++++++ ...ic.Rd => randomize_minimisation_pocock.Rd} | 67 ++++++++++--------- man/randomize_simple.Rd | 6 +- man/unbiased-package.Rd | 19 +++++- ...R => test-randomize-minimisation-pocock.R} | 34 +++++----- 9 files changed, 150 insertions(+), 84 deletions(-) rename R/{randomize-dynamic.R => randomize-minimisation-pocock.R} (89%) create mode 100644 man/compare_rows.Rd rename man/{randomize_dynamic.Rd => randomize_minimisation_pocock.Rd} (58%) rename tests/testthat/{test-randomize-dynamic.R => test-randomize-minimisation-pocock.R} (70%) diff --git a/DESCRIPTION b/DESCRIPTION index 9c014e0..c2b6c56 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,10 +1,27 @@ Package: unbiased -Title: What the Package Does (One Line, Title Case) +Title: Diverse Randomization Algorithms for Clinical Trials Version: 0.0.0.9003 -Authors@R: - person("First", "Last", , "first.last@example.com", role = c("aut", "cre"), - comment = c(ORCID = "YOUR-ORCID-ID")) -Description: What the package does (one paragraph). +Authors@R: c( + person("Kamil", "Sijko", , "kamil.sijko@ttsi.com.pl", + role = c("aut", "cre"), comment = c(ORCID = "0000-0002-2203-1065")), + person("Kinga", "Sałata", , "kinga.salata@ttsi.com.pl", + role = c("aut")), + person("Aleksandra", "Duda", , "aleksandra.duda@ttsi.com.pl", + role = c("aut")), + person("Łukasz", "Wałejko", , "lukasz.walejko@ttsi.com.pl", + role = c("aut")), + person("Michał", "Seweryn", , "michal.seweryn@biol.uni.lodz.pl", + role = c("ctr"), comment = c(ORCID = "0000-0002-9090-3435")), + person("Transition Technologies Science Sp. z o.o.", role = c("fnd", "cph")) + ) +Description: The Unbiased package offers a comprehensive suite of randomization + algorithms for clinical trials, encompassing dynamic strategies like the + minimization method, simple randomization approaches, and block randomization + techniques. Its primary purpose is to provide a harmonized set of functions that + will seamlessly integrate with a production-ready plumber API, also contained + within the package. This integration is designed to facilitate a smooth and + efficient interface with electronic Case Report Form (eCRF) systems, enhancing + the capability of clinical trials to manage patient allocation. License: MIT + file LICENSE Imports: checkmate, @@ -12,14 +29,20 @@ Imports: plumber, mathjaxr, tibble, - tidyr + tidyr, + dplyr, + rlang Suggests: callr, httr2, RPostgres, testthat (>= 3.0.0), usethis, - withr + withr, + DBI, + glue, + jsonlite, + purrr RdMacros: mathjaxr Config/testthat/edition: 3 Encoding: UTF-8 diff --git a/NAMESPACE b/NAMESPACE index 3967f3b..ea263ad 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,7 +2,7 @@ export(define_study) export(list_studies) -export(randomize_dynamic) +export(randomize_minimisation_pocock) export(randomize_simple) export(read_study_details) export(run_unbiased) diff --git a/R/randomize-dynamic.R b/R/randomize-minimisation-pocock.R similarity index 89% rename from R/randomize-dynamic.R rename to R/randomize-minimisation-pocock.R index b5d23a2..2f7aa07 100644 --- a/R/randomize-dynamic.R +++ b/R/randomize-minimisation-pocock.R @@ -8,8 +8,6 @@ #' #' @return data.frame with columns as in A and B, filled with TRUE if there is #' match in covariate and FALSE if not -#' -#' @examples compare_rows <- function(A, B) { # Find common column names common_cols <- intersect(names(A), names(B)) @@ -27,7 +25,7 @@ compare_rows <- function(A, B) { -#' Randomize Dynamic Algorithm for Patient Allocation +#' Patient Randomization Using Minimization Method #' #' \loadmathjax #' The `randomize_dynamic` function implements the dynamic randomization @@ -59,7 +57,7 @@ compare_rows <- function(A, B) { #' for each of the remaining arms. #' @references Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. -#' @references Minirand Package: Man Jin [aut, cre], Adam Polis [aut], Jonathan Hartzel [aut]. (https://CRAN.R-project.org/package=Minirand) +#' @references Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. (https://CRAN.R-project.org/package=Minirand) #' @note This function's implementation is a refactored adaptation of the codebase from the 'Minirand' package. #' #' @inheritParams randomize_simple @@ -100,13 +98,19 @@ compare_rows <- function(A, B) { #' prob = c(0.4, 0.4, 0.2) #' ) |> #' c("") -#' covar_df <- tibble(sex, diabetes, arm) +#' covar_df <- tibble::tibble(sex, diabetes, arm) #' covar_df #' -#' randomize_dynamic(arms = arms, current_state = covar_df) +#' randomize_minimisation_pocock(arms = arms, current_state = covar_df) +#' randomize_minimisation_pocock(arms = arms, current_state = covar_df, +#' ratio = c("control" = 1, +#' "active low" = 2, +#' "active high" = 2), +#' weights = c("sex" = 0.5, +#' "diabetes" = 1)) #' #' @export -randomize_dynamic <- +randomize_minimisation_pocock <- function(arms, current_state, weights, @@ -114,27 +118,27 @@ randomize_dynamic <- method = "var", p = 0.85) { # Assertions - assert_character( + checkmate::assert_character( arms, min.len = 2, min.chars = 1, unique = TRUE) - assert_choice( + checkmate::assert_choice( method, choices = c("range", "var", "sd") ) - assert_tibble( + checkmate::assert_tibble( current_state, any.missing = FALSE, min.cols = 2, min.rows = 1, null.ok = FALSE ) - assert_names( + checkmate::assert_names( colnames(current_state), must.include = "arm" ) - assert_character( + checkmate::assert_character( current_state$arm[nrow(current_state)], max.chars = 0, .var.name = "Last value of 'arm'") @@ -143,7 +147,7 @@ randomize_dynamic <- n_arms <- length(arms) - assert_subset( + checkmate::assert_subset( unique(current_state$arm), choices = c(arms, ""), .var.name = "'arm' variable in dataframe" @@ -158,7 +162,7 @@ randomize_dynamic <- names(weights) <- colnames(current_state)[colnames(current_state) != "arm"] } - assert_numeric( + checkmate::assert_numeric( weights, any.missing = FALSE, len = n_covariates, @@ -167,12 +171,12 @@ randomize_dynamic <- finite = TRUE, all.missing = FALSE ) - assert_names( + checkmate::assert_names( names(weights), must.include = colnames(current_state)[colnames(current_state) != "arm"] ) - assert_integer( + checkmate::assert_integerish( ratio, any.missing = FALSE, len = n_arms, @@ -181,11 +185,11 @@ randomize_dynamic <- all.missing = FALSE, names = "named" ) - assert_names( + checkmate::assert_names( names(ratio), must.include = arms ) - assert_number( + checkmate::assert_number( p, na.ok = FALSE, lower = 0, diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 9624260..441565b 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -13,7 +13,7 @@ #' @return Selected arm assignment. #' #' @examples -#' randomize_simple(c("active", "placebo"), c(2, 1)) +#' randomize_simple(c("active", "placebo"), c("active" = 2, "placebo" = 1)) #' #' @export randomize_simple <- function(arms, ratio) { @@ -24,20 +24,20 @@ randomize_simple <- function(arms, ratio) { } # Argument assertions - assert_character( + checkmate::assert_character( arms, any.missing = FALSE, unique = TRUE, min.chars = 1) - assert_integer( + checkmate::assert_integerish( ratio, any.missing = FALSE, lower = 0, len = length(arms), names = "named" ) - assert_names( + checkmate::assert_names( names(ratio), must.include = arms ) diff --git a/man/compare_rows.Rd b/man/compare_rows.Rd new file mode 100644 index 0000000..ed3a414 --- /dev/null +++ b/man/compare_rows.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/randomize-minimisation-pocock.R +\name{compare_rows} +\alias{compare_rows} +\title{Compare rows of two dataframes} +\usage{ +compare_rows(A, B) +} +\arguments{ +\item{A}{data.frame with all patients} + +\item{B}{data.frame with new patient} +} +\value{ +data.frame with columns as in A and B, filled with TRUE if there is +match in covariate and FALSE if not +} +\description{ +Takes dataframe B (presumably with one row / patient) and compares it to all +rows of A (presumably already randomized patietns) +} diff --git a/man/randomize_dynamic.Rd b/man/randomize_minimisation_pocock.Rd similarity index 58% rename from man/randomize_dynamic.Rd rename to man/randomize_minimisation_pocock.Rd index 2c93093..f2e2341 100644 --- a/man/randomize_dynamic.Rd +++ b/man/randomize_minimisation_pocock.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/randomize-dynamic.R -\name{randomize_dynamic} -\alias{randomize_dynamic} -\title{Randomize Dynamic Algorithm for Patient Allocation} +% Please edit documentation in R/randomize-minimisation-pocock.R +\name{randomize_minimisation_pocock} +\alias{randomize_minimisation_pocock} +\title{Patient Randomization Using Minimization Method} \usage{ -randomize_dynamic( +randomize_minimisation_pocock( arms, current_state, weights, @@ -17,7 +17,7 @@ randomize_dynamic( \item{arms}{\code{character()}\cr Arm names.} -\item{current_state}{\code{data.frame()}\cr +\item{current_state}{\code{tibble()}\cr table of covariates and current arm assignments in column \code{arm}, last row contains the new patient with empty string for \code{arm}} @@ -26,8 +26,8 @@ vector of positive weights, equal in length to number of covariates, numbered after covariates, defaults to equal weights} \item{ratio}{\code{integer()}\cr -Vector of positive integers, equal in length to number of arms, -named after arms, defaults to equal weight} +Vector of positive integers (0 is allowed), equal in length to number +of arms, named after arms, defaults to equal weight} \item{method}{\code{character()}\cr Function used to compute within-arm variability, must be one of: @@ -44,12 +44,12 @@ name of the arm assigned to the patient \description{ \loadmathjax The \code{randomize_dynamic} function implements the dynamic randomization -algorithm using the minimization method proposed by Pocock (Pocock and Simon -1975). It requires defining basic study parameters: the number of arms (k), -covariate values, patient allocation ratios (\(a_{k}\)), weights for the -covariates (\(w_{i}\)), and the maximum probability of assigning a patient to -the group with the smallest total unbalance multiplied by the respective -weights (\(G_{k}\)). As the total unbalance for the first patient is the same +algorithm using the minimization method proposed by Pocock (Pocock and Simon, +1975). It requires defining basic study parameters: the number of arms (K), +number of covariates (C), patient allocation ratios (\(a_{k}\)) (where k = 1,2,…., K), +weights for the covariates (\(w_{i}\)) (where i = 1,2,…., C), and the maximum probability (p) +of assigning a patient to the group with the smallest total unbalance multiplied by +the respective weights (\(G_{k}\)). As the total unbalance for the first patient is the same regardless of the assigned arm, this patient is randomly allocated to a given arm. Subsequent patients are randomized based on the calculation of the unbalance depending on the selected method: "range", "var" (variance), or @@ -59,24 +59,21 @@ equivalent to the "sd" method. \details{ Initially, the algorithm creates a matrix of results comparing a newly randomized patient with the current balance of patients based on the defined -covariates (C). In the next step, for each arm and specified covariate, +covariates. In the next step, for each arm and specified covariate, various scenarios of patient allocation are calculated. The existing results (n) are updated with the new patient, and then, considering the ratio -coefficients, the results are divided by the specific allocation ratio -(\(a_{k}\)). Depending on the method, the total unbalance is then calculated, -taking into account the allocation (\(a_{k}\)) and the number of covariates, -where i = 1,2,…,C. -\itemize{ -\item \code{range}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack RANGE(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), -\item \code{var}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack VAR(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\), -\item \code{sd}: \(G_{k} = \ \sum_{i = 1}^{c}w_{i}\lbrack SD(\frac{n_{ir_{i}1}}{a_{1}},\frac{n_{ir_{i}1}}{a_{2}},\ldots,\ \frac{n_{ir_{i}k}}{a_{k}})|\) -} - -Based on the number of defined arms (K), the minimum value of \(G_{k}\) +coefficients, the results are divided by the specific allocation ratio. +Depending on the method, the total unbalance is then calculated, +taking into account the weights, and the number of covariates using one +of three methods (“sd”, “range”, “var”). +Based on the number of defined arms, the minimum value of (\(G_{k}\)) (defined as the weighted sum of the level-based imbalance) selects the arm to -which the patient will be assigned with a predefined probability. The -probability that a patient will be assigned to any other arm will then be -\(\frac{(1 - p)}{(K - 1)}\) for each of the remaining arms. +which the patient will be assigned with a predefined probability (p). The +probability that a patient will be assigned to any other arm will then be equal (1-p)/(K-1) +for each of the remaining arms. +} +\note{ +This function's implementation is a refactored adaptation of the codebase from the 'Minirand' package. } \examples{ n_at_the_moment <- 10 @@ -99,12 +96,20 @@ arm <- prob = c(0.4, 0.4, 0.2) ) |> c("") -covar_df <- data.frame(sex, diabetes, arm) +covar_df <- tibble::tibble(sex, diabetes, arm) covar_df -randomize_dynamic(arms = arms, current_state = covar_df) +randomize_minimisation_pocock(arms = arms, current_state = covar_df) +randomize_minimisation_pocock(arms = arms, current_state = covar_df, + ratio = c("control" = 1, + "active low" = 2, + "active high" = 2), + weights = c("sex" = 0.5, + "diabetes" = 1)) } \references{ Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. + +Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. (https://CRAN.R-project.org/package=Minirand) } diff --git a/man/randomize_simple.Rd b/man/randomize_simple.Rd index 2da166f..95599eb 100644 --- a/man/randomize_simple.Rd +++ b/man/randomize_simple.Rd @@ -11,8 +11,8 @@ randomize_simple(arms, ratio) Arm names.} \item{ratio}{\code{integer()}\cr -Vector of positive integers, equal in length to number of arms, -named after arms, defaults to equal weight} +Vector of positive integers (0 is allowed), equal in length to number +of arms, named after arms, defaults to equal weight} } \value{ Selected arm assignment. @@ -22,6 +22,6 @@ Randomly assigns a patient to one of the arms according to specified ratios, regardless of already performed assignments. } \examples{ -randomize_simple(c("active", "placebo"), c(2, 1)) +randomize_simple(c("active", "placebo"), c("active" = 2, "placebo" = 1)) } diff --git a/man/unbiased-package.Rd b/man/unbiased-package.Rd index 14c1651..1ef7b04 100644 --- a/man/unbiased-package.Rd +++ b/man/unbiased-package.Rd @@ -4,9 +4,9 @@ \name{unbiased-package} \alias{unbiased} \alias{unbiased-package} -\title{unbiased: What the Package Does (One Line, Title Case)} +\title{unbiased: Diverse Randomization Algorithms for Clinical Trials} \description{ -What the package does (one paragraph). +The Unbiased package offers a comprehensive suite of randomization algorithms for clinical trials, encompassing dynamic strategies like the minimization method, simple randomization approaches, and block randomization techniques. Its primary purpose is to provide a harmonized set of functions that will seamlessly integrate with a production-ready plumber API, also contained within the package. This integration is designed to facilitate a smooth and efficient interface with electronic Case Report Form (eCRF) systems, enhancing the capability of clinical trials to manage patient allocation. } \seealso{ Useful links: @@ -16,7 +16,20 @@ Useful links: } \author{ -\strong{Maintainer}: First Last \email{first.last@example.com} (\href{https://orcid.org/YOUR-ORCID-ID}{ORCID}) +\strong{Maintainer}: Kamil Sijko \email{kamil.sijko@ttsi.com.pl} (\href{https://orcid.org/0000-0002-2203-1065}{ORCID}) + +Authors: +\itemize{ + \item Kinga Sałata \email{kinga.salata@ttsi.com.pl} + \item Aleksandra Duda \email{aleksandra.duda@ttsi.com.pl} + \item Łukasz Wałejko \email{lukasz.walejko@ttsi.com.pl} +} + +Other contributors: +\itemize{ + \item Michał Seweryn \email{michal.seweryn@biol.uni.lodz.pl} (\href{https://orcid.org/0000-0002-9090-3435}{ORCID}) [contractor] + \item Transition Technologies Science Sp. z o.o. [funder, copyright holder] +} } \keyword{internal} diff --git a/tests/testthat/test-randomize-dynamic.R b/tests/testthat/test-randomize-minimisation-pocock.R similarity index 70% rename from tests/testthat/test-randomize-dynamic.R rename to tests/testthat/test-randomize-minimisation-pocock.R index 9497a95..c461fc6 100644 --- a/tests/testthat/test-randomize-dynamic.R +++ b/tests/testthat/test-randomize-minimisation-pocock.R @@ -23,42 +23,42 @@ covar_df <- tibble::tibble(sex, diabetes, arm) test_that("You can call function and it returns arm", { expect_subset( - randomize_dynamic(arms = arms, current_state = covar_df), choices = arms + randomize_minimisation_pocock(arms = arms, current_state = covar_df), choices = arms ) }) test_that("Assertions work", { - expect_error(randomize_dynamic(arms = c(1, 2), current_state = covar_df), + expect_error(randomize_minimisation_pocock(arms = c(1, 2), current_state = covar_df), regexp = "Must be of type 'character'") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, method = "nonexistent"), regexp = "Must be element of set .'range','var','sd'., but is 'nonexistent'") - expect_error(randomize_dynamic(arms = arms, current_state = "5 patietns OK"), + expect_error(randomize_minimisation_pocock(arms = arms, current_state = "5 patietns OK"), regexp = "Assertion on 'current_state' failed: Must be a tibble, not character") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df[, 1:2]), + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df[, 1:2]), regexp = "Names must include the elements .'arm'.") # Last subject already randomized - expect_error(randomize_dynamic(arms = arms, current_state = covar_df[1:3,]), + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df[1:3,]), regexp = "must have at most 0 characters") - expect_error(randomize_dynamic(arms = c("foo", "bar"), + expect_error(randomize_minimisation_pocock(arms = c("foo", "bar"), current_state = covar_df), regexp = "Must be a subset of .'foo','bar',''.") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, weights = c("sex" = -1, "diabetes" = 2)), regexp = "Element 1 is not >= 0") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, weights = c("wrong" = 1, "diabetes" = 2)), regexp = "is missing elements .'sex'.") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, - ratio = c("control" = 1, + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, + ratio = c("control" = 1.5, "active low" = 2, "active high" = 1)), - regexp = "Must be of type 'integer', not 'double'") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + regexp = "element 1 is not close to an integer") + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, ratio = c("control" = 1L, "active high" = 1L)), regexp = "Must have length 3, but has length 2") - expect_error(randomize_dynamic(arms = arms, current_state = covar_df, + expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, p = 12), regexp = "Assertion on 'p' failed: Element 1 is not <= 1") }) @@ -66,7 +66,7 @@ test_that("Assertions work", { test_that("Function randomizes first patient randomly", { randomized <- sapply(1:100, function(x) { - randomize_dynamic(arms = arms, + randomize_minimisation_pocock(arms = arms, current_state = covar_df[nrow(covar_df), ]) }) test <- prop.test(x = sum(randomized == "control"), @@ -82,7 +82,7 @@ test_that("Function randomizes second patient deterministically", { situation <- tibble::tibble(sex = c("F", "F"), arm = c("A", "")) randomized <- - randomize_dynamic(arms = arms, + randomize_minimisation_pocock(arms = arms, current_state = situation, p = 1) @@ -96,7 +96,7 @@ test_that("Setting proportion of randomness works", { randomized <- sapply(1:100, function(x) { - randomize_dynamic(arms = arms, + randomize_minimisation_pocock(arms = arms, current_state = situation, p = 0.60) }) From db94264dca9828bfd1ce03af758eba42801b8bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 8 Jan 2024 14:30:12 +0000 Subject: [PATCH 075/240] add initial devcontainer config --- .devcontainer/Dockerfile | 9 ++++++++ .devcontainer/devcontainer.json | 36 +++++++++++++++++++++++++++++++ .devcontainer/docker-compose.yml | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..be3753c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/rocker-org/devcontainer/tidyverse:4 + +RUN apt update && apt-get install -y --no-install-recommends \ + # httpuv + libz-dev \ + # sodium + libsodium-dev \ + # RPostgres + libpq-dev libssl-dev diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5572058 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "R unbiased", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/rocker-org/devcontainer-features/renv-cache:0": {}, + "ghcr.io/rocker-org/devcontainer-features/r-apt:0": { + "installDevTools": true, + "installREnv": true, + "installRMarkdown": true, + "installRadian": true, + "installVscDebugger": true, + "useTesting": true, + "installBspm": true, + "vscodeRSupport": "full" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "RDebugger.r-debugger", + "GitHub.copilot", + "GitHub.copilot-chat", + "ms-azuretools.vscode-docker", + "REditorSupport.r" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "r.rterm.linux": "/usr/local/bin/radian", + "r.bracketedPaste": true, + "r.plot.useHttpgd": true + } + } + } +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..4c3dc3c --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + build: + context: .. + dockerfile: Dockerfile.postgres + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: \ No newline at end of file From 5f48fe1c4e9f60494c0fab38ac389787b91afd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 8 Jan 2024 17:56:33 +0000 Subject: [PATCH 076/240] more devcontainer + rstudio --- .devcontainer/Dockerfile | 4 ++- .devcontainer/devcontainer.json | 54 +++++++++++++++++--------------- .devcontainer/docker-compose.yml | 6 ++++ 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index be3753c..fc9e9d2 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/rocker-org/devcontainer/tidyverse:4 +FROM ghcr.io/rocker-org/devcontainer/r-ver:4.2 RUN apt update && apt-get install -y --no-install-recommends \ # httpuv @@ -7,3 +7,5 @@ RUN apt update && apt-get install -y --no-install-recommends \ libsodium-dev \ # RPostgres libpq-dev libssl-dev + +ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5572058..d25396a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,36 +1,38 @@ { "name": "R unbiased", "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { "ghcr.io/rocker-org/devcontainer-features/renv-cache:0": {}, - "ghcr.io/rocker-org/devcontainer-features/r-apt:0": { - "installDevTools": true, - "installREnv": true, - "installRMarkdown": true, - "installRadian": true, - "installVscDebugger": true, - "useTesting": true, - "installBspm": true, - "vscodeRSupport": "full" + "ghcr.io/rocker-org/devcontainer-features/rstudio-server:0": { + "singleUser": true, + "version": "stable" + } + }, + "postCreateCommand": "R -q -e 'renv::restore()'", + // "postAttachCommand": { + // "rstudio-start": "rserver" + // }, + "forwardPorts": [ + 8787 + ], + "portsAttributes": { + "8787": { + "label": "RStudio IDE" } }, "customizations": { - "vscode": { - "extensions": [ - "RDebugger.r-debugger", - "GitHub.copilot", - "GitHub.copilot-chat", - "ms-azuretools.vscode-docker", - "REditorSupport.r" - ], - "settings": { + "vscode": { + "extensions": [ + "RDebugger.r-debugger" + ], + "settings": { "terminal.integrated.shell.linux": "/bin/bash", - "r.rterm.linux": "/usr/local/bin/radian", - "r.bracketedPaste": true, - "r.plot.useHttpgd": true - } - } - } + "r.rterm.linux": "/usr/local/bin/radian", + "r.bracketedPaste": true, + "r.plot.useHttpgd": true + } + } + } } \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4c3dc3c..a8006ac 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -18,6 +18,12 @@ services: # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # (Adding the "ports" property to this file will not forward from a Codespace.) + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: db + db: build: context: .. From c30fa5ffc8e5b185550b053a7c9553a83720ea27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:17 +0000 Subject: [PATCH 077/240] add default port for database --- R/run_db.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/run_db.R b/R/run_db.R index 6e61ba2..c8f2b2d 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -37,7 +37,7 @@ connect_to_db <- purrr::insistently(function() { RPostgres::Postgres(), dbname = Sys.getenv("POSTGRES_DB"), host = Sys.getenv("POSTGRES_HOST"), - port = Sys.getenv("POSTGRES_PORT"), + port = Sys.getenv("POSTGRES_PORT", 5432), user = Sys.getenv("POSTGRES_USER"), password = Sys.getenv("POSTGRES_PASSWORD") ) From a16f948a05bd940a0fc800468d417fa194bc0ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:29 +0000 Subject: [PATCH 078/240] add autoreload --- autoreload.sh | 16 ++++++++++++++++ entrypoint.sh | 7 +++++++ 2 files changed, 23 insertions(+) create mode 100755 autoreload.sh create mode 100755 entrypoint.sh diff --git a/autoreload.sh b/autoreload.sh new file mode 100755 index 0000000..93abafb --- /dev/null +++ b/autoreload.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +COMMAND=$1 + +echo "Running $COMMAND" + +watchmedo auto-restart \ + --patterns="*.R;*.txt" \ + --ignore-patterns="renv" \ + --recursive \ + --directory="./R" \ + --directory="./inst" \ + --directory="./tests" \ + "$@" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..ba7a13a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +echo "Running unbiased" + +R -e "unbiased::run_unbiased()" \ No newline at end of file From 6a1c6a523953373aa03ab2ff19e8ebfd64fda907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:38 +0000 Subject: [PATCH 079/240] add populate_db script --- populate_db.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 populate_db.sh diff --git a/populate_db.sh b/populate_db.sh new file mode 100755 index 0000000..ad9d1e4 --- /dev/null +++ b/populate_db.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +export PGPASSWORD="$POSTGRES_PASSWORD" + +# List all sql files in inst/postgres directory and execute them in alphabetical order +for f in inst/postgres/*.sql; do + echo "Executing $f" + psql -v ON_ERROR_STOP=1 \ + --host "$POSTGRES_HOST" \ + --port "${POSTGRES_PORT:-5432}" \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -f "$f" +done \ No newline at end of file From 0a3ad3a9f03ccb0e4652118fd6b930f3b963c494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:48:19 +0000 Subject: [PATCH 080/240] Add autoreload improvements --- autoreload.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/autoreload.sh b/autoreload.sh index 93abafb..c2b841a 100755 --- a/autoreload.sh +++ b/autoreload.sh @@ -13,4 +13,7 @@ watchmedo auto-restart \ --directory="./R" \ --directory="./inst" \ --directory="./tests" \ + --verbose \ + --debounce-interval 1 \ + --no-restart-on-command-exit \ "$@" \ No newline at end of file From 2cd23aa7dfc893a47c6586e3d964bcb7922b5e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:43:42 +0000 Subject: [PATCH 081/240] add psql in dockerfiles --- .devcontainer/Dockerfile | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fc9e9d2..fc21b07 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,6 +6,6 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev + libpq-dev libssl-dev postgresql-client ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/Dockerfile b/Dockerfile index d5cdcaf..095b19b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev + libpq-dev libssl-dev postgresql-client ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE From 2183c7f060e31c993e68c48a9babc803c54249da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:44:25 +0000 Subject: [PATCH 082/240] exclude renv in vscode watcher --- .vscode/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..668377d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.watcherExclude": { + "**/renv/**": true + } +} \ No newline at end of file From edd256caf65a88f617c580cb0bec3047c75eaea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:45:17 +0000 Subject: [PATCH 083/240] update renv --- renv.lock | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/renv.lock b/renv.lock index 95574c5..f0c6a34 100644 --- a/renv.lock +++ b/renv.lock @@ -11,14 +11,14 @@ "Packages": { "DBI": { "Package": "DBI", - "Version": "1.1.3", + "Version": "1.2.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "b2866e62bab9378c3cc9476a1954226b" + "Hash": "3e0051431dff9acfe66c23765e55c556" }, "R6": { "Package": "R6", @@ -504,7 +504,7 @@ }, "httr2": { "Package": "httr2", - "Version": "1.0.0", + "Version": "0.2.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -513,15 +513,13 @@ "cli", "curl", "glue", - "lifecycle", "magrittr", "openssl", "rappdirs", "rlang", - "vctrs", "withr" ], - "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" + "Hash": "193bb297368afbbb42dc85784a46b36e" }, "jquerylib": { "Package": "jquerylib", @@ -535,13 +533,13 @@ }, "jsonlite": { "Package": "jsonlite", - "Version": "1.8.7", + "Version": "1.8.8", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "methods" ], - "Hash": "266a20443ca13c65688b2116d5220f76" + "Hash": "e1b9c55281c5adc4dd113652d9e26768" }, "knitr": { "Package": "knitr", @@ -886,14 +884,14 @@ }, "rlang": { "Package": "rlang", - "Version": "1.1.1", + "Version": "1.1.2", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "a85c767b55f0bf9b7ad16c6d7baee5bb" + "Hash": "50a6dbdc522936ca35afc5e2082ea91b" }, "rmarkdown": { "Package": "rmarkdown", @@ -1007,7 +1005,7 @@ }, "testthat": { "Package": "testthat", - "Version": "3.2.1", + "Version": "3.2.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1018,6 +1016,7 @@ "cli", "desc", "digest", + "ellipsis", "evaluate", "jsonlite", "lifecycle", @@ -1032,7 +1031,7 @@ "waldo", "withr" ], - "Hash": "4767a686ebe986e6cb01d075b3f09729" + "Hash": "877508719fcb8c9525eccdadf07a5102" }, "textshaping": { "Package": "textshaping", From 0d7144d0e834429d3f3993e8b9e2e7e4bf6a8ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:48:55 +0000 Subject: [PATCH 084/240] make autoreload less verbose --- autoreload.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/autoreload.sh b/autoreload.sh index c2b841a..a7ac3de 100755 --- a/autoreload.sh +++ b/autoreload.sh @@ -13,7 +13,6 @@ watchmedo auto-restart \ --directory="./R" \ --directory="./inst" \ --directory="./tests" \ - --verbose \ --debounce-interval 1 \ --no-restart-on-command-exit \ "$@" \ No newline at end of file From ee1d442f9f19f9850aafd3bc0cb15b73b7867fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:59:07 +0000 Subject: [PATCH 085/240] use db pool --- R/run_db.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/run_db.R b/R/run_db.R index c8f2b2d..cfaa447 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -33,7 +33,7 @@ run_unbiased_db <- function() { } connect_to_db <- purrr::insistently(function() { - DBI::dbConnect( + pool::dbPool( RPostgres::Postgres(), dbname = Sys.getenv("POSTGRES_DB"), host = Sys.getenv("POSTGRES_HOST"), From d46d515e71bfcce0a115b4f23f81325da362086b Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 9 Jan 2024 10:04:10 +0000 Subject: [PATCH 086/240] Updated initialize.sql (removed table method and changed 'method_id' to 'method' on study table). Added example of parameters from study table. --- inst/postgres/01-initialize.sql | 14 +------------- inst/postgres/90-examples.sql | 2 +- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index da9337a..b005835 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,15 +1,3 @@ --- Table: method --- Purpose: Holds the available randomization methods used in clinical studies. --- Each method is uniquely identified by an auto-incrementing ID. --- The 'name' column stores the name of the randomization method. --- The 'sys_period' column, of type TSTZRANGE, is used for temporal versioning, --- tracking the period during which each record is considered valid and current. -CREATE TABLE method ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - sys_period TSTZRANGE NOT NULL -); - -- Table: study -- Purpose: Stores information about various studies conducted. -- 'id' is an auto-incrementing primary key uniquely identifying each study. @@ -22,7 +10,7 @@ CREATE TABLE study ( id SERIAL PRIMARY KEY, identifier VARCHAR(12) NOT NULL, name VARCHAR(255) NOT NULL, - method_id INT NOT NULL, + method VARCHAR(255) NOT NULL, parameters JSONB, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), sys_period TSTZRANGE NOT NULL, diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql index 8d1498b..efe23b1 100644 --- a/inst/postgres/90-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,5 +1,5 @@ INSERT INTO study (identifier, name, method_id, parameters) -VALUES ('TEST', 'Badanie testowe', 1, '{}'); +VALUES ('TEST', 'Badanie testowe', 1, '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), From cab31f1dad090547866b7245ef9541ef14a59aa9 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 9 Jan 2024 10:29:35 +0000 Subject: [PATCH 087/240] Replaced 'method_id' by 'method' --- inst/postgres/01-initialize.sql | 8 ++------ inst/postgres/90-examples.sql | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index b005835..9cc8b65 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -3,9 +3,8 @@ -- 'id' is an auto-incrementing primary key uniquely identifying each study. -- 'identifier' is a unique, short textual identifier for the study (max 12 characters). -- 'name' provides the full name or title of the study. --- 'method_id' is a foreign key linking to the 'method' table, indicating the randomization method used in the study. +-- 'method' is the randomization method used in the study. -- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'study_method' constraint ensures referential integrity, linking each study to a valid randomization method. CREATE TABLE study ( id SERIAL PRIMARY KEY, identifier VARCHAR(12) NOT NULL, @@ -13,10 +12,7 @@ CREATE TABLE study ( method VARCHAR(255) NOT NULL, parameters JSONB, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - sys_period TSTZRANGE NOT NULL, - CONSTRAINT study_method - FOREIGN KEY (method_id) - REFERENCES method (id) + sys_period TSTZRANGE NOT NULL ); -- Table: arm diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql index efe23b1..7a64eca 100644 --- a/inst/postgres/90-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,5 +1,5 @@ -INSERT INTO study (identifier, name, method_id, parameters) -VALUES ('TEST', 'Badanie testowe', 1, '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); +INSERT INTO study (identifier, name, method, parameters) +VALUES ('TEST', 'Badanie testowe', 'minimise_pocock', '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), From 931ad94cbf6d923c5b581cdc78539094ff5f0dfd Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 9 Jan 2024 10:41:41 +0000 Subject: [PATCH 088/240] Removed "method" table --- inst/postgres/01-initialize.sql | 21 +++------------------ inst/postgres/03-versioning.sql | 7 ------- inst/postgres/10-values.sql | 2 -- inst/postgres/90-examples.sql | 4 ++-- 4 files changed, 5 insertions(+), 29 deletions(-) delete mode 100644 inst/postgres/10-values.sql diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index da9337a..bdb273e 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -1,34 +1,19 @@ --- Table: method --- Purpose: Holds the available randomization methods used in clinical studies. --- Each method is uniquely identified by an auto-incrementing ID. --- The 'name' column stores the name of the randomization method. --- The 'sys_period' column, of type TSTZRANGE, is used for temporal versioning, --- tracking the period during which each record is considered valid and current. -CREATE TABLE method ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - sys_period TSTZRANGE NOT NULL -); - -- Table: study -- Purpose: Stores information about various studies conducted. -- 'id' is an auto-incrementing primary key uniquely identifying each study. -- 'identifier' is a unique, short textual identifier for the study (max 12 characters). -- 'name' provides the full name or title of the study. --- 'method_id' is a foreign key linking to the 'method' table, indicating the randomization method used in the study. +-- 'method' is a randomization method name -- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. -- The 'study_method' constraint ensures referential integrity, linking each study to a valid randomization method. CREATE TABLE study ( id SERIAL PRIMARY KEY, identifier VARCHAR(12) NOT NULL, name VARCHAR(255) NOT NULL, - method_id INT NOT NULL, + method VARCHAR(255) NOT NULL, parameters JSONB, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - sys_period TSTZRANGE NOT NULL, - CONSTRAINT study_method - FOREIGN KEY (method_id) - REFERENCES method (id) + sys_period TSTZRANGE NOT NULL ); -- Table: arm diff --git a/inst/postgres/03-versioning.sql b/inst/postgres/03-versioning.sql index a10d6de..9572597 100644 --- a/inst/postgres/03-versioning.sql +++ b/inst/postgres/03-versioning.sql @@ -1,10 +1,3 @@ -CREATE TABLE method_history (LIKE method); - -CREATE TRIGGER method_versioning -BEFORE INSERT OR UPDATE OR DELETE ON method -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'method_history', true); - CREATE TABLE study_history (LIKE study); CREATE TRIGGER study_versioning diff --git a/inst/postgres/10-values.sql b/inst/postgres/10-values.sql deleted file mode 100644 index 65b25e5..0000000 --- a/inst/postgres/10-values.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT INTO method (name) -VALUES ('simple'), ('blocked'); diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql index 8d1498b..2a6879f 100644 --- a/inst/postgres/90-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,5 +1,5 @@ -INSERT INTO study (identifier, name, method_id, parameters) -VALUES ('TEST', 'Badanie testowe', 1, '{}'); +INSERT INTO study (identifier, name, method, parameters) +VALUES ('TEST', 'Badanie testowe', 'minimisation_pocock', '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), From 2ae4fa03ffd581f0a02c22e774d65abad258a5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 10:43:13 +0000 Subject: [PATCH 089/240] update renv.lock --- renv.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/renv.lock b/renv.lock index f0c6a34..68a93cd 100644 --- a/renv.lock +++ b/renv.lock @@ -769,6 +769,22 @@ ], "Hash": "8b65a7a00ef8edc5ddc6fabf0aff1194" }, + "pool": { + "Package": "pool", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "R6", + "later", + "methods", + "rlang", + "withr" + ], + "Hash": "52d086ff1a2ccccbae6d462cb0773835" + }, "praise": { "Package": "praise", "Version": "1.0.0", From 0551484d2b83bf7c86a944288d34043d9bb61437 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 9 Jan 2024 10:29:35 +0000 Subject: [PATCH 090/240] Replaced 'method_id' by 'method' --- inst/postgres/01-initialize.sql | 8 ++------ inst/postgres/03-versioning.sql | 7 ------- inst/postgres/10-values.sql | 4 ++-- inst/postgres/90-examples.sql | 4 ++-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/inst/postgres/01-initialize.sql b/inst/postgres/01-initialize.sql index b005835..9cc8b65 100644 --- a/inst/postgres/01-initialize.sql +++ b/inst/postgres/01-initialize.sql @@ -3,9 +3,8 @@ -- 'id' is an auto-incrementing primary key uniquely identifying each study. -- 'identifier' is a unique, short textual identifier for the study (max 12 characters). -- 'name' provides the full name or title of the study. --- 'method_id' is a foreign key linking to the 'method' table, indicating the randomization method used in the study. +-- 'method' is the randomization method used in the study. -- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'study_method' constraint ensures referential integrity, linking each study to a valid randomization method. CREATE TABLE study ( id SERIAL PRIMARY KEY, identifier VARCHAR(12) NOT NULL, @@ -13,10 +12,7 @@ CREATE TABLE study ( method VARCHAR(255) NOT NULL, parameters JSONB, timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - sys_period TSTZRANGE NOT NULL, - CONSTRAINT study_method - FOREIGN KEY (method_id) - REFERENCES method (id) + sys_period TSTZRANGE NOT NULL ); -- Table: arm diff --git a/inst/postgres/03-versioning.sql b/inst/postgres/03-versioning.sql index a10d6de..9572597 100644 --- a/inst/postgres/03-versioning.sql +++ b/inst/postgres/03-versioning.sql @@ -1,10 +1,3 @@ -CREATE TABLE method_history (LIKE method); - -CREATE TRIGGER method_versioning -BEFORE INSERT OR UPDATE OR DELETE ON method -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'method_history', true); - CREATE TABLE study_history (LIKE study); CREATE TRIGGER study_versioning diff --git a/inst/postgres/10-values.sql b/inst/postgres/10-values.sql index 65b25e5..20e66a1 100644 --- a/inst/postgres/10-values.sql +++ b/inst/postgres/10-values.sql @@ -1,2 +1,2 @@ -INSERT INTO method (name) -VALUES ('simple'), ('blocked'); +INSERT INTO study (method) +VALUES ('simple'), ('minimise_pocock'); diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql index efe23b1..7a64eca 100644 --- a/inst/postgres/90-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,5 +1,5 @@ -INSERT INTO study (identifier, name, method_id, parameters) -VALUES ('TEST', 'Badanie testowe', 1, '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); +INSERT INTO study (identifier, name, method, parameters) +VALUES ('TEST', 'Badanie testowe', 'minimise_pocock', '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), From bdd921068d18ee6290a1ac05b706c24811b249f0 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 9 Jan 2024 10:52:06 +0000 Subject: [PATCH 091/240] Fixed tests --- tests/testthat/test-DB-0.R | 2 +- tests/testthat/test-DB-study.R | 18 ++---------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index a63d90d..3bdc64e 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -4,7 +4,7 @@ skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") # Setup constants ---- versioned_tables <- c( - "method", "study", "arm", "stratum", "factor_constraint", + "study", "arm", "stratum", "factor_constraint", "numeric_constraint", "patient", "patient_stratum" ) nonversioned_tables <- c("settings") diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 403c694..3cd4b37 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -24,7 +24,7 @@ test_that("it is enough to provide a name, an identifier, and a method id", { tibble( identifier = "FINE", name = "Correctly working study", - method_id = 1 + method = "minimisation_pocock" ), copy = TRUE, in_place = TRUE ) @@ -35,20 +35,6 @@ new_study_id <- tbl(conn, "study") |> filter(identifier == "FINE") |> pull(id) -test_that("can't insert a study that references a non-existing method", { - expect_error({ - tbl(conn, "study") |> - rows_append( - tibble( - identifier = "error", - name = "Exception-throwing study", - method_id = 28 - ), - copy = TRUE, in_place = TRUE - ) - }, regexp = "violates foreign key constraint") -}) - test_that("deleting archivizes a study", { expect_no_error({ tbl(conn, "study") |> @@ -67,7 +53,7 @@ test_that("deleting archivizes a study", { id = new_study_id, identifier = "FINE", name = "Correctly working study", - method_id = 1L + method = "minimisation_pocock" ) ) }) From 08b24835a6637e24916345553944e8e9ca026945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 8 Jan 2024 14:30:12 +0000 Subject: [PATCH 092/240] add initial devcontainer config --- .devcontainer/Dockerfile | 9 ++++++++ .devcontainer/devcontainer.json | 36 +++++++++++++++++++++++++++++++ .devcontainer/docker-compose.yml | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..be3753c --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/rocker-org/devcontainer/tidyverse:4 + +RUN apt update && apt-get install -y --no-install-recommends \ + # httpuv + libz-dev \ + # sodium + libsodium-dev \ + # RPostgres + libpq-dev libssl-dev diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5572058 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "R unbiased", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/rocker-org/devcontainer-features/renv-cache:0": {}, + "ghcr.io/rocker-org/devcontainer-features/r-apt:0": { + "installDevTools": true, + "installREnv": true, + "installRMarkdown": true, + "installRadian": true, + "installVscDebugger": true, + "useTesting": true, + "installBspm": true, + "vscodeRSupport": "full" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "RDebugger.r-debugger", + "GitHub.copilot", + "GitHub.copilot-chat", + "ms-azuretools.vscode-docker", + "REditorSupport.r" + ], + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "r.rterm.linux": "/usr/local/bin/radian", + "r.bracketedPaste": true, + "r.plot.useHttpgd": true + } + } + } +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..4c3dc3c --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + build: + context: .. + dockerfile: Dockerfile.postgres + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: \ No newline at end of file From 18a602b7eebcd9c542ec4320b3429dc9a7dee640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 8 Jan 2024 17:56:33 +0000 Subject: [PATCH 093/240] more devcontainer + rstudio --- .devcontainer/Dockerfile | 4 ++- .devcontainer/devcontainer.json | 54 +++++++++++++++++--------------- .devcontainer/docker-compose.yml | 6 ++++ 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index be3753c..fc9e9d2 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/rocker-org/devcontainer/tidyverse:4 +FROM ghcr.io/rocker-org/devcontainer/r-ver:4.2 RUN apt update && apt-get install -y --no-install-recommends \ # httpuv @@ -7,3 +7,5 @@ RUN apt update && apt-get install -y --no-install-recommends \ libsodium-dev \ # RPostgres libpq-dev libssl-dev + +ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5572058..d25396a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,36 +1,38 @@ { "name": "R unbiased", "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "features": { "ghcr.io/rocker-org/devcontainer-features/renv-cache:0": {}, - "ghcr.io/rocker-org/devcontainer-features/r-apt:0": { - "installDevTools": true, - "installREnv": true, - "installRMarkdown": true, - "installRadian": true, - "installVscDebugger": true, - "useTesting": true, - "installBspm": true, - "vscodeRSupport": "full" + "ghcr.io/rocker-org/devcontainer-features/rstudio-server:0": { + "singleUser": true, + "version": "stable" + } + }, + "postCreateCommand": "R -q -e 'renv::restore()'", + // "postAttachCommand": { + // "rstudio-start": "rserver" + // }, + "forwardPorts": [ + 8787 + ], + "portsAttributes": { + "8787": { + "label": "RStudio IDE" } }, "customizations": { - "vscode": { - "extensions": [ - "RDebugger.r-debugger", - "GitHub.copilot", - "GitHub.copilot-chat", - "ms-azuretools.vscode-docker", - "REditorSupport.r" - ], - "settings": { + "vscode": { + "extensions": [ + "RDebugger.r-debugger" + ], + "settings": { "terminal.integrated.shell.linux": "/bin/bash", - "r.rterm.linux": "/usr/local/bin/radian", - "r.bracketedPaste": true, - "r.plot.useHttpgd": true - } - } - } + "r.rterm.linux": "/usr/local/bin/radian", + "r.bracketedPaste": true, + "r.plot.useHttpgd": true + } + } + } } \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 4c3dc3c..a8006ac 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -18,6 +18,12 @@ services: # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. # (Adding the "ports" property to this file will not forward from a Codespace.) + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: db + db: build: context: .. From c18a96b7024fc0d0a465fe6f3eee8512762948cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:17 +0000 Subject: [PATCH 094/240] add default port for database --- R/run_db.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/run_db.R b/R/run_db.R index 6e61ba2..c8f2b2d 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -37,7 +37,7 @@ connect_to_db <- purrr::insistently(function() { RPostgres::Postgres(), dbname = Sys.getenv("POSTGRES_DB"), host = Sys.getenv("POSTGRES_HOST"), - port = Sys.getenv("POSTGRES_PORT"), + port = Sys.getenv("POSTGRES_PORT", 5432), user = Sys.getenv("POSTGRES_USER"), password = Sys.getenv("POSTGRES_PASSWORD") ) From 2e5366a40e2794fc6bffee033b6726e491fdd310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:29 +0000 Subject: [PATCH 095/240] add autoreload --- autoreload.sh | 16 ++++++++++++++++ entrypoint.sh | 7 +++++++ 2 files changed, 23 insertions(+) create mode 100755 autoreload.sh create mode 100755 entrypoint.sh diff --git a/autoreload.sh b/autoreload.sh new file mode 100755 index 0000000..93abafb --- /dev/null +++ b/autoreload.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +COMMAND=$1 + +echo "Running $COMMAND" + +watchmedo auto-restart \ + --patterns="*.R;*.txt" \ + --ignore-patterns="renv" \ + --recursive \ + --directory="./R" \ + --directory="./inst" \ + --directory="./tests" \ + "$@" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..ba7a13a --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +echo "Running unbiased" + +R -e "unbiased::run_unbiased()" \ No newline at end of file From a29686b33399018a12e4bf5f7c3a448d2e47af69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:36:38 +0000 Subject: [PATCH 096/240] add populate_db script --- populate_db.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 populate_db.sh diff --git a/populate_db.sh b/populate_db.sh new file mode 100755 index 0000000..ad9d1e4 --- /dev/null +++ b/populate_db.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +export PGPASSWORD="$POSTGRES_PASSWORD" + +# List all sql files in inst/postgres directory and execute them in alphabetical order +for f in inst/postgres/*.sql; do + echo "Executing $f" + psql -v ON_ERROR_STOP=1 \ + --host "$POSTGRES_HOST" \ + --port "${POSTGRES_PORT:-5432}" \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -f "$f" +done \ No newline at end of file From c47bb082c15c6d0be9da6b7e39ef465b4b99c029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 07:48:19 +0000 Subject: [PATCH 097/240] Add autoreload improvements --- autoreload.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/autoreload.sh b/autoreload.sh index 93abafb..c2b841a 100755 --- a/autoreload.sh +++ b/autoreload.sh @@ -13,4 +13,7 @@ watchmedo auto-restart \ --directory="./R" \ --directory="./inst" \ --directory="./tests" \ + --verbose \ + --debounce-interval 1 \ + --no-restart-on-command-exit \ "$@" \ No newline at end of file From 349d21d8d2c431c665aefea749dcdccf600d9c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:43:42 +0000 Subject: [PATCH 098/240] add psql in dockerfiles --- .devcontainer/Dockerfile | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fc9e9d2..fc21b07 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,6 +6,6 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev + libpq-dev libssl-dev postgresql-client ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/Dockerfile b/Dockerfile index d5cdcaf..095b19b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev + libpq-dev libssl-dev postgresql-client ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE From 611e4e1b14cb68c72e69629ef96c0fc35bc3a82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:44:25 +0000 Subject: [PATCH 099/240] exclude renv in vscode watcher --- .vscode/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..668377d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.watcherExclude": { + "**/renv/**": true + } +} \ No newline at end of file From ef44b01b1c873a517e877cfc0141bf2e11712c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:45:17 +0000 Subject: [PATCH 100/240] update renv --- renv.lock | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/renv.lock b/renv.lock index 95574c5..f0c6a34 100644 --- a/renv.lock +++ b/renv.lock @@ -11,14 +11,14 @@ "Packages": { "DBI": { "Package": "DBI", - "Version": "1.1.3", + "Version": "1.2.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "b2866e62bab9378c3cc9476a1954226b" + "Hash": "3e0051431dff9acfe66c23765e55c556" }, "R6": { "Package": "R6", @@ -504,7 +504,7 @@ }, "httr2": { "Package": "httr2", - "Version": "1.0.0", + "Version": "0.2.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -513,15 +513,13 @@ "cli", "curl", "glue", - "lifecycle", "magrittr", "openssl", "rappdirs", "rlang", - "vctrs", "withr" ], - "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" + "Hash": "193bb297368afbbb42dc85784a46b36e" }, "jquerylib": { "Package": "jquerylib", @@ -535,13 +533,13 @@ }, "jsonlite": { "Package": "jsonlite", - "Version": "1.8.7", + "Version": "1.8.8", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "methods" ], - "Hash": "266a20443ca13c65688b2116d5220f76" + "Hash": "e1b9c55281c5adc4dd113652d9e26768" }, "knitr": { "Package": "knitr", @@ -886,14 +884,14 @@ }, "rlang": { "Package": "rlang", - "Version": "1.1.1", + "Version": "1.1.2", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "a85c767b55f0bf9b7ad16c6d7baee5bb" + "Hash": "50a6dbdc522936ca35afc5e2082ea91b" }, "rmarkdown": { "Package": "rmarkdown", @@ -1007,7 +1005,7 @@ }, "testthat": { "Package": "testthat", - "Version": "3.2.1", + "Version": "3.2.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1018,6 +1016,7 @@ "cli", "desc", "digest", + "ellipsis", "evaluate", "jsonlite", "lifecycle", @@ -1032,7 +1031,7 @@ "waldo", "withr" ], - "Hash": "4767a686ebe986e6cb01d075b3f09729" + "Hash": "877508719fcb8c9525eccdadf07a5102" }, "textshaping": { "Package": "textshaping", From 5228e5109efb0e62331168eec7c88da39aecdb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:48:55 +0000 Subject: [PATCH 101/240] make autoreload less verbose --- autoreload.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/autoreload.sh b/autoreload.sh index c2b841a..a7ac3de 100755 --- a/autoreload.sh +++ b/autoreload.sh @@ -13,7 +13,6 @@ watchmedo auto-restart \ --directory="./R" \ --directory="./inst" \ --directory="./tests" \ - --verbose \ --debounce-interval 1 \ --no-restart-on-command-exit \ "$@" \ No newline at end of file From a39f9f9a189f63f69e888f7346bcb5607be08c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 09:59:07 +0000 Subject: [PATCH 102/240] use db pool --- R/run_db.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/run_db.R b/R/run_db.R index c8f2b2d..cfaa447 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -33,7 +33,7 @@ run_unbiased_db <- function() { } connect_to_db <- purrr::insistently(function() { - DBI::dbConnect( + pool::dbPool( RPostgres::Postgres(), dbname = Sys.getenv("POSTGRES_DB"), host = Sys.getenv("POSTGRES_HOST"), From d45fbf6ff689f85b717c1868df2c7f4fd74630f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 10:43:13 +0000 Subject: [PATCH 103/240] update renv.lock --- renv.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/renv.lock b/renv.lock index f0c6a34..68a93cd 100644 --- a/renv.lock +++ b/renv.lock @@ -769,6 +769,22 @@ ], "Hash": "8b65a7a00ef8edc5ddc6fabf0aff1194" }, + "pool": { + "Package": "pool", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "R6", + "later", + "methods", + "rlang", + "withr" + ], + "Hash": "52d086ff1a2ccccbae6d462cb0773835" + }, "praise": { "Package": "praise", "Version": "1.0.0", From 214d50d54385c4004a5882fdf0b79e75dd611990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 9 Jan 2024 12:53:39 +0000 Subject: [PATCH 104/240] fix connection_pool issues --- R/run_api.R | 6 +++--- R/run_db.R | 4 ++-- R/study-define.R | 14 ++++++++------ R/study-details.R | 12 ++++++------ R/study-list.R | 4 ++-- tests/testthat/setup-DB.R | 11 +++++++++-- 6 files changed, 30 insertions(+), 21 deletions(-) diff --git a/R/run_api.R b/R/run_api.R index 879ece4..7606606 100644 --- a/R/run_api.R +++ b/R/run_api.R @@ -12,11 +12,11 @@ #' #' @export run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { - assignInMyNamespace("CONN", connect_to_db()) + assignInMyNamespace("db_connection_pool", create_db_connection_pool()) on.exit({ - DBI::dbDisconnect(CONN) - assignInMyNamespace("CONN", NULL) + pool::poolClose(db_connection_pool) + assignInMyNamespace("db_connection_pool", NULL) }) plumber::plumb_api('unbiased', 'unbiased_api') |> diff --git a/R/run_db.R b/R/run_db.R index cfaa447..4f0e125 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -1,4 +1,4 @@ -CONN <- NULL +db_connection_pool <- NULL #' Run local DB #' @@ -32,7 +32,7 @@ run_unbiased_db <- function() { )) } -connect_to_db <- purrr::insistently(function() { +create_db_connection_pool <- purrr::insistently(function() { pool::dbPool( RPostgres::Postgres(), dbname = Sys.getenv("POSTGRES_DB"), diff --git a/R/study-define.R b/R/study-define.R index 76840aa..39ded03 100644 --- a/R/study-define.R +++ b/R/study-define.R @@ -59,13 +59,15 @@ define_study <- function(name, identifier, arms, assert_list(parameters, names = "unique", null.ok = TRUE) - method_id <- tbl(CONN, "method") |> + conn_from_pool <- pool::localCheckout(db_connection_pool) + + method_id <- tbl(conn_from_pool, "method") |> filter(name == !!method) |> pull(id) assert_int(method_id) # Actual code - study_id <- tbl(CONN, "study") |> + study_id <- tbl(conn_from_pool, "study") |> rows_append( tibble( identifier = identifier, @@ -79,7 +81,7 @@ define_study <- function(name, identifier, arms, pull(id) purrr::walk2(arms, ratio, function(arm, prop) { - tbl(CONN, "arm") |> + tbl(conn_from_pool, "arm") |> rows_append( tibble( study_id = study_id, @@ -93,7 +95,7 @@ define_study <- function(name, identifier, arms, purrr::iwalk(strata, function(stratum, name) { if (is.numeric(stratum)) { # Numeric case - stratum_id <- tbl(CONN, "stratum") |> + stratum_id <- tbl(conn_from_pool, "stratum") |> rows_append( tibble( study_id = study_id, @@ -108,7 +110,7 @@ define_study <- function(name, identifier, arms, # TODO: how to set min/max values? } else { # Factor case - stratum_id <- tbl(CONN, "stratum") |> + stratum_id <- tbl(conn_from_pool, "stratum") |> rows_append( tibble( study_id = study_id, @@ -121,7 +123,7 @@ define_study <- function(name, identifier, arms, pull(id) purrr::walk(stratum, function(value) { - tbl(CONN, "factor_constraint") |> + tbl(conn_from_pool, "factor_constraint") |> rows_append( tibble( stratum_id = stratum_id, diff --git a/R/study-details.R b/R/study-details.R index 6bf16a2..595c59e 100644 --- a/R/study-details.R +++ b/R/study-details.R @@ -11,23 +11,23 @@ #' #' @export read_study_details <- function(study_id) { - arms <- tbl(CONN, "arm") |> + arms <- tbl(db_connection_pool, "arm") |> filter(study_id == !!study_id) |> select(name, ratio) |> collect() - strata <- tbl(CONN, "stratum") |> + strata <- tbl(db_connection_pool, "stratum") |> filter(study_id == !!study_id) |> select(id, name, value_type) |> collect() |> mutate(values = list(read_stratum_values(id, value_type)), .by = id) |> select(-id) - tbl(CONN, "study") |> + tbl(db_connection_pool, "study") |> filter(id == !!study_id) |> select(id, name, identifier, method_id, parameters) |> left_join( - tbl(CONN, "method") |> + tbl(db_connection_pool, "method") |> select(id, method = name), join_by(method_id == id) ) |> @@ -44,12 +44,12 @@ read_stratum_values <- function(stratum_id, value_type) { switch( value_type, "factor" = { - tbl(CONN, "factor_constraint") |> + tbl(db_connection_pool, "factor_constraint") |> filter(stratum_id == !!stratum_id) |> pull(value) }, "numeric" = { - tbl(CONN, "numeric_constraint") |> + tbl(db_connection_pool, "numeric_constraint") |> filter(stratum_id == !!stratum_id) |> select(min_value, max_value) |> collect() diff --git a/R/study-list.R b/R/study-list.R index 8ce057e..b21375f 100644 --- a/R/study-list.R +++ b/R/study-list.R @@ -7,7 +7,7 @@ #' #' @export list_studies <- function() { - tbl(CONN, "study") |> + tbl(db_connection_pool, "study") |> select(id, identifier, name, timestamp) |> arrange(desc(timestamp)) |> collect() @@ -25,7 +25,7 @@ list_studies <- function() { #' #' @export study_exists <- function(study_id) { - row_id <- tbl(CONN, "study") |> + row_id <- tbl(db_connection_pool, "study") |> filter(id == !!study_id) |> pull(id) test_int(row_id) diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R index c1a23fe..50d2e3f 100644 --- a/tests/testthat/setup-DB.R +++ b/tests/testthat/setup-DB.R @@ -1,7 +1,14 @@ if (is_CI()) { # Define connection ---- - conn <- connect_to_db() + db_pool <- create_db_connection_pool() + conn <- pool::poolCheckout(db_pool) # Close DB connection upon exiting - withr::defer({ DBI::dbDisconnect(conn) }, teardown_env()) + withr::defer( + { + pool::poolReturn(conn) + pool::poolClose(db_pool) + }, + teardown_env() + ) } From f29ff31f0fd5305f5693b4ef91cb8549e939539a Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 9 Jan 2024 13:20:25 +0000 Subject: [PATCH 105/240] WIP: create study changed license, improved API spec --- LICENSE | 4 +- LICENSE.md | 2 +- inst/plumber/unbiased_api/meta.R | 1 + .../unbiased_api/minimisation_pocock.R | 40 ++++++++ inst/plumber/unbiased_api/plumber.R | 97 +++++-------------- 5 files changed, 68 insertions(+), 76 deletions(-) create mode 100644 inst/plumber/unbiased_api/minimisation_pocock.R diff --git a/LICENSE b/LICENSE index 473aa63..d9a4b92 100644 --- a/LICENSE +++ b/LICENSE @@ -1,2 +1,2 @@ -YEAR: 2023 -COPYRIGHT HOLDER: unbiased authors +YEAR: 2024 +COPYRIGHT HOLDER: Transition Technologies Science sp. z o.o. diff --git a/LICENSE.md b/LICENSE.md index d01cf81..063c07c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # MIT License -Copyright (c) 2023 unbiased authors +Copyright (c) 2024 Transition Technologies Science sp. z o.o. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 1983922..171e191 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -3,6 +3,7 @@ #* Each release of the API is based on some Github commit. This endpoint allows #* the user to easily check the SHA of the deployed API version. #* +#* @tag other #* @get /sha #* @serializer unboxedJSON function(res) { diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R new file mode 100644 index 0000000..e62dfb9 --- /dev/null +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -0,0 +1,40 @@ +#* Initialize a study with Pocock's minimisation randomization +#* +#* Set up a new study for randomization defining it's parameters +#* +#* @param identifier:str Study code, at most 12 characters. +#* @param name:str Full study name. +#* @param method:str Function used to compute within-arm variability, must be one of: sd, var, range, defaults to var +#* @param arms:object Arm names (character) with their ratios (integer). +#* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). +#* +#* @tag initialize +#* +#* @post /minimisation_pocock +#* @serializer unboxedJSON +function(identifier, name, method, arms, covariates, req, res) { + # assert connection with DB + checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") + # check if study exists + browser() + dplyr::tbl(CONN, "study") |> + dplyr::filter(name == "name" | identifier == "TEST") + # return error if study already exists + + # validate request details + assert_string(name) + assert_string(identifier, max.chars = 12) + + assert_character( + arms, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE + ) + assert_integerish(ratio, lower = 0, any.missing = FALSE, len = length(arms)) + + assert_list(strata, names = "unique", any.missing = FALSE) + purrr::walk(strata, function(stratum) { + # TODO: when allowing numeric strata, change the assertions here + assert_character( + stratum, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE + ) + }) +} diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index e7135aa..f1b467c 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -1,9 +1,32 @@ +#* @apiTitle Unbiased +#* @apiDescription This API provides a diverse range of randomization algorithms specifically designed for use in clinical trials. It supports dynamic strategies such as the minimization method, as well as simpler approaches including standard and block randomization. The main goal of this API is to ensure seamless integration with electronic Case Report Form (eCRF) systems, facilitating efficient patient allocation management in clinical trials. +#* @apiContact list(name = "GitHub", url = "https://ttscience.github.io/unbiased/") +#* @apiLicense list(name = "MIT", url = "https://github.com/ttscience/unbiased/LICENSE.md") +#* @apiVersion 0.0.0.9003 +#* @apiTag initialize Endpoints that initialize study with chosen randomization method and parameters. +#* @apiTag randomize Endpoints that randomize individual patients after the study was created. +#* @apiTag other Other endpoints (helpers etc.). +#* #* @plumber function(api) { meta <- plumber::pr("meta.R") + minimisation_pocock <- plumber::pr("minimisation_pocock.R") api |> - plumber::pr_mount("/meta", meta) + plumber::pr_mount("/meta", meta) |> + plumber::pr_mount("/study", minimisation_pocock) |> + plumber::pr_set_api_spec(function(spec) { + # example of how to define arms + spec$paths$`/study/minimisation_pocock`$post$requestBody$content$`application/json`$schema$properties$arms$example <- + list("placebo" = 1, "active" = 1) + # example of how to define covariates in minimisation pocock + spec$paths$`/study/minimisation_pocock`$post$requestBody$content$`application/json`$schema$properties$covariates$example <- + list(sex = list(weight = 1, + levels = c("female", "male")), + weight = list(weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more"))) + spec + }) } #* Log request data @@ -24,75 +47,3 @@ function(req) { plumber::forward() } - -#* Define study to randomize -#* -#* @param identifier:str Study code, at most 12 characters. -#* @param name:str Full study name. -#* @param method:str Randomization method to apply. -#* @param arms:[str] Arm names to use. -#* @param ratio:[int] Arm ratios, must be the same length as arm names. -#* @param strata:object List of character vectors, each list element being a stratum and each string being a possible stratum value. Could possibly take a numeric structure as well instead of a character vector, e.g. `{"min": 1, "max": 10}`. It just needs handling by checking whether the inner list is named or not, I'd say. -#* @param parameters:object Parameters to pass to randomization. -#* -#* @post /study -function(identifier, name, method, arms, ratio, strata, parameters, req, res) { - # Coerce types (plumber doesn't do that) - ratio <- as.integer(ratio) - - # Assertions - - - # Define study - unbiased:::define_study( - name, identifier, arms, method, - strata = strata, parameters = parameters, ratio = ratio - ) -} - -#* Get available studies -#* -#* @get /study -function(req, res) { - unbiased:::list_studies() -} - -#* Get study details -#* -#* @get /study/ -function(study_id, req, res) { - study_id <- as.integer(study_id) - - if (!unbiased:::study_exists(study_id)) { - res$status <- 404 - return(list(error = glue::glue("Study {study_id} does not exist."))) - } - - unbiased:::read_study_details(study_id) -} - -#* Randomize one patient -#* -#* @param strata:object -#* -#* @post /study//randomize -function(strata, req, res) { - # Check whether study with study_id exists, if not, return error - - # Retrieve study details, especially the ones about randomization - method <- NULL - params <- list( - arms = character(), - ratio = numeric() - ) - - # Assert that patient has the same strata as study - # and that patient's values are allowed in study - - # Dispatch based on randomization method - switch( - method, - simple = do.call(unbiased:::randomize_simple, params), - # block = do.call(unbiased:::randomize_blocked, c(params, strata = strata)) - ) -} From dc380495abe622710482dfccd318990f0ccf0c2b Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Tue, 9 Jan 2024 17:09:02 +0000 Subject: [PATCH 106/240] WIP what is done: docs, inputs, validation what is missing: write to DB and return results --- .../unbiased_api/minimisation_pocock.R | 220 ++++++++++++++++-- 1 file changed, 196 insertions(+), 24 deletions(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index e62dfb9..79e186e 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -4,7 +4,8 @@ #* #* @param identifier:str Study code, at most 12 characters. #* @param name:str Full study name. -#* @param method:str Function used to compute within-arm variability, must be one of: sd, var, range, defaults to var +#* @param method:str Function used to compute within-arm variability, must be one of: sd, var, range +#* @param p:dbl Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) #* @param arms:object Arm names (character) with their ratios (integer). #* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). #* @@ -12,29 +13,200 @@ #* #* @post /minimisation_pocock #* @serializer unboxedJSON -function(identifier, name, method, arms, covariates, req, res) { - # assert connection with DB +#* +function(identifier, name, method, arms, covariates, p, req, res) { + response <- list() + # Assert DB connectivity -------------------------------------------------- checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") - # check if study exists - browser() - dplyr::tbl(CONN, "study") |> - dplyr::filter(name == "name" | identifier == "TEST") - # return error if study already exists - - # validate request details - assert_string(name) - assert_string(identifier, max.chars = 12) - - assert_character( - arms, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE - ) - assert_integerish(ratio, lower = 0, any.missing = FALSE, len = length(arms)) - - assert_list(strata, names = "unique", any.missing = FALSE) - purrr::walk(strata, function(stratum) { - # TODO: when allowing numeric strata, change the assertions here - assert_character( - stratum, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE - ) + + # Check if study exists --------------------------------------------------- + similar <- + dplyr::tbl(CONN, "study") |> + dplyr::select(id, name, identifier) |> + dplyr::filter(name == !!name | identifier == !!identifier) |> + dplyr::collect() + if (nrow(similar) >= 1) { + response <- c(response, list(similar_studies = similar)) + } + # Validate inputs --------------------------------------------------------- + # study name no longer than 255 characters + if (!checkmate::test_character(name, min.chars = 1, max.chars = 255)) { + message <- checkmate::check_character(name, max.chars = 255) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'name': {message}") + ) + ) + return(res) + } + if (!checkmate::test_character(identifier, min.chars = 1, max.chars = 12)) { + message <- checkmate::check_character(identifier, max.chars = 12) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'identifier': {message}") + ) + ) + return(res) + } + if (!checkmate::test_choice(method, choices = c("range", "var", "sd"))) { + message <- + checkmate::check_choice( + method, + choices = c("range", "var", "sd") + ) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'method': {message}") + ) + ) + return(res) + } + if (!checkmate::test_list(arms, + types = "integerish", + any.missing = FALSE, + min.len = 2, + names = "unique" + )) { + message <- + checkmate::check_list(arms, + types = "integerish", + any.missing = FALSE, + min.len = 2, + names = "unique" + ) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'arms': {message}") + ) + ) + return(res) + } + if (!checkmate::test_list(covariates, + types = c("numeric", "list", "character"), + any.missing = FALSE, + min.len = 2, + names = "unique" + )) { + message <- + checkmate::check_list(covariates, + types = c("numeric", "list", "character"), + any.missing = FALSE, + min.len = 1, + names = "unique" + ) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'covariates': {message}") + ) + ) + return(res) + } + # check all covariates + purrr::walk2(covariates, names(covariates), function(c_content, c_name) { + if (length(c_content) != 2) { + res$status <- 400 + res$body <- + c( + response, + list( + error = + glue::glue("Covariate '{c_name}' has {length(c_content)} elements while 2 were expected") + ) + ) + return(res) + } + # check covariate properties names + if (!all(names(c_content) == c("weight", "levels"))) { + res$status <- 400 + res$body <- + c( + response, + list( + error = + glue::glue("Covariate '{c_name}' has elements named different than 'weight' and 'levels'") + ) + ) + return(res) + } + if (!checkmate::test_numeric(c_content$weight, + lower = 0, + finite = TRUE, + len = 1, + null.ok = FALSE)) { + message <- + checkmate::check_numeric(c_content$weight, + lower = 0, + finite = TRUE, + len = 1, + null.ok = FALSE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for covariate '{c_name}', weight: {message}") + ) + ) + return(res) + } + if (!checkmate::test_character(c_content$levels, + min.chars = 1, + min.len = 2, + unique = TRUE)) { + message <- + checkmate::check_character(c_content$levels, + min.chars = 1, + min.len = 2, + unique = TRUE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for covariate '{c_name}', levels: {message}") + ) + ) + return(res) + } }) + # check probability + p <- as.numeric(p) + if (!checkmate::test_numeric(p, lower = 0, upper = 1, len = 1)) { + message <- + checkmate::check_numeric(p, lower = 0, upper = 1, len = 1) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Input validation failed for 'p': {message}") + ) + ) + return(res) + } + + # Write study to DB ------------------------------------------------------- + + # Response ---------------------------------------------------------------- + + return(response) } + + + + From fe4b9fd46fb4564e1f3fda3266d8109e023261c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 10 Jan 2024 09:35:56 +0000 Subject: [PATCH 107/240] add clear_db script --- clear_db.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100755 clear_db.sh diff --git a/clear_db.sh b/clear_db.sh new file mode 100755 index 0000000..6764408 --- /dev/null +++ b/clear_db.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +export PGPASSWORD="$POSTGRES_PASSWORD" + +# Clear the database +psql -v ON_ERROR_STOP=1 \ + --host "$POSTGRES_HOST" \ + --port "${POSTGRES_PORT:-5432}" \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" From f10251ceed99cd5fc7b5da384023b51a980cd839 Mon Sep 17 00:00:00 2001 From: Kinga Date: Wed, 10 Jan 2024 14:45:35 +0000 Subject: [PATCH 108/240] Created endpoint for randomization with Pocock method. --- R/parse_pocock.R | 34 +++++++ inst/plumber/unbiased_api/meta.R | 1 + inst/plumber/unbiased_api/plumber.R | 97 ++----------------- .../unbiased_api/randomization_patient.R | 41 ++++++++ inst/postgres/90-examples.sql | 2 +- 5 files changed, 87 insertions(+), 88 deletions(-) create mode 100644 R/parse_pocock.R create mode 100644 inst/plumber/unbiased_api/randomization_patient.R diff --git a/R/parse_pocock.R b/R/parse_pocock.R new file mode 100644 index 0000000..dffdaf3 --- /dev/null +++ b/R/parse_pocock.R @@ -0,0 +1,34 @@ +#' Parse parameters for Pocock randomization method +#' +#' Function to parse and process parameters for the Pocock randomization method. +#' +#' @return params List of parameters + + +parse_pocock_parameters <- function(CONN, study_id, current_state){ + parameters <- tbl(CONN, "study") |> + filter(study_id == study_id) |> + select(parameters) |> + pull() + + parameters <- jsonlite::fromJSON(parameters) + + # do testowania + # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') + + ratio_arms = tbl(CONN, "arm") |> + filter(study_id == study_id) |> + select(name, ratio) |> + collect() + + params <- list( + arms = ratio_arms$name, + current_state = tibble::as_tibble(current_state), + ratio = setNames(ratio_arms$ratio, ratio_arms$name), + method = parameters$method, + p = parameters$p, + weights = parameters$weights |> unlist() + ) + + return(params) +} diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 1983922..171e191 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -3,6 +3,7 @@ #* Each release of the API is based on some Github commit. This endpoint allows #* the user to easily check the SHA of the deployed API version. #* +#* @tag other #* @get /sha #* @serializer unboxedJSON function(res) { diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index e7135aa..678d3f4 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -1,98 +1,21 @@ #* @plumber function(api) { meta <- plumber::pr("meta.R") + randomization_patient <- plumber::pr("randomization_patient.R") api |> - plumber::pr_mount("/meta", meta) -} - -#* Log request data -#* -#* @filter logger -function(req) { - cat( - "[QUERY]", - req$REQUEST_METHOD, req$PATH_INFO, - "@", req$REMOTE_ADDR, "\n" - ) - purrr::imap(req$args, function(arg, arg_name) { - cat("[ARG]", arg_name, "=", as.character(arg), "\n") - }) - if (req$postBody != "") { - cat("[BODY]", req$postBody, "\n") - } - - plumber::forward() -} - -#* Define study to randomize -#* -#* @param identifier:str Study code, at most 12 characters. -#* @param name:str Full study name. -#* @param method:str Randomization method to apply. -#* @param arms:[str] Arm names to use. -#* @param ratio:[int] Arm ratios, must be the same length as arm names. -#* @param strata:object List of character vectors, each list element being a stratum and each string being a possible stratum value. Could possibly take a numeric structure as well instead of a character vector, e.g. `{"min": 1, "max": 10}`. It just needs handling by checking whether the inner list is named or not, I'd say. -#* @param parameters:object Parameters to pass to randomization. -#* -#* @post /study -function(identifier, name, method, arms, ratio, strata, parameters, req, res) { - # Coerce types (plumber doesn't do that) - ratio <- as.integer(ratio) - - # Assertions - + plumber::pr_mount("/meta", meta) |> + plumber::pr_mount("/study/patient", randomization_patient) |> + plumber::pr_set_api_spec(function(spec) { - # Define study - unbiased:::define_study( - name, identifier, arms, method, - strata = strata, parameters = parameters, ratio = ratio - ) -} + # example of how to define arms + spec$paths$`/study/patient/study/{study_id}/patient`$post$requestBody$content$`application/json`$schema$properties$current_state$example <- + tibble::tibble("gender" = c("female", "male"), + "arm" = c("placebo", "")) -#* Get available studies -#* -#* @get /study -function(req, res) { - unbiased:::list_studies() + spec + }) } -#* Get study details -#* -#* @get /study/ -function(study_id, req, res) { - study_id <- as.integer(study_id) - - if (!unbiased:::study_exists(study_id)) { - res$status <- 404 - return(list(error = glue::glue("Study {study_id} does not exist."))) - } - - unbiased:::read_study_details(study_id) -} -#* Randomize one patient -#* -#* @param strata:object -#* -#* @post /study//randomize -function(strata, req, res) { - # Check whether study with study_id exists, if not, return error - # Retrieve study details, especially the ones about randomization - method <- NULL - params <- list( - arms = character(), - ratio = numeric() - ) - - # Assert that patient has the same strata as study - # and that patient's values are allowed in study - - # Dispatch based on randomization method - switch( - method, - simple = do.call(unbiased:::randomize_simple, params), - # block = do.call(unbiased:::randomize_blocked, c(params, strata = strata)) - ) -} diff --git a/inst/plumber/unbiased_api/randomization_patient.R b/inst/plumber/unbiased_api/randomization_patient.R new file mode 100644 index 0000000..716a7fe --- /dev/null +++ b/inst/plumber/unbiased_api/randomization_patient.R @@ -0,0 +1,41 @@ +#* Randomize one patient +#* +#* +#* @param study_id:int Study identifier +#* @param current_state:object +#* +#* @tag randomize +#* @post /study//patient +function(study_id, current_state, req, res) { + + # Assertion connection with DB + checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") + + # Check whether study with study_id exists + checkmate::expect_subset(x = tbl(CONN, "study") |> + select(id) |> + pull(), + choices = req$args$study_id) + + #DF validation - error handling + + # Retrieve study details, especially the ones about randomization + method_randomization <- tbl(CONN, "study") |> + filter(study_id == study_id) |> + select(method) |> + pull() + + # Dispatch based on randomization method to parse parameters + params <- + switch( + method_randomization, + simple = do.call(unbiased:::parse_simple_randomization, list(CONN, study_id, current_state)), + minimisation_pocock = do.call(unbiased:::parse_pocock_parameters, list(CONN, study_id, current_state)) + ) + + switch( + method_randomization, + simple = do.call(unbiased:::randomize_simple, params), + minimisation_pocock = do.call(unbiased:::randomize_minimisation_pocock, params) + ) +} diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql index 7a64eca..a6e1419 100644 --- a/inst/postgres/90-examples.sql +++ b/inst/postgres/90-examples.sql @@ -1,5 +1,5 @@ INSERT INTO study (identifier, name, method, parameters) -VALUES ('TEST', 'Badanie testowe', 'minimise_pocock', '{"method": "var", "p": 0.85, "weights": [1,1,1]}'); +VALUES ('TEST', 'Badanie testowe', 'minimise_pocock', '{"method": "var", "p": 0.85, "weights": {"gender": 1}}'); INSERT INTO arm (study_id, name, ratio) VALUES (1, 'placebo', 2), From 9df0afcd7f44286a62eebee7ea4d1592087edb67 Mon Sep 17 00:00:00 2001 From: Kinga Date: Thu, 11 Jan 2024 13:25:26 +0000 Subject: [PATCH 109/240] Added validation to parse_pocock. Added tryCatch to randomization_patient.R --- R/parse_pocock.R | 29 ++++++++++++++++++- .../unbiased_api/randomization_patient.R | 24 +++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/R/parse_pocock.R b/R/parse_pocock.R index dffdaf3..e07128e 100644 --- a/R/parse_pocock.R +++ b/R/parse_pocock.R @@ -13,6 +13,19 @@ parse_pocock_parameters <- function(CONN, study_id, current_state){ parameters <- jsonlite::fromJSON(parameters) + if (!checkmate::test_list(parameters, null.ok = FALSE)){ + message <- checkmate::test_list(parameters, null.ok = FALSE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Parse validation failed. 'Parameters' must be a list: {message}") + ) + ) + return(res) + } + # do testowania # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') @@ -23,12 +36,26 @@ parse_pocock_parameters <- function(CONN, study_id, current_state){ params <- list( arms = ratio_arms$name, - current_state = tibble::as_tibble(current_state), + # current_state = tibble::as_tibble(current_state), + current_state = current_state, ratio = setNames(ratio_arms$ratio, ratio_arms$name), method = parameters$method, p = parameters$p, weights = parameters$weights |> unlist() ) + if (!checkmate::test_list(params, null.ok = FALSE)){ + message <- checkmate::test_list(params, null.ok = FALSE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Parse validation failed. Input parameters must be a list: {message}") + ) + ) + return(res) + } + return(params) } diff --git a/inst/plumber/unbiased_api/randomization_patient.R b/inst/plumber/unbiased_api/randomization_patient.R index 716a7fe..8946685 100644 --- a/inst/plumber/unbiased_api/randomization_patient.R +++ b/inst/plumber/unbiased_api/randomization_patient.R @@ -29,13 +29,27 @@ function(study_id, current_state, req, res) { params <- switch( method_randomization, - simple = do.call(unbiased:::parse_simple_randomization, list(CONN, study_id, current_state)), - minimisation_pocock = do.call(unbiased:::parse_pocock_parameters, list(CONN, study_id, current_state)) - ) + # simple = do.call(unbiased:::parse_simple_randomization, list(CONN, study_id, current_state)), + minimisation_pocock = tryCatch({ + do.call(unbiased:::parse_pocock_parameters, list(CONN, study_id, current_state)) + }, error = function(e) { + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + }) + ) switch( method_randomization, - simple = do.call(unbiased:::randomize_simple, params), - minimisation_pocock = do.call(unbiased:::randomize_minimisation_pocock, params) + # simple = do.call(unbiased:::randomize_simple, params), + minimisation_pocock = tryCatch({ + do.call(unbiased:::randomize_minimisation_pocock, params) + }, error = function(e) { + # browser() + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + } + ) ) + } + From 81526f61d8491969848037dcc013e51d5c0eedd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:34:11 +0000 Subject: [PATCH 110/240] deps and devcontainer --- .devcontainer/devcontainer.json | 6 +++++- .devcontainer/docker-compose.yml | 20 ++++++++++++++++++-- renv.lock | 11 ++++++----- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d25396a..2bf7c52 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,11 +15,15 @@ // "rstudio-start": "rserver" // }, "forwardPorts": [ - 8787 + 8787, + 5454 ], "portsAttributes": { "8787": { "label": "RStudio IDE" + }, + "5454": { + "label": "PGAdmin" } }, "customizations": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a8006ac..e6c3cb5 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -24,6 +24,22 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_HOST: db + pgadmin: + image: dpage/pgadmin4 + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_HOST: db + PGADMIN_DEFAULT_EMAIL: pgadmin@example.com + PGADMIN_DEFAULT_PASSWORD: pgadmin + volumes: + - pga-data:/tmp/dev/pga/data + depends_on: + - db + ports: + - "5454:80" + db: build: context: .. @@ -35,9 +51,9 @@ services: POSTGRES_USER: postgres POSTGRES_DB: postgres POSTGRES_PASSWORD: postgres - # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. # (Adding the "ports" property to this file will not forward from a Codespace.) volumes: - postgres-data: \ No newline at end of file + postgres-data: + pga-data: diff --git a/renv.lock b/renv.lock index 68a93cd..b517d78 100644 --- a/renv.lock +++ b/renv.lock @@ -504,7 +504,7 @@ }, "httr2": { "Package": "httr2", - "Version": "0.2.3", + "Version": "1.0.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -513,13 +513,15 @@ "cli", "curl", "glue", + "lifecycle", "magrittr", "openssl", "rappdirs", "rlang", + "vctrs", "withr" ], - "Hash": "193bb297368afbbb42dc85784a46b36e" + "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" }, "jquerylib": { "Package": "jquerylib", @@ -1021,7 +1023,7 @@ }, "testthat": { "Package": "testthat", - "Version": "3.2.0", + "Version": "3.2.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1032,7 +1034,6 @@ "cli", "desc", "digest", - "ellipsis", "evaluate", "jsonlite", "lifecycle", @@ -1047,7 +1048,7 @@ "waldo", "withr" ], - "Hash": "877508719fcb8c9525eccdadf07a5102" + "Hash": "4767a686ebe986e6cb01d075b3f09729" }, "textshaping": { "Package": "textshaping", From 6d67afbeb76947f639373ff074844a11276f13f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:34:30 +0000 Subject: [PATCH 111/240] build in compose --- docker-compose.test.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index b37b5e3..1dc9bb3 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -2,6 +2,9 @@ version: "3.9" services: postgres: image: temporal_postgres + build: + context: . + dockerfile: Dockerfile.postgres container_name: unbiased_postgres environment: - POSTGRES_PASSWORD=postgres @@ -13,6 +16,9 @@ services: target: /docker-entrypoint-initdb.d/ api: image: unbiased + build: + context: . + dockerfile: Dockerfile container_name: unbiased_api depends_on: - postgres @@ -25,7 +31,10 @@ services: networks: - test_net tests: - image: unbiased + # image: unbiased + build: + context: . + dockerfile: Dockerfile container_name: unbiased_tests depends_on: - api From 520c16c64bc6228ffdb8e5fcebb7ab02c23bdc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:35:11 +0000 Subject: [PATCH 112/240] psql running script --- run_psql.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100755 run_psql.sh diff --git a/run_psql.sh b/run_psql.sh new file mode 100755 index 0000000..c730a83 --- /dev/null +++ b/run_psql.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +export PGPASSWORD="$POSTGRES_PASSWORD" + +psql --host "$POSTGRES_HOST" \ + --port "${POSTGRES_PORT:-5432}" \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + "$@" From 3d4be286cd83988ec7b05302dd149dece62d31d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:37:16 +0000 Subject: [PATCH 113/240] db connection updates, validation and data persistence in study creation --- .devcontainer/Dockerfile | 2 + NAMESPACE | 1 - R/run_api.R | 16 +- R/run_db.R | 34 +- R/study-repository.R | 112 ++++++ R/validation-utils.R | 10 + entrypoint.sh | 3 +- .../unbiased_api/minimisation_pocock.R | 323 +++++++++--------- inst/plumber/unbiased_api/plumber.R | 35 +- man/run_unbiased_db.Rd | 17 - tests/testthat/setup-api.R | 7 +- 11 files changed, 325 insertions(+), 235 deletions(-) create mode 100644 R/study-repository.R create mode 100644 R/validation-utils.R delete mode 100644 man/run_unbiased_db.Rd diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fc21b07..02fffe9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,4 +8,6 @@ RUN apt update && apt-get install -y --no-install-recommends \ # RPostgres libpq-dev libssl-dev postgresql-client +RUN pip install watchdog[watchmedo] + ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/NAMESPACE b/NAMESPACE index ea263ad..102851c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,7 +6,6 @@ export(randomize_minimisation_pocock) export(randomize_simple) export(read_study_details) export(run_unbiased) -export(run_unbiased_db) export(study_exists) import(checkmate) import(dplyr) diff --git a/R/run_api.R b/R/run_api.R index 7606606..17aa312 100644 --- a/R/run_api.R +++ b/R/run_api.R @@ -12,13 +12,23 @@ #' #' @export run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { - assignInMyNamespace("db_connection_pool", create_db_connection_pool()) + assign("db_connection_pool", create_db_connection_pool(), envir = globalenv()) + on.exit({ + pool::poolClose(db_connection_pool) + assign("db_connection_pool", NULL, envir = globalenv()) + }) + + plumber::plumb_api("unbiased", "unbiased_api") |> + plumber::pr_run(host = host, port = port, ...) +} +run_unbiased_local <- function(host = "0.0.0.0", port = 3838, ...) { + assign("db_connection_pool", create_db_connection_pool(), envir = globalenv()) on.exit({ pool::poolClose(db_connection_pool) - assignInMyNamespace("db_connection_pool", NULL) + assign("db_connection_pool", NULL, envir = globalenv()) }) - plumber::plumb_api('unbiased', 'unbiased_api') |> + plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> plumber::pr_run(host = host, port = port, ...) } diff --git a/R/run_db.R b/R/run_db.R index 4f0e125..d664911 100644 --- a/R/run_db.R +++ b/R/run_db.R @@ -1,36 +1,4 @@ -db_connection_pool <- NULL - -#' Run local DB -#' -#' @description -#' Starts a Docker container containing a Postgres database for unbiased API -#' storage and sets environment variables to allow API to connect. Do not run -#' if an external Postgres database exists already, set appropriate env vars -#' instead. -#' -#' @return Return code describing success or lack thereof. -#' -#' @export -run_unbiased_db <- function() { - Sys.setenv(GITHUB_SHA = system("git rev-parse HEAD", intern = TRUE)) - Sys.setenv(POSTGRES_DB = "postgres") - Sys.setenv(POSTGRES_HOST = "127.0.0.1") - Sys.setenv(POSTGRES_PORT = 5432) - Sys.setenv(POSTGRES_USER = "postgres") - Sys.setenv(POSTGRES_PASSWORD = "postgres") - - system(glue::glue( - "docker run", - "-e POSTGRES_PASSWORD={Sys.getenv('POSTGRES_PASSWORD')}", - "-p {Sys.getenv('POSTGRES_PORT')}:5432", - # Docker engine v23+ allows relative paths on host, so it'd be simply - # ./inst/postgres, but v23 was pretty new when I was writing this - "-v {fs::path_wd('inst', 'postgres')}:/docker-entrypoint-initdb.d/", - "-d --name unbiased_db_local", - "eddhannay/alpine-postgres-temporal-tables:latest", - .sep = " " - )) -} +# db_connection_pool <- NULL create_db_connection_pool <- purrr::insistently(function() { pool::dbPool( diff --git a/R/study-repository.R b/R/study-repository.R new file mode 100644 index 0000000..a07fed6 --- /dev/null +++ b/R/study-repository.R @@ -0,0 +1,112 @@ +#' Defines methods for interacting with the study in the database + +get_similar_studies <- function(name, identifier) { + similar <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(id, name, identifier) |> + dplyr::filter(name == !!name | identifier == !!identifier) |> + dplyr::collect() + similar +} + +create_study <- function( + name, identifier, method, parameters, arms, strata) { + connection <- pool::poolCheckout(db_connection_pool) + + r <- tryCatch( + { + DBI::dbBegin(connection) + study_record <- list( + name = name, + identifier = identifier, + method = method, + parameters = jsonlite::toJSON(parameters, auto_unbox = TRUE) + |> as.character() + ) + + study <- DBI::dbGetQuery( + connection, + "INSERT INTO study (name, identifier, method, parameters) + VALUES ($1, $2, $3, $4) + RETURNING id, name, identifier, method, parameters", + unname(study_record) + ) + + study <- as.list(study) + study$parameters <- jsonlite::fromJSON(study$parameters) + + arm_records <- arms |> + purrr::imap(\(x, name) list(name=name, ratio=x)) |> + purrr::map(tibble::as_tibble) |> + purrr::list_c() + arm_records$study_id <- study$id + + DBI::dbWriteTable( + connection, + "arm", + arm_records, + append = TRUE, + row.names = FALSE + ) + + created_arms <- DBI::dbGetQuery( + connection, + "SELECT id, study_id, name, ratio + FROM arm + WHERE study_id = $1", + study$id + ) + + study$arms <- created_arms + + stratum_records <- strata |> + purrr::imap(\(x, name) list(name = name, value_type = x$value_type)) |> + purrr::map(tibble::as_tibble) |> + purrr::list_c() + stratum_records$study_id <- study$id + + DBI::dbWriteTable( + connection, + "stratum", + stratum_records, + append = TRUE, + row.names = FALSE + ) + + created_strata <- DBI::dbGetQuery( + connection, + "SELECT id, study_id, name, value_type + FROM stratum + WHERE study_id = $1", + study$id + ) + + factor_constraints <- strata |> + purrr::imap(\(x, name) tibble::as_tibble(x)) |> + purrr::list_c() |> + dplyr::filter(.data$value_type == "factor") |> + dplyr::select(name, levels) |> + dplyr::left_join(created_strata, dplyr::join_by("name")) |> + dplyr::select(id, levels) |> + dplyr::rename(value = levels, stratum_id = id) + + DBI::dbWriteTable( + connection, + "factor_constraint", + factor_constraints, + append = TRUE, + row.names = FALSE + ) + + DBI::dbCommit(connection) + list(study = study) + }, + error = function(cond) { + logger::log_error("Error creating study: {cond}", cond=cond) + DBI::dbRollback(connection) + list(error = conditionMessage(cond)) + } + ) + + r +} diff --git a/R/validation-utils.R b/R/validation-utils.R new file mode 100644 index 0000000..752c38c --- /dev/null +++ b/R/validation-utils.R @@ -0,0 +1,10 @@ +#' Utility functions for validation + +append_error <- function(validation_errors, field, error) { + if (field %in% names(validation_errors)) { + validation_errors[[field]] <- c(validation_errors[[field]], error) + } else { + validation_errors[[field]] <- list(error) + } + return(validation_errors) +} diff --git a/entrypoint.sh b/entrypoint.sh index ba7a13a..d3370c3 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,4 +4,5 @@ set -e echo "Running unbiased" -R -e "unbiased::run_unbiased()" \ No newline at end of file +# R -e "devtools::install(quick = TRUE, upgrade = FALSE); unbiased::run_unbiased()" +R -e "devtools::load_all(); unbiased:::run_unbiased_local()" diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index 79e186e..c7131fb 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -2,6 +2,7 @@ #* #* Set up a new study for randomization defining it's parameters #* +#* #* @param identifier:str Study code, at most 12 characters. #* @param name:str Full study name. #* @param method:str Function used to compute within-arm variability, must be one of: sd, var, range @@ -15,198 +16,178 @@ #* @serializer unboxedJSON #* function(identifier, name, method, arms, covariates, p, req, res) { - response <- list() - # Assert DB connectivity -------------------------------------------------- - checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") - - # Check if study exists --------------------------------------------------- - similar <- - dplyr::tbl(CONN, "study") |> - dplyr::select(id, name, identifier) |> - dplyr::filter(name == !!name | identifier == !!identifier) |> - dplyr::collect() - if (nrow(similar) >= 1) { - response <- c(response, list(similar_studies = similar)) - } - # Validate inputs --------------------------------------------------------- - # study name no longer than 255 characters - if (!checkmate::test_character(name, min.chars = 1, max.chars = 255)) { - message <- checkmate::check_character(name, max.chars = 255) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'name': {message}") - ) - ) - return(res) + validation_errors <- vector() + + logger::log_debug("Validating input") + + err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) + if (err != TRUE) { + validation_errors <- append_error( + validation_errors, "name", err + ) } - if (!checkmate::test_character(identifier, min.chars = 1, max.chars = 12)) { - message <- checkmate::check_character(identifier, max.chars = 12) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'identifier': {message}") - ) - ) - return(res) + + err <- checkmate::check_character(identifier, min.chars = 1, max.chars = 12) + if (err != TRUE) { + validation_errors <- append_error( + validation_errors, + "identifier", + err + ) } - if (!checkmate::test_choice(method, choices = c("range", "var", "sd"))) { - message <- - checkmate::check_choice( - method, - choices = c("range", "var", "sd") - ) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'method': {message}") - ) - ) - return(res) + + err <- checkmate::check_choice(method, choices = c("range", "var", "sd")) + if (err != TRUE) { + validation_errors <- append_error( + validation_errors, + "method", + err + ) } - if (!checkmate::test_list(arms, - types = "integerish", - any.missing = FALSE, - min.len = 2, - names = "unique" - )) { - message <- - checkmate::check_list(arms, - types = "integerish", - any.missing = FALSE, - min.len = 2, - names = "unique" - ) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'arms': {message}") - ) - ) - return(res) + + err <- + checkmate::check_list( + arms, + types = "integerish", + any.missing = FALSE, + min.len = 2, + names = "unique" + ) + if (err != TRUE) { + validation_errors <- append_error( + validation_errors, + "arms", + err + ) } - if (!checkmate::test_list(covariates, - types = c("numeric", "list", "character"), - any.missing = FALSE, - min.len = 2, - names = "unique" - )) { - message <- - checkmate::check_list(covariates, - types = c("numeric", "list", "character"), - any.missing = FALSE, - min.len = 1, - names = "unique" - ) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'covariates': {message}") - ) - ) - return(res) + + err <- + checkmate::check_list( + covariates, + types = c("numeric", "list", "character"), + any.missing = FALSE, + min.len = 2, + names = "unique" + ) + if (err != TRUE) { + validation_errors <- + append_error(validation_errors, "covariates", err) } - # check all covariates - purrr::walk2(covariates, names(covariates), function(c_content, c_name) { - if (length(c_content) != 2) { - res$status <- 400 - res$body <- - c( - response, - list( - error = - glue::glue("Covariate '{c_name}' has {length(c_content)} elements while 2 were expected") - ) + + response <- list() + for (c_name in names(covariates)) { + c_content <- covariates[[c_name]] + + err <- checkmate::check_list( + c_content, + any.missing = FALSE, + len = 2, + ) + if (err != TRUE) { + validation_errors <- + append_error( + validation_errors, + glue::glue("covariates[{c_name}]"), + err ) - return(res) } - # check covariate properties names - if (!all(names(c_content) == c("weight", "levels"))) { - res$status <- 400 - res$body <- - c( - response, - list( - error = - glue::glue("Covariate '{c_name}' has elements named different than 'weight' and 'levels'") - ) + err <- checkmate::check_names( + names(c_content), + permutation.of = c("weight", "levels"), + ) + if (err != TRUE) { + validation_errors <- + append_error( + validation_errors, + glue::glue("covariates[{c_name}]"), + err ) - return(res) } - if (!checkmate::test_numeric(c_content$weight, - lower = 0, - finite = TRUE, - len = 1, - null.ok = FALSE)) { - message <- - checkmate::check_numeric(c_content$weight, - lower = 0, - finite = TRUE, - len = 1, - null.ok = FALSE) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for covariate '{c_name}', weight: {message}") - ) + + # check covariate weight + err <- checkmate::check_numeric(c_content$weight, + lower = 0, + finite = TRUE, + len = 1, + null.ok = FALSE + ) + if (err != TRUE) { + validation_errors <- + append_error( + validation_errors, + glue::glue("covariates[{c_name}][weight]"), + err ) - return(res) } - if (!checkmate::test_character(c_content$levels, - min.chars = 1, - min.len = 2, - unique = TRUE)) { - message <- - checkmate::check_character(c_content$levels, - min.chars = 1, - min.len = 2, - unique = TRUE) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for covariate '{c_name}', levels: {message}") - ) + + err <- checkmate::check_character(c_content$levels, + min.chars = 1, + min.len = 2, + unique = TRUE + ) + if (err != TRUE) { + validation_errors <- + append_error( + validation_errors, + glue::glue("covariates[{c_name}][levels]"), + err ) - return(res) } - }) + } + # check probability p <- as.numeric(p) - if (!checkmate::test_numeric(p, lower = 0, upper = 1, len = 1)) { - message <- - checkmate::check_numeric(p, lower = 0, upper = 1, len = 1) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Input validation failed for 'p': {message}") - ) + err <- checkmate::check_numeric(p, lower = 0, upper = 1, len = 1) + if (err != TRUE) { + validation_errors <- + append_error( + validation_errors, + "p", + err ) - return(res) } - # Write study to DB ------------------------------------------------------- + if (length(validation_errors) > 0) { + res$status <- 400 + return(list( + error = "Input validation failed", + validation_errors = validation_errors + )) + } - # Response ---------------------------------------------------------------- + similar_studies <- get_similar_studies(name, identifier) - return(response) -} + strata <- purrr::imap(covariates, function(covariate, name) { + list( + name = name, + levels = covariate$levels, + value_type = "factor" + ) + }) + weights <- lapply(covariates, function(covariate) covariate$weight) + # Write study to DB ------------------------------------------------------- + new_study <- create_study( + name = name, + identifier = identifier, + method = "minimisation_pocock", + parameters = list( + method = method, + p = p, + weights = weights + ), + arms = arms, + strata = strata + ) + # Response ---------------------------------------------------------------- + response <- list( + study = new_study + ) + if (nrow(similar_studies) >= 1) { + response <- c(response, list(similar_studies = similar_studies)) + } + return(response) +} diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index f1b467c..91c56d4 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -16,15 +16,29 @@ function(api) { plumber::pr_mount("/meta", meta) |> plumber::pr_mount("/study", minimisation_pocock) |> plumber::pr_set_api_spec(function(spec) { - # example of how to define arms - spec$paths$`/study/minimisation_pocock`$post$requestBody$content$`application/json`$schema$properties$arms$example <- - list("placebo" = 1, "active" = 1) + spec$ + paths$ + `/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$schema$properties$ + arms$example <- list("placebo" = 1, "active" = 1) # example of how to define covariates in minimisation pocock - spec$paths$`/study/minimisation_pocock`$post$requestBody$content$`application/json`$schema$properties$covariates$example <- - list(sex = list(weight = 1, - levels = c("female", "male")), - weight = list(weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more"))) + spec$ + paths$`/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$ + schema$properties$ + covariates$example <- + list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + ) spec }) } @@ -47,3 +61,8 @@ function(req) { plumber::forward() } + +add_payload_example <- function(spec, endpoint, example) { + spec$paths[[endpoint]]$post$requestBody$content$`application/json`$schema$example <- example + spec +} diff --git a/man/run_unbiased_db.Rd b/man/run_unbiased_db.Rd deleted file mode 100644 index b73d63f..0000000 --- a/man/run_unbiased_db.Rd +++ /dev/null @@ -1,17 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/run_db.R -\name{run_unbiased_db} -\alias{run_unbiased_db} -\title{Run local DB} -\usage{ -run_unbiased_db() -} -\value{ -Return code describing success or lack thereof. -} -\description{ -Starts a Docker container containing a Postgres database for unbiased API -storage and sets environment variables to allow API to connect. Do not run -if an external Postgres database exists already, set appropriate env vars -instead. -} diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 97c6e43..081b53e 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -29,7 +29,12 @@ if (!isTRUE(as.logical(Sys.getenv("CI")))) { } # Close API upon exiting - withr::defer({ api$kill() }, teardown_env()) + withr::defer( + { + api$kill() + }, + teardown_env() + ) } # Retry a request until the API starts From 71f85e842433059c9c7d1471af74d0ac0e17e6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:45:48 +0000 Subject: [PATCH 114/240] remove study-define since this method was replaced by create_study method that uses less db queries --- R/study-define.R | 139 ----------------------------------------------- 1 file changed, 139 deletions(-) delete mode 100644 R/study-define.R diff --git a/R/study-define.R b/R/study-define.R deleted file mode 100644 index 39ded03..0000000 --- a/R/study-define.R +++ /dev/null @@ -1,139 +0,0 @@ -#' Define study -#' -#' @description -#' Creates a study with specified parameters and publishes it to the DB. -#' -#' @param name `character(1)`\cr -#' Full study name. -#' @param identifier `character(1)`\cr -#' Study code, at most 12 characters. -#' @param arms `character()`\cr -#' Arm names to use. -#' @param method `character(1)`\cr -#' Randomization method to apply. -#' @param ratio `integer()`\cr -#' Arm ratios, must be positive and the same length as arm names. -#' @param strata `list()`\cr -#' List of character vectors, each list element being a stratum and each string -#' being a possible stratum value. Could possibly take a numeric structure as -#' well instead of a character vector, e.g. `list(min = 1, max = 10)`. It just -#' needs handling by checking whether the inner list is named or not, I'd say. -#' @param parameters `list()`\cr -#' Parameters to pass to randomization. -#' -#' @return This function is called for the side effect of updating the DB. -#' -#' @examples -#' \dontrun{ -#' define_study( -#' "DEMO", "Demonstrational study", c("placebo", "active"), -#' method = "simple", -#' strata = list(gender = c("F", "M"), working = c("yes", "no")) -#' ) -#' } -#' -#' @export -define_study <- function(name, identifier, arms, - method = c("simple", "block"), - strata = list(), - parameters = NULL, - ratio = rep(1, times = length(arms))) { - method <- match.arg(method) - - # Assertions - assert_string(name) - assert_string(identifier, max.chars = 12) - - assert_character( - arms, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE - ) - assert_integerish(ratio, lower = 0, any.missing = FALSE, len = length(arms)) - - assert_list(strata, names = "unique", any.missing = FALSE) - purrr::walk(strata, function(stratum) { - # TODO: when allowing numeric strata, change the assertions here - assert_character( - stratum, min.chars = 1, any.missing = FALSE, min.len = 2, unique = TRUE - ) - }) - - assert_list(parameters, names = "unique", null.ok = TRUE) - - conn_from_pool <- pool::localCheckout(db_connection_pool) - - method_id <- tbl(conn_from_pool, "method") |> - filter(name == !!method) |> - pull(id) - assert_int(method_id) - - # Actual code - study_id <- tbl(conn_from_pool, "study") |> - rows_append( - tibble( - identifier = identifier, - name = name, - method_id = method_id, - parameters = jsonlite::toJSON(parameters, auto_unbox = FALSE) - ), - copy = TRUE, in_place = TRUE, returning = id - ) |> - dbplyr::get_returned_rows() |> - pull(id) - - purrr::walk2(arms, ratio, function(arm, prop) { - tbl(conn_from_pool, "arm") |> - rows_append( - tibble( - study_id = study_id, - name = arm, - ratio = prop - ), - copy = TRUE, in_place = TRUE - ) - }) - - purrr::iwalk(strata, function(stratum, name) { - if (is.numeric(stratum)) { - # Numeric case - stratum_id <- tbl(conn_from_pool, "stratum") |> - rows_append( - tibble( - study_id = study_id, - name = name, - value_type = "numeric" - ), - copy = TRUE, in_place = TRUE, returning = id - ) |> - dbplyr::get_returned_rows() |> - pull(id) - - # TODO: how to set min/max values? - } else { - # Factor case - stratum_id <- tbl(conn_from_pool, "stratum") |> - rows_append( - tibble( - study_id = study_id, - name = name, - value_type = "factor" - ), - copy = TRUE, in_place = TRUE, returning = id - ) |> - dbplyr::get_returned_rows() |> - pull(id) - - purrr::walk(stratum, function(value) { - tbl(conn_from_pool, "factor_constraint") |> - rows_append( - tibble( - stratum_id = stratum_id, - value = as.character(value) - ), - copy = TRUE, in_place = TRUE - ) - }) - } - }) - - study_id -} From f5c95a4665f5ec5fbe4143648c3b6215685fd79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:53:12 +0000 Subject: [PATCH 115/240] add missing logger package --- renv.lock | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/renv.lock b/renv.lock index b517d78..92b9151 100644 --- a/renv.lock +++ b/renv.lock @@ -583,6 +583,16 @@ ], "Hash": "001cecbeac1cff9301bdc3775ee46a86" }, + "logger": { + "Package": "logger", + "Version": "0.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "c269b06beb2bbadb0d058c0e6fa4ec3d" + }, "lubridate": { "Package": "lubridate", "Version": "1.9.3", From 6e3ed9801af869e953079d0da15055c95d151c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 10:54:10 +0000 Subject: [PATCH 116/240] handle study output format correctly --- inst/plumber/unbiased_api/minimisation_pocock.R | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index c7131fb..a120098 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -17,9 +17,7 @@ #* function(identifier, name, method, arms, covariates, p, req, res) { validation_errors <- vector() - - logger::log_debug("Validating input") - + err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) if (err != TRUE) { validation_errors <- append_error( @@ -167,7 +165,7 @@ function(identifier, name, method, arms, covariates, p, req, res) { weights <- lapply(covariates, function(covariate) covariate$weight) # Write study to DB ------------------------------------------------------- - new_study <- create_study( + r <- create_study( name = name, identifier = identifier, method = "minimisation_pocock", @@ -182,8 +180,16 @@ function(identifier, name, method, arms, covariates, p, req, res) { # Response ---------------------------------------------------------------- + if (!is.null(r$error)) { + res$status <- 409 + return(list( + error = "There was a problem creating the study", + details = r$error + )) + } + response <- list( - study = new_study + study = r$study ) if (nrow(similar_studies) >= 1) { response <- c(response, list(similar_studies = similar_studies)) From 3b24fba7ab6295a55af8c66310c1b4b379d36124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 11:52:40 +0000 Subject: [PATCH 117/240] fix namespace --- NAMESPACE | 1 - 1 file changed, 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index 102851c..c96d202 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,5 @@ # Generated by roxygen2: do not edit by hand -export(define_study) export(list_studies) export(randomize_minimisation_pocock) export(randomize_simple) From c5e6510a6f5643e0ab7ece4279d39d4cf4c71148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 12 Jan 2024 11:57:40 +0000 Subject: [PATCH 118/240] regenerate man pages --- man/append_error.Rd | 11 ++++++++ man/define_study.Rd | 57 -------------------------------------- man/get_similar_studies.Rd | 11 ++++++++ 3 files changed, 22 insertions(+), 57 deletions(-) create mode 100644 man/append_error.Rd delete mode 100644 man/define_study.Rd create mode 100644 man/get_similar_studies.Rd diff --git a/man/append_error.Rd b/man/append_error.Rd new file mode 100644 index 0000000..b443d1c --- /dev/null +++ b/man/append_error.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/validation-utils.R +\name{append_error} +\alias{append_error} +\title{Utility functions for validation} +\usage{ +append_error(validation_errors, field, error) +} +\description{ +Utility functions for validation +} diff --git a/man/define_study.Rd b/man/define_study.Rd deleted file mode 100644 index 2876268..0000000 --- a/man/define_study.Rd +++ /dev/null @@ -1,57 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/study-define.R -\name{define_study} -\alias{define_study} -\title{Define study} -\usage{ -define_study( - name, - identifier, - arms, - method = c("simple", "block"), - strata = list(), - parameters = NULL, - ratio = rep(1, times = length(arms)) -) -} -\arguments{ -\item{name}{\code{character(1)}\cr -Full study name.} - -\item{identifier}{\code{character(1)}\cr -Study code, at most 12 characters.} - -\item{arms}{\code{character()}\cr -Arm names to use.} - -\item{method}{\code{character(1)}\cr -Randomization method to apply.} - -\item{strata}{\code{list()}\cr -List of character vectors, each list element being a stratum and each string -being a possible stratum value. Could possibly take a numeric structure as -well instead of a character vector, e.g. \code{list(min = 1, max = 10)}. It just -needs handling by checking whether the inner list is named or not, I'd say.} - -\item{parameters}{\code{list()}\cr -Parameters to pass to randomization.} - -\item{ratio}{\code{integer()}\cr -Arm ratios, must be positive and the same length as arm names.} -} -\value{ -This function is called for the side effect of updating the DB. -} -\description{ -Creates a study with specified parameters and publishes it to the DB. -} -\examples{ -\dontrun{ -define_study( - "DEMO", "Demonstrational study", c("placebo", "active"), - method = "simple", - strata = list(gender = c("F", "M"), working = c("yes", "no")) -) -} - -} diff --git a/man/get_similar_studies.Rd b/man/get_similar_studies.Rd new file mode 100644 index 0000000..f56af93 --- /dev/null +++ b/man/get_similar_studies.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/study-repository.R +\name{get_similar_studies} +\alias{get_similar_studies} +\title{Defines methods for interacting with the study in the database} +\usage{ +get_similar_studies(name, identifier) +} +\description{ +Defines methods for interacting with the study in the database +} From ab04d71a382f565a7f47c1abdb88acf95f765618 Mon Sep 17 00:00:00 2001 From: Kinga Date: Fri, 12 Jan 2024 12:45:59 +0000 Subject: [PATCH 119/240] Added library::function() --- R/parse_pocock.R | 22 ++++++++++--------- .../unbiased_api/randomization_patient.R | 17 +++++++------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/R/parse_pocock.R b/R/parse_pocock.R index e07128e..3701d11 100644 --- a/R/parse_pocock.R +++ b/R/parse_pocock.R @@ -6,10 +6,12 @@ parse_pocock_parameters <- function(CONN, study_id, current_state){ - parameters <- tbl(CONN, "study") |> - filter(study_id == study_id) |> - select(parameters) |> - pull() + + parameters <- + dplyr::tbl(CONN, "study") |> + dplyr::filter(study_id == study_id) |> + dplyr::select(parameters) |> + dplyr::pull() parameters <- jsonlite::fromJSON(parameters) @@ -29,15 +31,15 @@ parse_pocock_parameters <- function(CONN, study_id, current_state){ # do testowania # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') - ratio_arms = tbl(CONN, "arm") |> - filter(study_id == study_id) |> - select(name, ratio) |> - collect() + ratio_arms <- + dplyr::tbl(CONN, "arm") |> + dplyr::filter(study_id == study_id) |> + dplyr::select(name, ratio) |> + dplyr::collect() params <- list( arms = ratio_arms$name, - # current_state = tibble::as_tibble(current_state), - current_state = current_state, + current_state = tibble::as_tibble(current_state), ratio = setNames(ratio_arms$ratio, ratio_arms$name), method = parameters$method, p = parameters$p, diff --git a/inst/plumber/unbiased_api/randomization_patient.R b/inst/plumber/unbiased_api/randomization_patient.R index 8946685..765c0c1 100644 --- a/inst/plumber/unbiased_api/randomization_patient.R +++ b/inst/plumber/unbiased_api/randomization_patient.R @@ -12,18 +12,20 @@ function(study_id, current_state, req, res) { checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") # Check whether study with study_id exists - checkmate::expect_subset(x = tbl(CONN, "study") |> - select(id) |> - pull(), + checkmate::expect_subset(x = + dplyr::tbl(CONN, "study") |> + dplyr::select(id) |> + dplyr::pull(), choices = req$args$study_id) #DF validation - error handling # Retrieve study details, especially the ones about randomization - method_randomization <- tbl(CONN, "study") |> - filter(study_id == study_id) |> - select(method) |> - pull() + method_randomization <- + dplyr::tbl(CONN, "study") |> + dplyr::filter(study_id == study_id) |> + dplyr::select(method) |> + dplyr::pull() # Dispatch based on randomization method to parse parameters params <- @@ -50,6 +52,5 @@ function(study_id, current_state, req, res) { } ) ) - } From 126c66e6b91b66c512309ab7c09dd3b534e143cf Mon Sep 17 00:00:00 2001 From: Kinga Date: Fri, 12 Jan 2024 15:44:37 +0000 Subject: [PATCH 120/240] Finalizing the endpoint. Changing the connection to the database. Returning the patient number and assigned patient. --- R/parse_pocock.R | 11 +++--- R/randomize-minimisation-pocock.R | 2 + R/study-repository.R | 13 +++++++ .../unbiased_api/randomization_patient.R | 39 +++++++++++++------ 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/R/parse_pocock.R b/R/parse_pocock.R index 3701d11..9d8334d 100644 --- a/R/parse_pocock.R +++ b/R/parse_pocock.R @@ -5,11 +5,10 @@ #' @return params List of parameters -parse_pocock_parameters <- function(CONN, study_id, current_state){ - +parse_pocock_parameters <- function(db_connetion_pool, study_id, current_state){ parameters <- - dplyr::tbl(CONN, "study") |> - dplyr::filter(study_id == study_id) |> + dplyr::tbl(db_connetion_pool, "study") |> + dplyr::filter(id == study_id) |> dplyr::select(parameters) |> dplyr::pull() @@ -32,8 +31,8 @@ parse_pocock_parameters <- function(CONN, study_id, current_state){ # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') ratio_arms <- - dplyr::tbl(CONN, "arm") |> - dplyr::filter(study_id == study_id) |> + dplyr::tbl(db_connetion_pool, "arm") |> + dplyr::filter(study_id == !!study_id) |> dplyr::select(name, ratio) |> dplyr::collect() diff --git a/R/randomize-minimisation-pocock.R b/R/randomize-minimisation-pocock.R index 2f7aa07..c9537e0 100644 --- a/R/randomize-minimisation-pocock.R +++ b/R/randomize-minimisation-pocock.R @@ -117,12 +117,14 @@ randomize_minimisation_pocock <- ratio, method = "var", p = 0.85) { + # Assertions checkmate::assert_character( arms, min.len = 2, min.chars = 1, unique = TRUE) + checkmate::assert_choice( method, choices = c("range", "var", "sd") diff --git a/R/study-repository.R b/R/study-repository.R index a07fed6..0375cde 100644 --- a/R/study-repository.R +++ b/R/study-repository.R @@ -110,3 +110,16 @@ create_study <- function( r } + +save_patient <- function(study_id, arm_id){ + randomized_patient <- DBI::dbGetQuery( + db_connection_pool, + "INSERT INTO patient (arm_id, study_id) + VALUES ($1, $2) + RETURNING id, arm_id", + list(arm_id, study_id) + ) + + return(randomized_patient) +} + diff --git a/inst/plumber/unbiased_api/randomization_patient.R b/inst/plumber/unbiased_api/randomization_patient.R index 765c0c1..67a6e94 100644 --- a/inst/plumber/unbiased_api/randomization_patient.R +++ b/inst/plumber/unbiased_api/randomization_patient.R @@ -6,41 +6,47 @@ #* #* @tag randomize #* @post /study//patient -function(study_id, current_state, req, res) { +#* @serializer unboxedJSON +#* +function(study_id, current_state, req, res) { # Assertion connection with DB - checkmate::assert(DBI::dbIsValid(CONN), .var.name = "DB connection") + checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection") + # Check whether study with study_id exists - checkmate::expect_subset(x = - dplyr::tbl(CONN, "study") |> + checkmate::expect_subset(x = req$args$study_id, + choices = + dplyr::tbl(db_connection_pool, "study") |> dplyr::select(id) |> - dplyr::pull(), - choices = req$args$study_id) + dplyr::pull()) #DF validation - error handling # Retrieve study details, especially the ones about randomization method_randomization <- - dplyr::tbl(CONN, "study") |> - dplyr::filter(study_id == study_id) |> + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> dplyr::select(method) |> dplyr::pull() + # asercja jeden element + # Dispatch based on randomization method to parse parameters params <- switch( method_randomization, - # simple = do.call(unbiased:::parse_simple_randomization, list(CONN, study_id, current_state)), minimisation_pocock = tryCatch({ - do.call(unbiased:::parse_pocock_parameters, list(CONN, study_id, current_state)) + do.call(unbiased:::parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) }, error = function(e) { res$status <- 400 res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) }) ) - switch( + arm_name <- + switch( method_randomization, # simple = do.call(unbiased:::randomize_simple, params), minimisation_pocock = tryCatch({ @@ -49,8 +55,19 @@ function(study_id, current_state, req, res) { # browser() res$status <- 400 res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) } ) ) + + arm <- dplyr::tbl(db_connection_pool, "arm") |> + dplyr::filter(study_id == !!study_id & name == arm_name) |> + dplyr::select(arm_id = id, name, ratio) |> + dplyr::collect() + + save_patient(study_id, arm$arm_id) |> + dplyr::mutate(arm_name = arm$name) |> + rename(patient_id = id) |> + as.list() } From 87f0a1da9e426daf98782d5bbf4342204eaa3cf3 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 12 Jan 2024 15:59:59 +0000 Subject: [PATCH 121/240] smoke tests --- .../test-E2E-study-minimisation-pocock.R | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/testthat/test-E2E-study-minimisation-pocock.R diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R new file mode 100644 index 0000000..2e2fae1 --- /dev/null +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -0,0 +1,47 @@ +test_that("endpoint returns the study id, can randomize 2 patients", { + response <- request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_url_query(identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85) |> + req_body_json( + data = list(arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform() + response_body <- + response |> + resp_body_json() + + expect_number(response$status_code, lower = 200, upper = 200) + expect_number(response_body$study$id, lower = 1, upper = 200) + + response_patient <- request(api_url) |> + req_url_path("study", response_body$study$id, "patient") |> + req_method("POST") |> + req_body_json( + data = tibble::tibble("sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "")) + ) |> + req_perform() + response_patient_body <- + response_patient |> + resp_body_json() + + expect_number(response_patient$status_code, lower = 200, upper = 200) + expect_number(response_patient_body$study$id, lower = 1, upper = 200) +}) From 6fa574f0890bcf7198ed2c8869926bfbae6dd3dc Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 12 Jan 2024 16:41:14 +0000 Subject: [PATCH 122/240] finished smoke tests, changed routing --- .../unbiased_api/minimisation_pocock.R | 74 +++++++++++++++++++ inst/plumber/unbiased_api/plumber.R | 7 +- .../unbiased_api/randomization_patient.R | 73 ------------------ .../test-E2E-study-minimisation-pocock.R | 7 +- 4 files changed, 82 insertions(+), 79 deletions(-) delete mode 100644 inst/plumber/unbiased_api/randomization_patient.R diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index a120098..a7199b1 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -197,3 +197,77 @@ function(identifier, name, method, arms, covariates, p, req, res) { return(response) } + +#* Randomize one patient +#* +#* +#* @param study_id:int Study identifier +#* @param current_state:object +#* +#* @tag randomize +#* @post //patient +#* @serializer unboxedJSON +#* + +function(study_id, current_state, req, res) { + # Assertion connection with DB + checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection") + + + # Check whether study with study_id exists + checkmate::expect_subset(x = req$args$study_id, + choices = + dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(id) |> + dplyr::pull()) + + #DF validation - error handling + + # Retrieve study details, especially the ones about randomization + method_randomization <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::select(method) |> + dplyr::pull() + + # asercja jeden element + + # Dispatch based on randomization method to parse parameters + params <- + switch( + method_randomization, + minimisation_pocock = tryCatch({ + do.call(unbiased:::parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) + }, error = function(e) { + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) + }) + ) + + arm_name <- + switch( + method_randomization, + # simple = do.call(unbiased:::randomize_simple, params), + minimisation_pocock = tryCatch({ + do.call(unbiased:::randomize_minimisation_pocock, params) + }, error = function(e) { + # browser() + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) + } + ) + ) + + arm <- dplyr::tbl(db_connection_pool, "arm") |> + dplyr::filter(study_id == !!study_id & name == arm_name) |> + dplyr::select(arm_id = id, name, ratio) |> + dplyr::collect() + + save_patient(study_id, arm$arm_id) |> + dplyr::mutate(arm_name = arm$name) |> + rename(patient_id = id) |> + as.list() +} + diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 6df174a..4ffd51c 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -11,11 +11,9 @@ function(api) { meta <- plumber::pr("meta.R") minimisation_pocock <- plumber::pr("minimisation_pocock.R") - randomization_patient <- plumber::pr("randomization_patient.R") api |> plumber::pr_mount("/meta", meta) |> - plumber::pr_mount("/study/patient", randomization_patient) |> plumber::pr_mount("/study", minimisation_pocock) |> plumber::pr_set_api_spec(function(spec) { spec$ @@ -41,7 +39,10 @@ function(api) { levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) ) - spec$paths$`/study/patient/study/{study_id}/patient`$post$requestBody$content$`application/json`$schema$properties$current_state$example <- + spec$ + paths$`/study/{study_id}/patient`$ + post$requestBody$content$`application/json`$ + schema$properties$current_state$example <- tibble::tibble("sex" = c("female", "male"), "weight" = c("61-80 kg", "81 kg or more"), "arm" = c("placebo", "")) diff --git a/inst/plumber/unbiased_api/randomization_patient.R b/inst/plumber/unbiased_api/randomization_patient.R deleted file mode 100644 index 67a6e94..0000000 --- a/inst/plumber/unbiased_api/randomization_patient.R +++ /dev/null @@ -1,73 +0,0 @@ -#* Randomize one patient -#* -#* -#* @param study_id:int Study identifier -#* @param current_state:object -#* -#* @tag randomize -#* @post /study//patient -#* @serializer unboxedJSON -#* - -function(study_id, current_state, req, res) { - # Assertion connection with DB - checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection") - - - # Check whether study with study_id exists - checkmate::expect_subset(x = req$args$study_id, - choices = - dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(id) |> - dplyr::pull()) - - #DF validation - error handling - - # Retrieve study details, especially the ones about randomization - method_randomization <- - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::select(method) |> - dplyr::pull() - - # asercja jeden element - - # Dispatch based on randomization method to parse parameters - params <- - switch( - method_randomization, - minimisation_pocock = tryCatch({ - do.call(unbiased:::parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) - }, error = function(e) { - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - }) - ) - - arm_name <- - switch( - method_randomization, - # simple = do.call(unbiased:::randomize_simple, params), - minimisation_pocock = tryCatch({ - do.call(unbiased:::randomize_minimisation_pocock, params) - }, error = function(e) { - # browser() - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - } - ) - ) - - arm <- dplyr::tbl(db_connection_pool, "arm") |> - dplyr::filter(study_id == !!study_id & name == arm_name) |> - dplyr::select(arm_id = id, name, ratio) |> - dplyr::collect() - - save_patient(study_id, arm$arm_id) |> - dplyr::mutate(arm_name = arm$name) |> - rename(patient_id = id) |> - as.list() -} - diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 2e2fae1..9e49788 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -33,9 +33,10 @@ test_that("endpoint returns the study id, can randomize 2 patients", { req_url_path("study", response_body$study$id, "patient") |> req_method("POST") |> req_body_json( - data = tibble::tibble("sex" = c("female", "male"), + data = list(current_state = + tibble::tibble("sex" = c("female", "male"), "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", "")) + "arm" = c("placebo", ""))) ) |> req_perform() response_patient_body <- @@ -43,5 +44,5 @@ test_that("endpoint returns the study id, can randomize 2 patients", { resp_body_json() expect_number(response_patient$status_code, lower = 200, upper = 200) - expect_number(response_patient_body$study$id, lower = 1, upper = 200) + expect_number(response_patient_body$patient_id, lower = 1, upper = 200) }) From d9564870418a4219858f4aa7991e581db7612a4c Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 12 Jan 2024 16:53:59 +0000 Subject: [PATCH 123/240] moved API utils to API dir --- inst/plumber/unbiased_api/minimisation_pocock.R | 5 ++++- {R => inst/plumber/unbiased_api}/parse_pocock.R | 0 {R => inst/plumber/unbiased_api}/study-repository.R | 0 {R => inst/plumber/unbiased_api}/validation-utils.R | 0 4 files changed, 4 insertions(+), 1 deletion(-) rename {R => inst/plumber/unbiased_api}/parse_pocock.R (100%) rename {R => inst/plumber/unbiased_api}/study-repository.R (100%) rename {R => inst/plumber/unbiased_api}/validation-utils.R (100%) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index a7199b1..279212f 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -16,6 +16,8 @@ #* @serializer unboxedJSON #* function(identifier, name, method, arms, covariates, p, req, res) { + source("study-repository.R") + source("validation-utils.R") validation_errors <- vector() err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) @@ -233,11 +235,12 @@ function(study_id, current_state, req, res) { # asercja jeden element # Dispatch based on randomization method to parse parameters + source("parse_pocock.R") params <- switch( method_randomization, minimisation_pocock = tryCatch({ - do.call(unbiased:::parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) + do.call(parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) }, error = function(e) { res$status <- 400 res$body = glue::glue("Error message: {conditionMessage(e)}") diff --git a/R/parse_pocock.R b/inst/plumber/unbiased_api/parse_pocock.R similarity index 100% rename from R/parse_pocock.R rename to inst/plumber/unbiased_api/parse_pocock.R diff --git a/R/study-repository.R b/inst/plumber/unbiased_api/study-repository.R similarity index 100% rename from R/study-repository.R rename to inst/plumber/unbiased_api/study-repository.R diff --git a/R/validation-utils.R b/inst/plumber/unbiased_api/validation-utils.R similarity index 100% rename from R/validation-utils.R rename to inst/plumber/unbiased_api/validation-utils.R From 387244c3ce15183c2a2a0dc7a14578803257a815 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Fri, 12 Jan 2024 17:00:05 +0000 Subject: [PATCH 124/240] bugfix --- inst/plumber/unbiased_api/minimisation_pocock.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index 279212f..c1e41a9 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -270,7 +270,7 @@ function(study_id, current_state, req, res) { save_patient(study_id, arm$arm_id) |> dplyr::mutate(arm_name = arm$name) |> - rename(patient_id = id) |> + dplyr::rename(patient_id = id) |> as.list() } From f2436340dcef3826b08805e260facb1720787e44 Mon Sep 17 00:00:00 2001 From: Ola Date: Thu, 25 Jan 2024 12:33:39 +0000 Subject: [PATCH 125/240] Add simulations.Rmd (vignette code + text) with references.bib file --- .gitignore | 1 + DESCRIPTION | 5 +- renv.lock | 1443 +++++++++++++++++- vignettes/.gitignore | 2 + vignettes/references.bib | 191 +++ vignettes/renv.lock | 2959 +++++++++++++++++++++++++++++++++++++ vignettes/simulations.Rmd | 860 +++++++++++ 7 files changed, 5445 insertions(+), 16 deletions(-) create mode 100644 vignettes/.gitignore create mode 100644 vignettes/references.bib create mode 100644 vignettes/renv.lock create mode 100644 vignettes/simulations.Rmd diff --git a/.gitignore b/.gitignore index b27cb91..20e257d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ po/*~ rsconnect/ .Rproj.user docs +inst/doc diff --git a/DESCRIPTION b/DESCRIPTION index c2b6c56..d0cb095 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -42,10 +42,13 @@ Suggests: DBI, glue, jsonlite, - purrr + purrr, + knitr, + rmarkdown RdMacros: mathjaxr Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) RoxygenNote: 7.2.3 URL: https://ttscience.github.io/unbiased/ +VignetteBuilder: knitr diff --git a/renv.lock b/renv.lock index 92b9151..11e6669 100644 --- a/renv.lock +++ b/renv.lock @@ -1,6 +1,6 @@ { "R": { - "Version": "4.2.3", + "Version": "4.2.1", "Repositories": [ { "Name": "CRAN", @@ -9,6 +9,13 @@ ] }, "Packages": { + "BH": { + "Package": "BH", + "Version": "1.81.0-1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "68122010f01c4dcfbe58ce7112f2433d" + }, "DBI": { "Package": "DBI", "Version": "1.2.0", @@ -20,6 +27,47 @@ ], "Hash": "3e0051431dff9acfe66c23765e55c556" }, + "MASS": { + "Package": "MASS", + "Version": "7.3-57", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "71476c1d88d1ebdf31580e5a257d5d31" + }, + "Matrix": { + "Package": "Matrix", + "Version": "1.4-1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "grid", + "lattice", + "methods", + "stats", + "utils" + ], + "Hash": "699c47c606293bdfbc9fd78a93c9c8fe" + }, + "PwrGSD": { + "Package": "PwrGSD", + "Version": "2.3.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "survival" + ], + "Hash": "c26126e59b9b078953521379ee219a05" + }, "R6": { "Package": "R6", "Version": "2.5.1", @@ -30,6 +78,16 @@ ], "Hash": "470851b6d5d0ac559e9d01bb352b4021" }, + "RColorBrewer": { + "Package": "RColorBrewer", + "Version": "1.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "45f0398006e83a5b10b72a90663d8d8c" + }, "RPostgres": { "Package": "RPostgres", "Version": "1.4.6", @@ -60,6 +118,58 @@ ], "Hash": "ae6cbbe1492f4de79c45fce06f967ce8" }, + "RcppArmadillo": { + "Package": "RcppArmadillo", + "Version": "0.12.6.6.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "methods", + "stats", + "utils" + ], + "Hash": "d2b60e0a15d73182a3a766ff0a7d0d7f" + }, + "RcppEigen": { + "Package": "RcppEigen", + "Version": "0.3.3.9.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "stats", + "utils" + ], + "Hash": "acb0a5bf38490f26ab8661b467f4f53a" + }, + "TH.data": { + "Package": "TH.data", + "Version": "1.1-2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "survival" + ], + "Hash": "5b250ad4c5863ee4a68e280fcb0a3600" + }, + "V8": { + "Package": "V8", + "Version": "4.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp", + "curl", + "jsonlite", + "utils" + ], + "Hash": "435359b59b8a9b8f9235135da471ea3c" + }, "askpass": { "Package": "askpass", "Version": "1.2.0", @@ -90,6 +200,47 @@ ], "Hash": "543776ae6848fde2f48ff3816d0628bc" }, + "bigD": { + "Package": "bigD", + "Version": "0.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "93637e906f3fe962413912c956eb44db" + }, + "bigmemory": { + "Package": "bigmemory", + "Version": "4.6.2", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteRepo": "bigmemory", + "RemoteUsername": "kaneplusplus", + "RemoteRef": "HEAD", + "RemoteSha": "3064277f4a83b74490464ea4ac5a43f76e426ada", + "Requirements": [ + "BH", + "R", + "Rcpp", + "bigmemory.sri", + "methods", + "utils", + "uuid" + ], + "Hash": "65fe01c6e8e22c8bd0c6f5b5e3ccf19e" + }, + "bigmemory.sri": { + "Package": "bigmemory.sri", + "Version": "0.1.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods" + ], + "Hash": "cd3e474a907284c598e60417a5edeb79" + }, "bit": { "Package": "bit", "Version": "4.0.5", @@ -114,6 +265,13 @@ ], "Hash": "9fe98599ca456d6552421db0d6772d8f" }, + "bitops": { + "Package": "bitops", + "Version": "1.0-7", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "b7d8d8ee39869c18d8846a184dd8a1af" + }, "blob": { "Package": "blob", "Version": "1.2.4", @@ -133,6 +291,48 @@ "Repository": "RSPM", "Hash": "976cf154dfb043c012d87cddd8bca363" }, + "broom": { + "Package": "broom", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "backports", + "dplyr", + "ellipsis", + "generics", + "glue", + "lifecycle", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyr" + ], + "Hash": "fd25391c3c4f6ecf0fa95f1e6d15378c" + }, + "broom.helpers": { + "Package": "broom.helpers", + "Version": "1.14.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "cli", + "dplyr", + "labelled", + "lifecycle", + "purrr", + "rlang", + "stats", + "stringr", + "tibble", + "tidyr" + ], + "Hash": "ea30eb5d9412a4a5c2740685f680cd49" + }, "bslib": { "Package": "bslib", "Version": "0.6.1", @@ -178,6 +378,18 @@ ], "Hash": "9b2191ede20fa29828139b9900922e51" }, + "cellranger": { + "Package": "cellranger", + "Version": "1.1.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "rematch", + "tibble" + ], + "Hash": "f61dbaec772ccd2e17705c1e872e9e7c" + }, "checkmate": { "Package": "checkmate", "Version": "2.2.0", @@ -190,6 +402,19 @@ ], "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" }, + "class": { + "Package": "class", + "Version": "7.3-20", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "MASS", + "R", + "stats", + "utils" + ], + "Hash": "da09d82223e669d270e47ed24ac8686e" + }, "cli": { "Package": "cli", "Version": "3.6.1", @@ -201,6 +426,81 @@ ], "Hash": "89e6d8219950eac806ae0c489052048a" }, + "clipr": { + "Package": "clipr", + "Version": "0.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" + }, + "codetools": { + "Package": "codetools", + "Version": "0.2-18", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "019388fc48e48b3da0d3a76ff94608a8" + }, + "coin": { + "Package": "coin", + "Version": "1.4-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "libcoin", + "matrixStats", + "methods", + "modeltools", + "multcomp", + "mvtnorm", + "parallel", + "stats", + "stats4", + "survival", + "utils" + ], + "Hash": "4084b5070a40ad99dad581ed3b67bd55" + }, + "colorspace": { + "Package": "colorspace", + "Version": "2.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats" + ], + "Hash": "f20c47fd52fae58b4e377c37bb8c335b" + }, + "commonmark": { + "Package": "commonmark", + "Version": "1.9.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "d691c61bff84bd63c383874d2d0c3307" + }, + "conflicted": { + "Package": "conflicted", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "memoise", + "rlang" + ], + "Hash": "bb097fccb22d156624fd07cd2894ddb6" + }, "cpp11": { "Package": "cpp11", "Version": "0.4.6", @@ -233,6 +533,17 @@ ], "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" }, + "data.table": { + "Package": "data.table", + "Version": "1.14.10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "6ea17a32294d8ca00455825ab0cf71b9" + }, "dbplyr": { "Package": "dbplyr", "Version": "2.4.0", @@ -344,6 +655,41 @@ ], "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" }, + "dtplyr": { + "Package": "dtplyr", + "Version": "1.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "data.table", + "dplyr", + "glue", + "lifecycle", + "rlang", + "tibble", + "tidyselect", + "vctrs" + ], + "Hash": "54ed3ea01b11e81a86544faaecfef8e2" + }, + "e1071": { + "Package": "e1071", + "Version": "1.7-14", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "class", + "grDevices", + "graphics", + "methods", + "proxy", + "stats", + "utils" + ], + "Hash": "4ef372b716824753719a8a38b258442d" + }, "ellipsis": { "Package": "ellipsis", "Version": "0.3.2", @@ -378,6 +724,27 @@ ], "Hash": "3e8583a60163b4bc1a80016e63b9959e" }, + "farver": { + "Package": "farver", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8106d78941f34855c440ddb946b8f7a5" + }, + "fastglm": { + "Package": "fastglm", + "Version": "0.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "BH", + "Rcpp", + "RcppEigen", + "bigmemory", + "methods" + ], + "Hash": "e0f222ad320efdaa48ebf88eb576bb21" + }, "fastmap": { "Package": "fastmap", "Version": "1.1.1", @@ -397,6 +764,22 @@ ], "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" }, + "forcats": { + "Package": "forcats", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "tibble" + ], + "Hash": "1a0a9a3d5083d0d573c4214576f1e690" + }, "fs": { "Package": "fs", "Version": "1.6.3", @@ -406,29 +789,280 @@ "R", "methods" ], - "Hash": "47b5f30c720c23999b913a1a635cf0bb" + "Hash": "47b5f30c720c23999b913a1a635cf0bb" + }, + "gargle": { + "Package": "gargle", + "Version": "1.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "fs", + "glue", + "httr", + "jsonlite", + "lifecycle", + "openssl", + "rappdirs", + "rlang", + "stats", + "utils", + "withr" + ], + "Hash": "fc0b272e5847c58cd5da9b20eedbd026" + }, + "gdata": { + "Package": "gdata", + "Version": "3.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "gtools", + "methods", + "stats", + "utils" + ], + "Hash": "d3d6e4c174b8a5f251fd273f245f2471" + }, + "generics": { + "Package": "generics", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + }, + "ggplot2": { + "Package": "ggplot2", + "Version": "3.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "cli", + "glue", + "grDevices", + "grid", + "gtable", + "isoband", + "lifecycle", + "mgcv", + "rlang", + "scales", + "stats", + "tibble", + "vctrs", + "withr" + ], + "Hash": "313d31eff2274ecf4c1d3581db7241f9" + }, + "glue": { + "Package": "glue", + "Version": "1.6.2", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods" + ], + "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" + }, + "gmodels": { + "Package": "gmodels", + "Version": "2.18.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "gdata" + ], + "Hash": "6713a242cb6909e492d8169a35dfe0b0" + }, + "googledrive": { + "Package": "googledrive", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gargle", + "glue", + "httr", + "jsonlite", + "lifecycle", + "magrittr", + "pillar", + "purrr", + "rlang", + "tibble", + "utils", + "uuid", + "vctrs", + "withr" + ], + "Hash": "e99641edef03e2a5e87f0a0b1fcc97f4" + }, + "googlesheets4": { + "Package": "googlesheets4", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cellranger", + "cli", + "curl", + "gargle", + "glue", + "googledrive", + "httr", + "ids", + "lifecycle", + "magrittr", + "methods", + "purrr", + "rematch2", + "rlang", + "tibble", + "utils", + "vctrs", + "withr" + ], + "Hash": "d6db1667059d027da730decdc214b959" + }, + "gsDesign": { + "Package": "gsDesign", + "Version": "3.6.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "dplyr", + "ggplot2", + "graphics", + "gt", + "magrittr", + "methods", + "r2rtf", + "rlang", + "stats", + "tibble", + "tidyr", + "tools", + "xtable" + ], + "Hash": "496b38bfc6524e1a1fc04220da550892" + }, + "gt": { + "Package": "gt", + "Version": "0.10.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "bigD", + "bitops", + "cli", + "commonmark", + "dplyr", + "fs", + "glue", + "htmltools", + "htmlwidgets", + "juicyjuice", + "magrittr", + "markdown", + "reactable", + "rlang", + "sass", + "scales", + "tibble", + "tidyselect", + "xml2" + ], + "Hash": "21737c74811cccac01b5097bcb0f8b4c" + }, + "gtable": { + "Package": "gtable", + "Version": "0.3.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "grid", + "lifecycle", + "rlang" + ], + "Hash": "b29cf3031f49b04ab9c852c912547eef" + }, + "gtools": { + "Package": "gtools", + "Version": "3.9.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "stats", + "utils" + ], + "Hash": "588d091c35389f1f4a9d533c8d709b35" }, - "generics": { - "Package": "generics", - "Version": "0.1.3", + "gtsummary": { + "Package": "gtsummary", + "Version": "1.7.2", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", - "methods" + "broom", + "broom.helpers", + "cli", + "dplyr", + "forcats", + "glue", + "gt", + "knitr", + "lifecycle", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyr", + "vctrs" ], - "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + "Hash": "08df7405a102e3f0bdf7a13a29e8c6ab" }, - "glue": { - "Package": "glue", - "Version": "1.6.2", + "haven": { + "Package": "haven", + "Version": "2.5.4", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", - "methods" + "cli", + "cpp11", + "forcats", + "hms", + "lifecycle", + "methods", + "readr", + "rlang", + "tibble", + "tidyselect", + "vctrs" ], - "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" + "Hash": "9171f898db9d9c4c1b2c745adc2c1ef1" }, "highr": { "Package": "highr", @@ -472,6 +1106,21 @@ ], "Hash": "2d7b3857980e0e0d0a1fd6f11928ab0f" }, + "htmlwidgets": { + "Package": "htmlwidgets", + "Version": "1.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "htmltools", + "jsonlite", + "knitr", + "rmarkdown", + "yaml" + ], + "Hash": "04291cc45198225444a397606810ac37" + }, "httpuv": { "Package": "httpuv", "Version": "1.6.11", @@ -523,6 +1172,41 @@ ], "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" }, + "ids": { + "Package": "ids", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "openssl", + "uuid" + ], + "Hash": "99df65cfef20e525ed38c3d2577f7190" + }, + "insight": { + "Package": "insight", + "Version": "0.19.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "utils" + ], + "Hash": "750aba9b42391da33ac290b71a749023" + }, + "isoband": { + "Package": "isoband", + "Version": "0.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grid", + "utils" + ], + "Hash": "0080607b4a1a7b28979aecef976d8bc2" + }, "jquerylib": { "Package": "jquerylib", "Version": "0.1.4", @@ -543,6 +1227,16 @@ ], "Hash": "e1b9c55281c5adc4dd113652d9e26768" }, + "juicyjuice": { + "Package": "juicyjuice", + "Version": "0.1.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "V8" + ], + "Hash": "3bcd11943da509341838da9399e18bce" + }, "knitr": { "Package": "knitr", "Version": "1.45", @@ -559,6 +1253,34 @@ ], "Hash": "1ec462871063897135c1bcbe0fc8f07d" }, + "labeling": { + "Package": "labeling", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "graphics", + "stats" + ], + "Hash": "b64ec208ac5bc1852b285f665d6368b3" + }, + "labelled": { + "Package": "labelled", + "Version": "2.12.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "dplyr", + "haven", + "lifecycle", + "rlang", + "stringr", + "tidyr", + "vctrs" + ], + "Hash": "1ec27c624ece6c20431e9249bd232797" + }, "later": { "Package": "later", "Version": "1.3.1", @@ -570,6 +1292,33 @@ ], "Hash": "40401c9cf2bc2259dfe83311c9384710" }, + "lattice": { + "Package": "lattice", + "Version": "0.20-45", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "stats", + "utils" + ], + "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" + }, + "libcoin": { + "Package": "libcoin", + "Version": "1.0-10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "mvtnorm", + "stats" + ], + "Hash": "3f3775a14588ff5d013e5eab4453bf28" + }, "lifecycle": { "Package": "lifecycle", "Version": "1.0.3", @@ -616,6 +1365,19 @@ ], "Hash": "7ce2733a9826b3aeb1775d56fd305472" }, + "markdown": { + "Package": "markdown", + "Version": "1.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "commonmark", + "utils", + "xfun" + ], + "Hash": "765cf53992401b3b6c297b69e1edb8bd" + }, "mathjaxr": { "Package": "mathjaxr", "Version": "1.6-0", @@ -623,6 +1385,16 @@ "Repository": "RSPM", "Hash": "87da6ccdcee6077a7d5719406bf3ae45" }, + "matrixStats": { + "Package": "matrixStats", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "33a3ca9e732b57244d14f5d732ffc9eb" + }, "memoise": { "Package": "memoise", "Version": "2.0.1", @@ -634,6 +1406,23 @@ ], "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" }, + "mgcv": { + "Package": "mgcv", + "Version": "1.8-40", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "nlme", + "splines", + "stats", + "utils" + ], + "Hash": "c6b2fdb18cf68ab613bd564363e1ba0d" + }, "mime": { "Package": "mime", "Version": "0.12", @@ -644,6 +1433,147 @@ ], "Hash": "18e9c28c1d3ca1560ce30658b22ce104" }, + "minqa": { + "Package": "minqa", + "Version": "1.2.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp" + ], + "Hash": "f48238f8d4740426ca12f53f27d004dd" + }, + "mitools": { + "Package": "mitools", + "Version": "2.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "methods", + "stats" + ], + "Hash": "a4b659bd0528226724d55034f11ed7cb" + }, + "modelr": { + "Package": "modelr", + "Version": "0.1.11", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "magrittr", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "vctrs" + ], + "Hash": "4f50122dc256b1b6996a4703fecea821" + }, + "modeltools": { + "Package": "modeltools", + "Version": "0.2-23", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "stats", + "stats4" + ], + "Hash": "f5a957c02222589bdf625a67be68b2a9" + }, + "mstate": { + "Package": "mstate", + "Version": "0.3.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "RColorBrewer", + "data.table", + "lattice", + "rlang", + "survival", + "viridisLite" + ], + "Hash": "53ca2f4a1ab4ac93fec33c92dc22c886" + }, + "multcomp": { + "Package": "multcomp", + "Version": "1.4-25", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "TH.data", + "codetools", + "graphics", + "mvtnorm", + "sandwich", + "stats", + "survival" + ], + "Hash": "2688bf2f8d54c19534ee7d8a876d9fc7" + }, + "munsell": { + "Package": "munsell", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "colorspace", + "methods" + ], + "Hash": "6dfe8bf774944bd5595785e3229d8771" + }, + "mvnfast": { + "Package": "mvnfast", + "Version": "0.2.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "BH", + "Rcpp", + "RcppArmadillo" + ], + "Hash": "e65cac8e8501bdfbdca0412c37bb18c9" + }, + "mvtnorm": { + "Package": "mvtnorm", + "Version": "1.2-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats" + ], + "Hash": "17e96668f44a28aef0981d9e17c49b59" + }, + "nlme": { + "Package": "nlme", + "Version": "3.1-157", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "dbca60742be0c9eddc5205e5c7ca1f44" + }, + "numDeriv": { + "Package": "numDeriv", + "Version": "2016.8-1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "df58958f293b166e4ab885ebcad90e02" + }, "openssl": { "Package": "openssl", "Version": "2.1.1", @@ -654,6 +1584,18 @@ ], "Hash": "2a0dc8c6adfb6f032e4d4af82d258ab5" }, + "pbv": { + "Package": "pbv", + "Version": "0.5-47", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "RcppArmadillo" + ], + "Hash": "b0fa64575651e261cfa1fdb46025cb44" + }, "pillar": { "Package": "pillar", "Version": "1.9.0", @@ -757,6 +1699,20 @@ "Repository": "RSPM", "Hash": "09eb987710984fc2905c7129c7d85e65" }, + "plotrix": { + "Package": "plotrix", + "Version": "3.8-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "stats", + "utils" + ], + "Hash": "d47fdfc45aeba360ce9db50643de3fbd" + }, "plumber": { "Package": "plumber", "Version": "1.2.1", @@ -781,6 +1737,17 @@ ], "Hash": "8b65a7a00ef8edc5ddc6fabf0aff1194" }, + "plyr": { + "Package": "plyr", + "Version": "1.8.9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp" + ], + "Hash": "6b8177fd19982f0020743fadbfdbd933" + }, "pool": { "Package": "pool", "Version": "1.0.1", @@ -827,6 +1794,20 @@ ], "Hash": "3efbd8ac1be0296a46c55387aeace0f3" }, + "progress": { + "Package": "progress", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "crayon", + "hms", + "prettyunits" + ], + "Hash": "f4625e061cb2865f111b47ff163a5ca6" + }, "promises": { "Package": "promises", "Version": "1.2.1", @@ -841,7 +1822,19 @@ "rlang", "stats" ], - "Hash": "0d8a15c9d000970ada1ab21405387dee" + "Hash": "0d8a15c9d000970ada1ab21405387dee" + }, + "proxy": { + "Package": "proxy", + "Version": "0.4-27", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "e0ef355c12942cf7a6b91a6cfaea8b3e" }, "ps": { "Package": "ps", @@ -869,6 +1862,18 @@ ], "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" }, + "r2rtf": { + "Package": "r2rtf", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "tools" + ], + "Hash": "807989b4dccfab6440841a5e8aaa95f1" + }, "ragg": { "Package": "ragg", "Version": "1.2.7", @@ -880,6 +1885,31 @@ ], "Hash": "90a1b8b7e518d7f90480d56453b4d062" }, + "randomizeR": { + "Package": "randomizeR", + "Version": "3.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "PwrGSD", + "R", + "coin", + "dplyr", + "ggplot2", + "gsDesign", + "insight", + "magrittr", + "methods", + "mstate", + "mvtnorm", + "plotrix", + "purrr", + "reshape2", + "rlang", + "survival" + ], + "Hash": "d22309ab2b609eb233d4b2e931dad265" + }, "rappdirs": { "Package": "rappdirs", "Version": "0.3.3", @@ -890,6 +1920,76 @@ ], "Hash": "5e3c5dc0b071b21fa128676560dbe94d" }, + "reactR": { + "Package": "reactR", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools" + ], + "Hash": "c9014fd1a435b2d790dd506589cb24e5" + }, + "reactable": { + "Package": "reactable", + "Version": "0.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "digest", + "htmltools", + "htmlwidgets", + "jsonlite", + "reactR" + ], + "Hash": "6069eb2a6597963eae0605c1875ff14c" + }, + "readr": { + "Package": "readr", + "Version": "2.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "clipr", + "cpp11", + "crayon", + "hms", + "lifecycle", + "methods", + "rlang", + "tibble", + "tzdb", + "utils", + "vroom" + ], + "Hash": "b5047343b3825f37ad9d3b5d89aa1078" + }, + "readxl": { + "Package": "readxl", + "Version": "1.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cellranger", + "cpp11", + "progress", + "tibble", + "utils" + ], + "Hash": "8cf9c239b96df1bbb133b74aef77ad0a" + }, + "rematch": { + "Package": "rematch", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "cbff1b666c6fa6d21202f07e2318d4f1" + }, "rematch2": { "Package": "rematch2", "Version": "2.1.2", @@ -910,6 +2010,41 @@ ], "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" }, + "reprex": { + "Package": "reprex", + "Version": "2.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "callr", + "cli", + "clipr", + "fs", + "glue", + "knitr", + "lifecycle", + "rlang", + "rmarkdown", + "rstudioapi", + "utils", + "withr" + ], + "Hash": "d66fe009d4c20b7ab1927eb405db9ee2" + }, + "reshape2": { + "Package": "reshape2", + "Version": "1.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "plyr", + "stringr" + ], + "Hash": "bb5996d0bd962d214a11140d77589917" + }, "rlang": { "Package": "rlang", "Version": "1.1.2", @@ -955,6 +2090,46 @@ ], "Hash": "1de7ab598047a87bba48434ba35d497d" }, + "rstudioapi": { + "Package": "rstudioapi", + "Version": "0.15.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "5564500e25cffad9e22244ced1379887" + }, + "rvest": { + "Package": "rvest", + "Version": "1.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "httr", + "lifecycle", + "magrittr", + "rlang", + "selectr", + "tibble", + "withr", + "xml2" + ], + "Hash": "a4a5ac819a467808c60e36e92ddf195e" + }, + "sandwich": { + "Package": "sandwich", + "Version": "3.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils", + "zoo" + ], + "Hash": "1cf6ae532f0179350862fefeb0987c9b" + }, "sass": { "Package": "sass", "Version": "0.4.8", @@ -969,6 +2144,57 @@ ], "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" }, + "scales": { + "Package": "scales", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "RColorBrewer", + "cli", + "farver", + "glue", + "labeling", + "lifecycle", + "munsell", + "rlang", + "viridisLite" + ], + "Hash": "c19df082ba346b0ffa6f833e92de34d1" + }, + "selectr": { + "Package": "selectr", + "Version": "0.4-2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "methods", + "stringr" + ], + "Hash": "3838071b66e0c566d55cc26bd6e27bf4" + }, + "simstudy": { + "Package": "simstudy", + "Version": "0.7.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "backports", + "data.table", + "fastglm", + "glue", + "methods", + "mvnfast", + "pbv" + ], + "Hash": "deb66424ac81e3aa78066791e0e6b97f" + }, "sodium": { "Package": "sodium", "Version": "1.3.0", @@ -1006,6 +2232,43 @@ ], "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" }, + "survey": { + "Package": "survey", + "Version": "4.2-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Matrix", + "R", + "graphics", + "grid", + "lattice", + "methods", + "minqa", + "mitools", + "numDeriv", + "splines", + "stats", + "survival" + ], + "Hash": "03195177db81a992f22361f8f54852f4" + }, + "survival": { + "Package": "survival", + "Version": "3.3-1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "splines", + "stats", + "utils" + ], + "Hash": "f6189c70451d3d68e0d571235576e833" + }, "swagger": { "Package": "swagger", "Version": "3.33.1", @@ -1031,6 +2294,22 @@ ], "Hash": "15b594369e70b975ba9f064295983499" }, + "tableone": { + "Package": "tableone", + "Version": "0.13.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "e1071", + "gmodels", + "labelled", + "nlme", + "survey", + "zoo" + ], + "Hash": "b1a77da61a4c3585987241b8a1cc6b95" + }, "testthat": { "Package": "testthat", "Version": "3.2.1", @@ -1130,6 +2409,46 @@ ], "Hash": "79540e5fcd9e0435af547d885f184fd5" }, + "tidyverse": { + "Package": "tidyverse", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "cli", + "conflicted", + "dbplyr", + "dplyr", + "dtplyr", + "forcats", + "ggplot2", + "googledrive", + "googlesheets4", + "haven", + "hms", + "httr", + "jsonlite", + "lubridate", + "magrittr", + "modelr", + "pillar", + "purrr", + "ragg", + "readr", + "readxl", + "reprex", + "rlang", + "rstudioapi", + "rvest", + "stringr", + "tibble", + "tidyr", + "xml2" + ], + "Hash": "c328568cd14ea89a83bd4ca7f54ae07e" + }, "timechange": { "Package": "timechange", "Version": "0.2.0", @@ -1151,6 +2470,27 @@ ], "Hash": "5ac22900ae0f386e54f1c307eca7d843" }, + "truncnorm": { + "Package": "truncnorm", + "Version": "1.0-9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ef5b32c5194351ff409dfb37ca9468f1" + }, + "tzdb": { + "Package": "tzdb", + "Version": "0.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "f561504ec2897f4d46f0c7657e488ae1" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", @@ -1161,6 +2501,16 @@ ], "Hash": "1fe17157424bb09c48a8b3b550c753bc" }, + "uuid": { + "Package": "uuid", + "Version": "1.1-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "3d78edfb977a69fc7a0341bee25e163f" + }, "vctrs": { "Package": "vctrs", "Version": "0.6.4", @@ -1175,6 +2525,42 @@ ], "Hash": "266c1ca411266ba8f365fcc726444b87" }, + "viridisLite": { + "Package": "viridisLite", + "Version": "0.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "c826c7c4241b6fc89ff55aaea3fa7491" + }, + "vroom": { + "Package": "vroom", + "Version": "1.6.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bit64", + "cli", + "cpp11", + "crayon", + "glue", + "hms", + "lifecycle", + "methods", + "progress", + "rlang", + "stats", + "tibble", + "tidyselect", + "tzdb", + "vctrs", + "withr" + ], + "Hash": "390f9315bc0025be03012054103d227c" + }, "waldo": { "Package": "waldo", "Version": "0.5.1", @@ -1247,12 +2633,39 @@ ], "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" }, + "xtable": { + "Package": "xtable", + "Version": "1.8-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "b8acdf8af494d9ec19ccb2481a9b11c2" + }, "yaml": { "Package": "yaml", "Version": "2.3.8", "Source": "Repository", "Repository": "RSPM", "Hash": "29240487a071f535f5e5d5a323b7afbd" + }, + "zoo": { + "Package": "zoo", + "Version": "1.8-12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "5c715954112b45499fb1dadc6ee6ee3e" } } } diff --git a/vignettes/.gitignore b/vignettes/.gitignore new file mode 100644 index 0000000..097b241 --- /dev/null +++ b/vignettes/.gitignore @@ -0,0 +1,2 @@ +*.html +*.R diff --git a/vignettes/references.bib b/vignettes/references.bib new file mode 100644 index 0000000..39abe6a --- /dev/null +++ b/vignettes/references.bib @@ -0,0 +1,191 @@ +% Encoding: UTF-8 + +@article{lim2019randomization, + title={Randomization in clinical studies}, + author={Lim, Chi-Yeon and In, Junyong}, + journal={Korean journal of anesthesiology}, + volume={72}, + number={3}, + pages={221--232}, + year={2019}, + publisher={Korean Society of Anesthesiologists} +} + + @article{goldfeld2020simstudy, + title = {simstudy: Illuminating research methods through data generation}, + author = {Keith Goldfeld and Jacob Wujciak-Jens}, + publisher = {The Open Journal}, + journal = {Journal of Open Source Software}, + year = {2020}, + volume = {5}, + number = {54}, + pages = {2763}, + url = {https://doi.org/10.21105/joss.02763}, + doi = {10.21105/joss.02763}, + } + + @article{mrozikiewicz2023allogenic, + title={Allogenic Adipose-Derived Stem Cells in Diabetic Foot Ulcer Treatment: Clinical Effectiveness, Safety, Survival in the Wound Site, and Proteomic Impact}, + author={Mrozikiewicz-Rakowska, Beata and Szab{\l}owska-Gadomska, Ilona and Cysewski, Dominik and Rudzi{\'n}ski, Stefan and P{\l}oski, Rafa{\l} and Gasperowicz, Piotr and Konarzewska, Magdalena and Zieli{\'n}ski, Jakub and Mieczkowski, Mateusz and Sie{\'n}ko, Damian and others}, + journal={International Journal of Molecular Sciences}, + volume={24}, + number={2}, + pages={1472}, + year={2023}, + publisher={MDPI} +} + +@article{pocock1975sequential, + title={Sequential treatment assignment with balancing for prognostic factors in the controlled clinical trial}, + author={Pocock, Stuart J and Simon, Richard}, + journal={Biometrics}, + pages={103--115}, + year={1975}, + publisher={JSTOR} +} + +@book{rosenberger2015randomization, + title={Randomization in clinical trials: theory and practice}, + author={Rosenberger, William F and Lachin, John M}, + year={2015}, + publisher={John Wiley \& Sons} +} + +@article{lee2021estimating, + title={Estimating COVID-19 infection and severity risks in patients with chronic rhinosinusitis: a Korean nationwide cohort study}, + author={Lee, Seung Won and Kim, So Young and Moon, Sung Yong and Yang, Jee Myung and Ha, Eun Kyo and Jee, Hye Mi and Shin, Jae Il and Cho, Seong Ho and Yon, Dong Keon and Suh, Dong In}, + journal={The Journal of Allergy and Clinical Immunology: In Practice}, + volume={9}, + number={6}, + pages={2262--2271}, + year={2021}, + publisher={Elsevier} +} + +@article{austin2009balance, + title={Balance diagnostics for comparing the distribution of baseline covariates between treatment groups in propensity-score matched samples}, + author={Austin, Peter C}, + journal={Statistics in medicine}, + volume={28}, + number={25}, + pages={3083--3107}, + year={2009}, + publisher={Wiley Online Library} +} + +@article{doah2021impact, + title={The impact of primary tumor resection on survival in asymptomatic colorectal cancer patients with unresectable metastases}, + author={Doah, Ki Yoon and Shin, Ui Sup and Jeon, Byong Ho and Cho, Sang Sik and Moon, Sun Mi}, + journal={Annals of Coloproctology}, + volume={37}, + number={2}, + pages={94}, + year={2021}, + publisher={Korean Society of Coloproctology} +} + +@article{brown2020novel, + title={A novel approach for propensity score matching and stratification for multiple treatments: Application to an electronic health record--derived study}, + author={Brown, Derek W and DeSantis, Stacia M and Greene, Thomas J and Maroufy, Vahed and Yaseen, Ashraf and Wu, Hulin and Williams, George and Swartz, Michael D}, + journal={Statistics in medicine}, + volume={39}, + number={17}, + pages={2308--2323}, + year={2020}, + publisher={Wiley Online Library} +} + +@article{nguyen2017double, + title={Double-adjustment in propensity score matching analysis: choosing a threshold for considering residual imbalance}, + author={Nguyen, Tri-Long and Collins, Gary S and Spence, Jessica and Daur{\`e}s, Jean-Pierre and Devereaux, PJ and Landais, Paul and Le Manach, Yannick}, + journal={BMC medical research methodology}, + volume={17}, + pages={1--8}, + year={2017}, + publisher={Springer} +} + +@article{sanchez2003effect, + title={Effect-size indices for dichotomized outcomes in meta-analysis.}, + author={S{\'a}nchez-Meca, Julio and Mar{\'\i}n-Mart{\'\i}nez, Fulgencio and Chac{\'o}n-Moscoso, Salvador}, + journal={Psychological methods}, + volume={8}, + number={4}, + pages={448}, + year={2003}, + publisher={American Psychological Association} +} + +@article{lee2022propensity, + title={Propensity score matching for causal inference and reducing the confounding effects: statistical standard and guideline of Life Cycle Committee}, + author={Lee, Seung Won and Acharya, Krishna Prasad and others}, + journal={Life Cycle}, + volume={2}, + year={2022}, + publisher={Life Cycle} +} + +@article{zhang2019balance, + title={Balance diagnostics after propensity score matching}, + author={Zhang, Zhongheng and Kim, Hwa Jung and Lonjon, Guillaume and Zhu, Yibing and others}, + journal={Annals of translational medicine}, + volume={7}, + number={1}, + year={2019}, + publisher={AME Publications} +} + + @Manual{truncnorm, + title = {truncnorm: Truncated Normal Distribution}, + author = {Olaf Mersmann and Heike Trautmann and Detlef Steuer and Björn Bornkamp}, + year = {2023}, + note = {R package version 1.0-9}, + url = {https://github.com/olafmersmann/truncnorm}, + } + +@article{burkardt2014truncated, + title={The truncated normal distribution}, + author={Burkardt, John}, + journal={Department of Scientific Computing Website, Florida State University}, + volume={1}, + pages={35}, + year={2014} +} + +@article{hodel2023beta, + title={The Beta-Binomial Distribution}, + author={Hodel, Florian and Booth, James}, + year={2023} +} + + @Manual{tableone, + title = {tableone: Create 'Table 1' to Describe Baseline Characteristics with or +without Propensity Score Weights}, + author = {Kazuki Yoshida and Alexander Bartel}, + year = {2022}, + note = {R package version 0.13.2}, + url = {https://github.com/kaz-yos/tableone}, + } + @article{randomizeR, + title = {{randomizeR}: An {R} Package for the Assessment and Implementation of Randomization in Clinical Trials}, + author = {Diane Uschner and David Schindler and Ralf-Dieter Hilgers and Nicole Heussen}, + journal = {Journal of Statistical Software}, + year = {2018}, + volume = {85}, + number = {8}, + pages = {1--22}, + doi = {10.18637/jss.v085.i08}, + } + + + @article{gtsummary, + author = {Daniel D. Sjoberg and Karissa Whiting and Michael Curry and Jessica A. Lavery and Joseph Larmarange}, + title = {Reproducible Summary Tables with the gtsummary Package}, + journal = {{The R Journal}}, + year = {2021}, + url = {https://doi.org/10.32614/RJ-2021-053}, + doi = {10.32614/RJ-2021-053}, + volume = {13}, + issue = {1}, + pages = {570-580}, + } diff --git a/vignettes/renv.lock b/vignettes/renv.lock new file mode 100644 index 0000000..b7f6c22 --- /dev/null +++ b/vignettes/renv.lock @@ -0,0 +1,2959 @@ +{ + "R": { + "Version": "4.2.1", + "Repositories": [ + { + "Name": "CRAN", + "URL": "https://packagemanager.posit.co/cran/latest" + } + ] + }, + "Packages": { + "BH": { + "Package": "BH", + "Version": "1.81.0-1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "68122010f01c4dcfbe58ce7112f2433d" + }, + "DBI": { + "Package": "DBI", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "3e0051431dff9acfe66c23765e55c556" + }, + "MASS": { + "Package": "MASS", + "Version": "7.3-57", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "71476c1d88d1ebdf31580e5a257d5d31" + }, + "Matrix": { + "Package": "Matrix", + "Version": "1.4-1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "grid", + "lattice", + "methods", + "stats", + "utils" + ], + "Hash": "699c47c606293bdfbc9fd78a93c9c8fe" + }, + "PwrGSD": { + "Package": "PwrGSD", + "Version": "2.3.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "survival" + ], + "Hash": "c26126e59b9b078953521379ee219a05" + }, + "R6": { + "Package": "R6", + "Version": "2.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "470851b6d5d0ac559e9d01bb352b4021" + }, + "RColorBrewer": { + "Package": "RColorBrewer", + "Version": "1.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "45f0398006e83a5b10b72a90663d8d8c" + }, + "Rcpp": { + "Package": "Rcpp", + "Version": "1.0.11", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "utils" + ], + "Hash": "ae6cbbe1492f4de79c45fce06f967ce8" + }, + "RcppArmadillo": { + "Package": "RcppArmadillo", + "Version": "0.12.6.6.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "methods", + "stats", + "utils" + ], + "Hash": "d2b60e0a15d73182a3a766ff0a7d0d7f" + }, + "RcppEigen": { + "Package": "RcppEigen", + "Version": "0.3.3.9.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "stats", + "utils" + ], + "Hash": "acb0a5bf38490f26ab8661b467f4f53a" + }, + "TH.data": { + "Package": "TH.data", + "Version": "1.1-2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "survival" + ], + "Hash": "5b250ad4c5863ee4a68e280fcb0a3600" + }, + "V8": { + "Package": "V8", + "Version": "4.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp", + "curl", + "jsonlite", + "utils" + ], + "Hash": "435359b59b8a9b8f9235135da471ea3c" + }, + "askpass": { + "Package": "askpass", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "sys" + ], + "Hash": "cad6cf7f1d5f6e906700b9d3e718c796" + }, + "backports": { + "Package": "backports", + "Version": "1.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "c39fbec8a30d23e721980b8afb31984c" + }, + "base64enc": { + "Package": "base64enc", + "Version": "0.1-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "543776ae6848fde2f48ff3816d0628bc" + }, + "bigD": { + "Package": "bigD", + "Version": "0.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "93637e906f3fe962413912c956eb44db" + }, + "bigmemory": { + "Package": "bigmemory", + "Version": "4.6.2", + "Source": "GitHub", + "RemoteType": "github", + "RemoteHost": "api.github.com", + "RemoteRepo": "bigmemory", + "RemoteUsername": "kaneplusplus", + "RemoteRef": "HEAD", + "RemoteSha": "3064277f4a83b74490464ea4ac5a43f76e426ada", + "Requirements": [ + "BH", + "R", + "Rcpp", + "bigmemory.sri", + "methods", + "utils", + "uuid" + ], + "Hash": "65fe01c6e8e22c8bd0c6f5b5e3ccf19e" + }, + "bigmemory.sri": { + "Package": "bigmemory.sri", + "Version": "0.1.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods" + ], + "Hash": "cd3e474a907284c598e60417a5edeb79" + }, + "bit": { + "Package": "bit", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "d242abec29412ce988848d0294b208fd" + }, + "bit64": { + "Package": "bit64", + "Version": "4.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bit", + "methods", + "stats", + "utils" + ], + "Hash": "9fe98599ca456d6552421db0d6772d8f" + }, + "bitops": { + "Package": "bitops", + "Version": "1.0-7", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "b7d8d8ee39869c18d8846a184dd8a1af" + }, + "blob": { + "Package": "blob", + "Version": "1.2.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "rlang", + "vctrs" + ], + "Hash": "40415719b5a479b87949f3aa0aee737c" + }, + "brew": { + "Package": "brew", + "Version": "1.0-10", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8f4a384e19dccd8c65356dc096847b76" + }, + "brio": { + "Package": "brio", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "976cf154dfb043c012d87cddd8bca363" + }, + "broom": { + "Package": "broom", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "backports", + "dplyr", + "ellipsis", + "generics", + "glue", + "lifecycle", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyr" + ], + "Hash": "fd25391c3c4f6ecf0fa95f1e6d15378c" + }, + "broom.helpers": { + "Package": "broom.helpers", + "Version": "1.14.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "cli", + "dplyr", + "labelled", + "lifecycle", + "purrr", + "rlang", + "stats", + "stringr", + "tibble", + "tidyr" + ], + "Hash": "ea30eb5d9412a4a5c2740685f680cd49" + }, + "bslib": { + "Package": "bslib", + "Version": "0.6.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "cachem", + "grDevices", + "htmltools", + "jquerylib", + "jsonlite", + "lifecycle", + "memoise", + "mime", + "rlang", + "sass" + ], + "Hash": "c0d8599494bc7fb408cd206bbdd9cab0" + }, + "cachem": { + "Package": "cachem", + "Version": "1.0.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "fastmap", + "rlang" + ], + "Hash": "c35768291560ce302c0a6589f92e837d" + }, + "callr": { + "Package": "callr", + "Version": "3.7.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "processx", + "utils" + ], + "Hash": "9b2191ede20fa29828139b9900922e51" + }, + "cellranger": { + "Package": "cellranger", + "Version": "1.1.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "rematch", + "tibble" + ], + "Hash": "f61dbaec772ccd2e17705c1e872e9e7c" + }, + "checkmate": { + "Package": "checkmate", + "Version": "2.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "backports", + "utils" + ], + "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" + }, + "class": { + "Package": "class", + "Version": "7.3-20", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "MASS", + "R", + "stats", + "utils" + ], + "Hash": "da09d82223e669d270e47ed24ac8686e" + }, + "cli": { + "Package": "cli", + "Version": "3.6.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "89e6d8219950eac806ae0c489052048a" + }, + "clipr": { + "Package": "clipr", + "Version": "0.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" + }, + "codetools": { + "Package": "codetools", + "Version": "0.2-18", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "019388fc48e48b3da0d3a76ff94608a8" + }, + "coin": { + "Package": "coin", + "Version": "1.4-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "libcoin", + "matrixStats", + "methods", + "modeltools", + "multcomp", + "mvtnorm", + "parallel", + "stats", + "stats4", + "survival", + "utils" + ], + "Hash": "4084b5070a40ad99dad581ed3b67bd55" + }, + "colorspace": { + "Package": "colorspace", + "Version": "2.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats" + ], + "Hash": "f20c47fd52fae58b4e377c37bb8c335b" + }, + "commonmark": { + "Package": "commonmark", + "Version": "1.9.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "d691c61bff84bd63c383874d2d0c3307" + }, + "conflicted": { + "Package": "conflicted", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "memoise", + "rlang" + ], + "Hash": "bb097fccb22d156624fd07cd2894ddb6" + }, + "cpp11": { + "Package": "cpp11", + "Version": "0.4.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "707fae4bbf73697ec8d85f9d7076c061" + }, + "crayon": { + "Package": "crayon", + "Version": "1.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "methods", + "utils" + ], + "Hash": "e8a1e41acf02548751f45c718d55aa6a" + }, + "credentials": { + "Package": "credentials", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "curl", + "jsonlite", + "openssl", + "sys" + ], + "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" + }, + "curl": { + "Package": "curl", + "Version": "5.1.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" + }, + "data.table": { + "Package": "data.table", + "Version": "1.14.10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "6ea17a32294d8ca00455825ab0cf71b9" + }, + "dbplyr": { + "Package": "dbplyr", + "Version": "2.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "R", + "R6", + "blob", + "cli", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "utils", + "vctrs", + "withr" + ], + "Hash": "59351f28a81f0742720b85363c4fdd61" + }, + "desc": { + "Package": "desc", + "Version": "1.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "rprojroot", + "utils" + ], + "Hash": "6b9602c7ebbe87101a9c8edb6e8b6d21" + }, + "devtools": { + "Package": "devtools", + "Version": "2.4.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "desc", + "ellipsis", + "fs", + "lifecycle", + "memoise", + "miniUI", + "pkgbuild", + "pkgdown", + "pkgload", + "profvis", + "rcmdcheck", + "remotes", + "rlang", + "roxygen2", + "rversions", + "sessioninfo", + "stats", + "testthat", + "tools", + "urlchecker", + "usethis", + "utils", + "withr" + ], + "Hash": "ea5bc8b4a6a01e4f12d98b58329930bb" + }, + "diffobj": { + "Package": "diffobj", + "Version": "0.3.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "crayon", + "methods", + "stats", + "tools", + "utils" + ], + "Hash": "bcaa8b95f8d7d01a5dedfd959ce88ab8" + }, + "digest": { + "Package": "digest", + "Version": "0.6.33", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" + }, + "downlit": { + "Package": "downlit", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "brio", + "desc", + "digest", + "evaluate", + "fansi", + "memoise", + "rlang", + "vctrs", + "withr", + "yaml" + ], + "Hash": "14fa1f248b60ed67e1f5418391a17b14" + }, + "dplyr": { + "Package": "dplyr", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "generics", + "glue", + "lifecycle", + "magrittr", + "methods", + "pillar", + "rlang", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" + }, + "dtplyr": { + "Package": "dtplyr", + "Version": "1.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "data.table", + "dplyr", + "glue", + "lifecycle", + "rlang", + "tibble", + "tidyselect", + "vctrs" + ], + "Hash": "54ed3ea01b11e81a86544faaecfef8e2" + }, + "e1071": { + "Package": "e1071", + "Version": "1.7-14", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "class", + "grDevices", + "graphics", + "methods", + "proxy", + "stats", + "utils" + ], + "Hash": "4ef372b716824753719a8a38b258442d" + }, + "ellipsis": { + "Package": "ellipsis", + "Version": "0.3.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "rlang" + ], + "Hash": "bb0eec2fe32e88d9e2836c2f73ea2077" + }, + "evaluate": { + "Package": "evaluate", + "Version": "0.22", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "66f39c7a21e03c4dcb2c2d21d738d603" + }, + "fansi": { + "Package": "fansi", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "utils" + ], + "Hash": "3e8583a60163b4bc1a80016e63b9959e" + }, + "farver": { + "Package": "farver", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8106d78941f34855c440ddb946b8f7a5" + }, + "fastglm": { + "Package": "fastglm", + "Version": "0.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "BH", + "Rcpp", + "RcppEigen", + "bigmemory", + "methods" + ], + "Hash": "e0f222ad320efdaa48ebf88eb576bb21" + }, + "fastmap": { + "Package": "fastmap", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "f7736a18de97dea803bde0a2daaafb27" + }, + "fontawesome": { + "Package": "fontawesome", + "Version": "0.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmltools", + "rlang" + ], + "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" + }, + "forcats": { + "Package": "forcats", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "tibble" + ], + "Hash": "1a0a9a3d5083d0d573c4214576f1e690" + }, + "fs": { + "Package": "fs", + "Version": "1.6.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "47b5f30c720c23999b913a1a635cf0bb" + }, + "gargle": { + "Package": "gargle", + "Version": "1.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "fs", + "glue", + "httr", + "jsonlite", + "lifecycle", + "openssl", + "rappdirs", + "rlang", + "stats", + "utils", + "withr" + ], + "Hash": "fc0b272e5847c58cd5da9b20eedbd026" + }, + "gdata": { + "Package": "gdata", + "Version": "3.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "gtools", + "methods", + "stats", + "utils" + ], + "Hash": "d3d6e4c174b8a5f251fd273f245f2471" + }, + "generics": { + "Package": "generics", + "Version": "0.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "15e9634c0fcd294799e9b2e929ed1b86" + }, + "gert": { + "Package": "gert", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "credentials", + "openssl", + "rstudioapi", + "sys", + "zip" + ], + "Hash": "f70d3fe2d9e7654213a946963d1591eb" + }, + "ggplot2": { + "Package": "ggplot2", + "Version": "3.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "cli", + "glue", + "grDevices", + "grid", + "gtable", + "isoband", + "lifecycle", + "mgcv", + "rlang", + "scales", + "stats", + "tibble", + "vctrs", + "withr" + ], + "Hash": "313d31eff2274ecf4c1d3581db7241f9" + }, + "gh": { + "Package": "gh", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gitcreds", + "httr2", + "ini", + "jsonlite", + "rlang" + ], + "Hash": "03533b1c875028233598f848fda44c4c" + }, + "gitcreds": { + "Package": "gitcreds", + "Version": "0.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ab08ac61f3e1be454ae21911eb8bc2fe" + }, + "glue": { + "Package": "glue", + "Version": "1.6.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods" + ], + "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" + }, + "gmodels": { + "Package": "gmodels", + "Version": "2.18.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "R", + "gdata" + ], + "Hash": "6713a242cb6909e492d8169a35dfe0b0" + }, + "googledrive": { + "Package": "googledrive", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gargle", + "glue", + "httr", + "jsonlite", + "lifecycle", + "magrittr", + "pillar", + "purrr", + "rlang", + "tibble", + "utils", + "uuid", + "vctrs", + "withr" + ], + "Hash": "e99641edef03e2a5e87f0a0b1fcc97f4" + }, + "googlesheets4": { + "Package": "googlesheets4", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cellranger", + "cli", + "curl", + "gargle", + "glue", + "googledrive", + "httr", + "ids", + "lifecycle", + "magrittr", + "methods", + "purrr", + "rematch2", + "rlang", + "tibble", + "utils", + "vctrs", + "withr" + ], + "Hash": "d6db1667059d027da730decdc214b959" + }, + "gsDesign": { + "Package": "gsDesign", + "Version": "3.6.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "dplyr", + "ggplot2", + "graphics", + "gt", + "magrittr", + "methods", + "r2rtf", + "rlang", + "stats", + "tibble", + "tidyr", + "tools", + "xtable" + ], + "Hash": "496b38bfc6524e1a1fc04220da550892" + }, + "gt": { + "Package": "gt", + "Version": "0.10.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "bigD", + "bitops", + "cli", + "commonmark", + "dplyr", + "fs", + "glue", + "htmltools", + "htmlwidgets", + "juicyjuice", + "magrittr", + "markdown", + "reactable", + "rlang", + "sass", + "scales", + "tibble", + "tidyselect", + "xml2" + ], + "Hash": "21737c74811cccac01b5097bcb0f8b4c" + }, + "gtable": { + "Package": "gtable", + "Version": "0.3.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "grid", + "lifecycle", + "rlang" + ], + "Hash": "b29cf3031f49b04ab9c852c912547eef" + }, + "gtools": { + "Package": "gtools", + "Version": "3.9.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "stats", + "utils" + ], + "Hash": "588d091c35389f1f4a9d533c8d709b35" + }, + "gtsummary": { + "Package": "gtsummary", + "Version": "1.7.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "broom.helpers", + "cli", + "dplyr", + "forcats", + "glue", + "gt", + "knitr", + "lifecycle", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyr", + "vctrs" + ], + "Hash": "08df7405a102e3f0bdf7a13a29e8c6ab" + }, + "haven": { + "Package": "haven", + "Version": "2.5.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "cpp11", + "forcats", + "hms", + "lifecycle", + "methods", + "readr", + "rlang", + "tibble", + "tidyselect", + "vctrs" + ], + "Hash": "9171f898db9d9c4c1b2c745adc2c1ef1" + }, + "highr": { + "Package": "highr", + "Version": "0.10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "xfun" + ], + "Hash": "06230136b2d2b9ba5805e1963fa6e890" + }, + "hms": { + "Package": "hms", + "Version": "1.1.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "lifecycle", + "methods", + "pkgconfig", + "rlang", + "vctrs" + ], + "Hash": "b59377caa7ed00fa41808342002138f9" + }, + "htmltools": { + "Package": "htmltools", + "Version": "0.5.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "base64enc", + "digest", + "ellipsis", + "fastmap", + "grDevices", + "rlang", + "utils" + ], + "Hash": "2d7b3857980e0e0d0a1fd6f11928ab0f" + }, + "htmlwidgets": { + "Package": "htmlwidgets", + "Version": "1.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "htmltools", + "jsonlite", + "knitr", + "rmarkdown", + "yaml" + ], + "Hash": "04291cc45198225444a397606810ac37" + }, + "httpuv": { + "Package": "httpuv", + "Version": "1.6.11", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "Rcpp", + "later", + "promises", + "utils" + ], + "Hash": "838602f54e32c1a0f8cc80708cefcefa" + }, + "httr": { + "Package": "httr", + "Version": "1.4.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "curl", + "jsonlite", + "mime", + "openssl" + ], + "Hash": "ac107251d9d9fd72f0ca8049988f1d7f" + }, + "httr2": { + "Package": "httr2", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "curl", + "glue", + "lifecycle", + "magrittr", + "openssl", + "rappdirs", + "rlang", + "vctrs", + "withr" + ], + "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" + }, + "ids": { + "Package": "ids", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "openssl", + "uuid" + ], + "Hash": "99df65cfef20e525ed38c3d2577f7190" + }, + "ini": { + "Package": "ini", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "6154ec2223172bce8162d4153cda21f7" + }, + "insight": { + "Package": "insight", + "Version": "0.19.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "utils" + ], + "Hash": "750aba9b42391da33ac290b71a749023" + }, + "isoband": { + "Package": "isoband", + "Version": "0.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grid", + "utils" + ], + "Hash": "0080607b4a1a7b28979aecef976d8bc2" + }, + "jquerylib": { + "Package": "jquerylib", + "Version": "0.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools" + ], + "Hash": "5aab57a3bd297eee1c1d862735972182" + }, + "jsonlite": { + "Package": "jsonlite", + "Version": "1.8.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods" + ], + "Hash": "e1b9c55281c5adc4dd113652d9e26768" + }, + "juicyjuice": { + "Package": "juicyjuice", + "Version": "0.1.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "V8" + ], + "Hash": "3bcd11943da509341838da9399e18bce" + }, + "knitr": { + "Package": "knitr", + "Version": "1.45", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "evaluate", + "highr", + "methods", + "tools", + "xfun", + "yaml" + ], + "Hash": "1ec462871063897135c1bcbe0fc8f07d" + }, + "labeling": { + "Package": "labeling", + "Version": "0.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "graphics", + "stats" + ], + "Hash": "b64ec208ac5bc1852b285f665d6368b3" + }, + "labelled": { + "Package": "labelled", + "Version": "2.12.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "dplyr", + "haven", + "lifecycle", + "rlang", + "stringr", + "tidyr", + "vctrs" + ], + "Hash": "1ec27c624ece6c20431e9249bd232797" + }, + "later": { + "Package": "later", + "Version": "1.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp", + "rlang" + ], + "Hash": "40401c9cf2bc2259dfe83311c9384710" + }, + "lattice": { + "Package": "lattice", + "Version": "0.20-45", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "stats", + "utils" + ], + "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" + }, + "libcoin": { + "Package": "libcoin", + "Version": "1.0-10", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "mvtnorm", + "stats" + ], + "Hash": "3f3775a14588ff5d013e5eab4453bf28" + }, + "lifecycle": { + "Package": "lifecycle", + "Version": "1.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "rlang" + ], + "Hash": "001cecbeac1cff9301bdc3775ee46a86" + }, + "lubridate": { + "Package": "lubridate", + "Version": "1.9.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "generics", + "methods", + "timechange" + ], + "Hash": "680ad542fbcf801442c83a6ac5a2126c" + }, + "magrittr": { + "Package": "magrittr", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "7ce2733a9826b3aeb1775d56fd305472" + }, + "markdown": { + "Package": "markdown", + "Version": "1.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "commonmark", + "utils", + "xfun" + ], + "Hash": "765cf53992401b3b6c297b69e1edb8bd" + }, + "mathjaxr": { + "Package": "mathjaxr", + "Version": "1.6-0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "87da6ccdcee6077a7d5719406bf3ae45" + }, + "matrixStats": { + "Package": "matrixStats", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "33a3ca9e732b57244d14f5d732ffc9eb" + }, + "memoise": { + "Package": "memoise", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cachem", + "rlang" + ], + "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" + }, + "mgcv": { + "Package": "mgcv", + "Version": "1.8-40", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "nlme", + "splines", + "stats", + "utils" + ], + "Hash": "c6b2fdb18cf68ab613bd564363e1ba0d" + }, + "mime": { + "Package": "mime", + "Version": "0.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "tools" + ], + "Hash": "18e9c28c1d3ca1560ce30658b22ce104" + }, + "miniUI": { + "Package": "miniUI", + "Version": "0.1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools", + "shiny", + "utils" + ], + "Hash": "fec5f52652d60615fdb3957b3d74324a" + }, + "minqa": { + "Package": "minqa", + "Version": "1.2.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp" + ], + "Hash": "f48238f8d4740426ca12f53f27d004dd" + }, + "mitools": { + "Package": "mitools", + "Version": "2.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "methods", + "stats" + ], + "Hash": "a4b659bd0528226724d55034f11ed7cb" + }, + "modelr": { + "Package": "modelr", + "Version": "0.1.11", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "magrittr", + "purrr", + "rlang", + "tibble", + "tidyr", + "tidyselect", + "vctrs" + ], + "Hash": "4f50122dc256b1b6996a4703fecea821" + }, + "modeltools": { + "Package": "modeltools", + "Version": "0.2-23", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "stats", + "stats4" + ], + "Hash": "f5a957c02222589bdf625a67be68b2a9" + }, + "mstate": { + "Package": "mstate", + "Version": "0.3.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "RColorBrewer", + "data.table", + "lattice", + "rlang", + "survival", + "viridisLite" + ], + "Hash": "53ca2f4a1ab4ac93fec33c92dc22c886" + }, + "multcomp": { + "Package": "multcomp", + "Version": "1.4-25", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "TH.data", + "codetools", + "graphics", + "mvtnorm", + "sandwich", + "stats", + "survival" + ], + "Hash": "2688bf2f8d54c19534ee7d8a876d9fc7" + }, + "munsell": { + "Package": "munsell", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "colorspace", + "methods" + ], + "Hash": "6dfe8bf774944bd5595785e3229d8771" + }, + "mvnfast": { + "Package": "mvnfast", + "Version": "0.2.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "BH", + "Rcpp", + "RcppArmadillo" + ], + "Hash": "e65cac8e8501bdfbdca0412c37bb18c9" + }, + "mvtnorm": { + "Package": "mvtnorm", + "Version": "1.2-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats" + ], + "Hash": "17e96668f44a28aef0981d9e17c49b59" + }, + "nlme": { + "Package": "nlme", + "Version": "3.1-157", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "dbca60742be0c9eddc5205e5c7ca1f44" + }, + "numDeriv": { + "Package": "numDeriv", + "Version": "2016.8-1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "df58958f293b166e4ab885ebcad90e02" + }, + "openssl": { + "Package": "openssl", + "Version": "2.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass" + ], + "Hash": "2a0dc8c6adfb6f032e4d4af82d258ab5" + }, + "pbv": { + "Package": "pbv", + "Version": "0.5-47", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "RcppArmadillo" + ], + "Hash": "b0fa64575651e261cfa1fdb46025cb44" + }, + "pillar": { + "Package": "pillar", + "Version": "1.9.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cli", + "fansi", + "glue", + "lifecycle", + "rlang", + "utf8", + "utils", + "vctrs" + ], + "Hash": "15da5a8412f317beeee6175fbc76f4bb" + }, + "pkgbuild": { + "Package": "pkgbuild", + "Version": "1.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "callr", + "cli", + "crayon", + "desc", + "prettyunits", + "processx", + "rprojroot" + ], + "Hash": "beb25b32a957a22a5c301a9e441190b3" + }, + "pkgconfig": { + "Package": "pkgconfig", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "01f28d4278f15c76cddbea05899c5d6f" + }, + "pkgdown": { + "Package": "pkgdown", + "Version": "2.0.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bslib", + "callr", + "cli", + "desc", + "digest", + "downlit", + "fs", + "httr", + "jsonlite", + "magrittr", + "memoise", + "purrr", + "ragg", + "rlang", + "rmarkdown", + "tibble", + "whisker", + "withr", + "xml2", + "yaml" + ], + "Hash": "16fa15449c930bf3a7761d3c68f8abf9" + }, + "pkgload": { + "Package": "pkgload", + "Version": "1.3.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "crayon", + "desc", + "fs", + "glue", + "methods", + "pkgbuild", + "rlang", + "rprojroot", + "utils", + "withr" + ], + "Hash": "903d68319ae9923fb2e2ee7fa8230b91" + }, + "plotrix": { + "Package": "plotrix", + "Version": "3.8-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "stats", + "utils" + ], + "Hash": "d47fdfc45aeba360ce9db50643de3fbd" + }, + "plumber": { + "Package": "plumber", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "crayon", + "ellipsis", + "httpuv", + "jsonlite", + "lifecycle", + "magrittr", + "mime", + "promises", + "rlang", + "sodium", + "stringi", + "swagger", + "webutils" + ], + "Hash": "8b65a7a00ef8edc5ddc6fabf0aff1194" + }, + "plyr": { + "Package": "plyr", + "Version": "1.8.9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp" + ], + "Hash": "6b8177fd19982f0020743fadbfdbd933" + }, + "praise": { + "Package": "praise", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "a555924add98c99d2f411e37e7d25e9f" + }, + "prettyunits": { + "Package": "prettyunits", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "6b01fc98b1e86c4f705ce9dcfd2f57c7" + }, + "processx": { + "Package": "processx", + "Version": "3.8.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "ps", + "utils" + ], + "Hash": "3efbd8ac1be0296a46c55387aeace0f3" + }, + "profvis": { + "Package": "profvis", + "Version": "0.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmlwidgets", + "purrr", + "rlang", + "stringr", + "vctrs" + ], + "Hash": "aa5a3864397ce6ae03458f98618395a1" + }, + "progress": { + "Package": "progress", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "crayon", + "hms", + "prettyunits" + ], + "Hash": "f4625e061cb2865f111b47ff163a5ca6" + }, + "promises": { + "Package": "promises", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "Rcpp", + "fastmap", + "later", + "magrittr", + "rlang", + "stats" + ], + "Hash": "0d8a15c9d000970ada1ab21405387dee" + }, + "proxy": { + "Package": "proxy", + "Version": "0.4-27", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "e0ef355c12942cf7a6b91a6cfaea8b3e" + }, + "ps": { + "Package": "ps", + "Version": "1.7.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "709d852d33178db54b17c722e5b1e594" + }, + "purrr": { + "Package": "purrr", + "Version": "1.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "lifecycle", + "magrittr", + "rlang", + "vctrs" + ], + "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" + }, + "r2rtf": { + "Package": "r2rtf", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "tools" + ], + "Hash": "807989b4dccfab6440841a5e8aaa95f1" + }, + "ragg": { + "Package": "ragg", + "Version": "1.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "systemfonts", + "textshaping" + ], + "Hash": "90a1b8b7e518d7f90480d56453b4d062" + }, + "randomizeR": { + "Package": "randomizeR", + "Version": "3.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "PwrGSD", + "R", + "coin", + "dplyr", + "ggplot2", + "gsDesign", + "insight", + "magrittr", + "methods", + "mstate", + "mvtnorm", + "plotrix", + "purrr", + "reshape2", + "rlang", + "survival" + ], + "Hash": "d22309ab2b609eb233d4b2e931dad265" + }, + "rappdirs": { + "Package": "rappdirs", + "Version": "0.3.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5e3c5dc0b071b21fa128676560dbe94d" + }, + "rcmdcheck": { + "Package": "rcmdcheck", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "callr", + "cli", + "curl", + "desc", + "digest", + "pkgbuild", + "prettyunits", + "rprojroot", + "sessioninfo", + "utils", + "withr", + "xopen" + ], + "Hash": "8f25ebe2ec38b1f2aef3b0d2ef76f6c4" + }, + "reactR": { + "Package": "reactR", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools" + ], + "Hash": "c9014fd1a435b2d790dd506589cb24e5" + }, + "reactable": { + "Package": "reactable", + "Version": "0.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "digest", + "htmltools", + "htmlwidgets", + "jsonlite", + "reactR" + ], + "Hash": "6069eb2a6597963eae0605c1875ff14c" + }, + "readr": { + "Package": "readr", + "Version": "2.1.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "cli", + "clipr", + "cpp11", + "crayon", + "hms", + "lifecycle", + "methods", + "rlang", + "tibble", + "tzdb", + "utils", + "vroom" + ], + "Hash": "b5047343b3825f37ad9d3b5d89aa1078" + }, + "readxl": { + "Package": "readxl", + "Version": "1.4.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cellranger", + "cpp11", + "progress", + "tibble", + "utils" + ], + "Hash": "8cf9c239b96df1bbb133b74aef77ad0a" + }, + "rematch": { + "Package": "rematch", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "cbff1b666c6fa6d21202f07e2318d4f1" + }, + "rematch2": { + "Package": "rematch2", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "tibble" + ], + "Hash": "76c9e04c712a05848ae7a23d2f170a40" + }, + "remotes": { + "Package": "remotes", + "Version": "2.4.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "tools", + "utils" + ], + "Hash": "63d15047eb239f95160112bcadc4fcb9" + }, + "renv": { + "Package": "renv", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "utils" + ], + "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" + }, + "reprex": { + "Package": "reprex", + "Version": "2.0.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "callr", + "cli", + "clipr", + "fs", + "glue", + "knitr", + "lifecycle", + "rlang", + "rmarkdown", + "rstudioapi", + "utils", + "withr" + ], + "Hash": "d66fe009d4c20b7ab1927eb405db9ee2" + }, + "reshape2": { + "Package": "reshape2", + "Version": "1.4.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "plyr", + "stringr" + ], + "Hash": "bb5996d0bd962d214a11140d77589917" + }, + "rlang": { + "Package": "rlang", + "Version": "1.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "50a6dbdc522936ca35afc5e2082ea91b" + }, + "rmarkdown": { + "Package": "rmarkdown", + "Version": "2.25", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bslib", + "evaluate", + "fontawesome", + "htmltools", + "jquerylib", + "jsonlite", + "knitr", + "methods", + "stringr", + "tinytex", + "tools", + "utils", + "xfun", + "yaml" + ], + "Hash": "d65e35823c817f09f4de424fcdfa812a" + }, + "roxygen2": { + "Package": "roxygen2", + "Version": "7.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "brew", + "cli", + "commonmark", + "cpp11", + "desc", + "knitr", + "methods", + "pkgload", + "purrr", + "rlang", + "stringi", + "stringr", + "utils", + "withr", + "xml2" + ], + "Hash": "7b153c746193b143c14baa072bae4e27" + }, + "rprojroot": { + "Package": "rprojroot", + "Version": "2.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "1de7ab598047a87bba48434ba35d497d" + }, + "rstudioapi": { + "Package": "rstudioapi", + "Version": "0.15.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "5564500e25cffad9e22244ced1379887" + }, + "rversions": { + "Package": "rversions", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "curl", + "utils", + "xml2" + ], + "Hash": "a9881dfed103e83f9de151dc17002cd1" + }, + "rvest": { + "Package": "rvest", + "Version": "1.0.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "httr", + "lifecycle", + "magrittr", + "rlang", + "selectr", + "tibble", + "withr", + "xml2" + ], + "Hash": "a4a5ac819a467808c60e36e92ddf195e" + }, + "sandwich": { + "Package": "sandwich", + "Version": "3.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils", + "zoo" + ], + "Hash": "1cf6ae532f0179350862fefeb0987c9b" + }, + "sass": { + "Package": "sass", + "Version": "0.4.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "fs", + "htmltools", + "rappdirs", + "rlang" + ], + "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" + }, + "scales": { + "Package": "scales", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "RColorBrewer", + "cli", + "farver", + "glue", + "labeling", + "lifecycle", + "munsell", + "rlang", + "viridisLite" + ], + "Hash": "c19df082ba346b0ffa6f833e92de34d1" + }, + "selectr": { + "Package": "selectr", + "Version": "0.4-2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "methods", + "stringr" + ], + "Hash": "3838071b66e0c566d55cc26bd6e27bf4" + }, + "sessioninfo": { + "Package": "sessioninfo", + "Version": "1.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "tools", + "utils" + ], + "Hash": "3f9796a8d0a0e8c6eb49a4b029359d1f" + }, + "shiny": { + "Package": "shiny", + "Version": "1.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "bslib", + "cachem", + "commonmark", + "crayon", + "ellipsis", + "fastmap", + "fontawesome", + "glue", + "grDevices", + "htmltools", + "httpuv", + "jsonlite", + "later", + "lifecycle", + "methods", + "mime", + "promises", + "rlang", + "sourcetools", + "tools", + "utils", + "withr", + "xtable" + ], + "Hash": "3a1f41807d648a908e3c7f0334bf85e6" + }, + "simstudy": { + "Package": "simstudy", + "Version": "0.7.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "backports", + "data.table", + "fastglm", + "glue", + "methods", + "mvnfast", + "pbv" + ], + "Hash": "deb66424ac81e3aa78066791e0e6b97f" + }, + "sodium": { + "Package": "sodium", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "bd436c1e48dc1982125e4d955017724e" + }, + "sourcetools": { + "Package": "sourcetools", + "Version": "0.1.7-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5f5a7629f956619d519205ec475fe647" + }, + "stringi": { + "Package": "stringi", + "Version": "1.7.12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "tools", + "utils" + ], + "Hash": "ca8bd84263c77310739d2cf64d84d7c9" + }, + "stringr": { + "Package": "stringr", + "Version": "1.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "magrittr", + "rlang", + "stringi", + "vctrs" + ], + "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" + }, + "survey": { + "Package": "survey", + "Version": "4.2-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Matrix", + "R", + "graphics", + "grid", + "lattice", + "methods", + "minqa", + "mitools", + "numDeriv", + "splines", + "stats", + "survival" + ], + "Hash": "03195177db81a992f22361f8f54852f4" + }, + "survival": { + "Package": "survival", + "Version": "3.3-1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "splines", + "stats", + "utils" + ], + "Hash": "f6189c70451d3d68e0d571235576e833" + }, + "swagger": { + "Package": "swagger", + "Version": "3.33.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "f28d25ed70c903922254157c11b0081d" + }, + "sys": { + "Package": "sys", + "Version": "3.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "3a1be13d68d47a8cd0bfd74739ca1555" + }, + "systemfonts": { + "Package": "systemfonts", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "15b594369e70b975ba9f064295983499" + }, + "tableone": { + "Package": "tableone", + "Version": "0.13.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "MASS", + "e1071", + "gmodels", + "labelled", + "nlme", + "survey", + "zoo" + ], + "Hash": "b1a77da61a4c3585987241b8a1cc6b95" + }, + "testthat": { + "Package": "testthat", + "Version": "3.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "brio", + "callr", + "cli", + "desc", + "digest", + "evaluate", + "jsonlite", + "lifecycle", + "magrittr", + "methods", + "pkgload", + "praise", + "processx", + "ps", + "rlang", + "utils", + "waldo", + "withr" + ], + "Hash": "4767a686ebe986e6cb01d075b3f09729" + }, + "textshaping": { + "Package": "textshaping", + "Version": "0.3.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11", + "systemfonts" + ], + "Hash": "997aac9ad649e0ef3b97f96cddd5622b" + }, + "tibble": { + "Package": "tibble", + "Version": "3.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "fansi", + "lifecycle", + "magrittr", + "methods", + "pillar", + "pkgconfig", + "rlang", + "utils", + "vctrs" + ], + "Hash": "a84e2cc86d07289b3b6f5069df7a004c" + }, + "tidyr": { + "Package": "tidyr", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "cpp11", + "dplyr", + "glue", + "lifecycle", + "magrittr", + "purrr", + "rlang", + "stringr", + "tibble", + "tidyselect", + "utils", + "vctrs" + ], + "Hash": "e47debdc7ce599b070c8e78e8ac0cfcf" + }, + "tidyselect": { + "Package": "tidyselect", + "Version": "1.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang", + "vctrs", + "withr" + ], + "Hash": "79540e5fcd9e0435af547d885f184fd5" + }, + "tidyverse": { + "Package": "tidyverse", + "Version": "2.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "broom", + "cli", + "conflicted", + "dbplyr", + "dplyr", + "dtplyr", + "forcats", + "ggplot2", + "googledrive", + "googlesheets4", + "haven", + "hms", + "httr", + "jsonlite", + "lubridate", + "magrittr", + "modelr", + "pillar", + "purrr", + "ragg", + "readr", + "readxl", + "reprex", + "rlang", + "rstudioapi", + "rvest", + "stringr", + "tibble", + "tidyr", + "xml2" + ], + "Hash": "c328568cd14ea89a83bd4ca7f54ae07e" + }, + "timechange": { + "Package": "timechange", + "Version": "0.2.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "8548b44f79a35ba1791308b61e6012d7" + }, + "tinytex": { + "Package": "tinytex", + "Version": "0.49", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "xfun" + ], + "Hash": "5ac22900ae0f386e54f1c307eca7d843" + }, + "truncnorm": { + "Package": "truncnorm", + "Version": "1.0-9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ef5b32c5194351ff409dfb37ca9468f1" + }, + "tzdb": { + "Package": "tzdb", + "Version": "0.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "f561504ec2897f4d46f0c7657e488ae1" + }, + "unbiased": { + "Package": "unbiased", + "Version": "0.0.0.9003", + "Source": "unknown", + "Requirements": [ + "checkmate", + "dbplyr", + "dplyr", + "mathjaxr", + "plumber", + "rlang", + "tibble", + "tidyr" + ], + "Hash": "10b4a8733ed5a18c78c6f683d9023b06" + }, + "urlchecker": { + "Package": "urlchecker", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "curl", + "tools", + "xml2" + ], + "Hash": "409328b8e1253c8d729a7836fe7f7a16" + }, + "usethis": { + "Package": "usethis", + "Version": "2.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "clipr", + "crayon", + "curl", + "desc", + "fs", + "gert", + "gh", + "glue", + "jsonlite", + "lifecycle", + "purrr", + "rappdirs", + "rlang", + "rprojroot", + "rstudioapi", + "stats", + "utils", + "whisker", + "withr", + "yaml" + ], + "Hash": "60e51f0b94d0324dc19e44110098fa9f" + }, + "utf8": { + "Package": "utf8", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "1fe17157424bb09c48a8b3b550c753bc" + }, + "uuid": { + "Package": "uuid", + "Version": "1.1-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "3d78edfb977a69fc7a0341bee25e163f" + }, + "vctrs": { + "Package": "vctrs", + "Version": "0.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "glue", + "lifecycle", + "rlang" + ], + "Hash": "266c1ca411266ba8f365fcc726444b87" + }, + "viridisLite": { + "Package": "viridisLite", + "Version": "0.4.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "c826c7c4241b6fc89ff55aaea3fa7491" + }, + "vroom": { + "Package": "vroom", + "Version": "1.6.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "bit64", + "cli", + "cpp11", + "crayon", + "glue", + "hms", + "lifecycle", + "methods", + "progress", + "rlang", + "stats", + "tibble", + "tidyselect", + "tzdb", + "vctrs", + "withr" + ], + "Hash": "390f9315bc0025be03012054103d227c" + }, + "waldo": { + "Package": "waldo", + "Version": "0.5.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "cli", + "diffobj", + "fansi", + "glue", + "methods", + "rematch2", + "rlang", + "tibble" + ], + "Hash": "2c993415154cdb94649d99ae138ff5e5" + }, + "webutils": { + "Package": "webutils", + "Version": "1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "curl", + "jsonlite" + ], + "Hash": "75d8b5b05fe22659b54076563f83f26a" + }, + "whisker": { + "Package": "whisker", + "Version": "0.4.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "c6abfa47a46d281a7d5159d0a8891e88" + }, + "withr": { + "Package": "withr", + "Version": "2.5.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "stats" + ], + "Hash": "4b25e70111b7d644322e9513f403a272" + }, + "xfun": { + "Package": "xfun", + "Version": "0.41", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "stats", + "tools" + ], + "Hash": "460a5e0fe46a80ef87424ad216028014" + }, + "xml2": { + "Package": "xml2", + "Version": "1.3.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "methods", + "rlang" + ], + "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" + }, + "xopen": { + "Package": "xopen", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "processx" + ], + "Hash": "6c85f015dee9cc7710ddd20f86881f58" + }, + "xtable": { + "Package": "xtable", + "Version": "1.8-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "b8acdf8af494d9ec19ccb2481a9b11c2" + }, + "yaml": { + "Package": "yaml", + "Version": "2.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "29240487a071f535f5e5d5a323b7afbd" + }, + "zip": { + "Package": "zip", + "Version": "2.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "d98c94dacb7e0efcf83b0a133a705504" + }, + "zoo": { + "Package": "zoo", + "Version": "1.8-12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "5c715954112b45499fb1dadc6ee6ee3e" + } + } +} diff --git a/vignettes/simulations.Rmd b/vignettes/simulations.Rmd new file mode 100644 index 0000000..d32694a --- /dev/null +++ b/vignettes/simulations.Rmd @@ -0,0 +1,860 @@ +--- +title: "Comparison of Minimization Randomization with Other Randomization Methods - balance of covariates" +author: "Aleksandra Duda - Tranistion Technologies Science" +date: "`r Sys.Date()`" +output: + html_vignette: + toc: yes +vignette: > + %\VignetteIndexEntry{Comparison of Minimization Randomization with Other Randomization Methods - balance of covariates} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +bibliography: references.bib +link-citations: true +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>" +) +``` + +```{=html} + +``` + +## Introduction + +Randomization in clinical trials is the gold standard and is considered the best widely accepted design for evaluating the effectiveness of new treatments compared to alternative treatments (standard of care) or placebo. Randomization has many advantages. Firstly, it allows the elimination of random errors, including selection biases. This helps avoid any bias in the planning stage of the study protocol, influencing the quality of such a study. Additionally, through randomization, it is possible to match groups based on similarity using specified stratifying factors. This enables the even distribution of factors among groups. If the results of the treated group and the control group show differences, it will be the only difference between the study arms. This means that this difference is caused by the treatment, not the variation in groups based on baseline characteristics [@lim2019randomization]. + +This document provides a summary of the comparison of three randomization methods: simple randomization, block randomization, and adaptive randomization. Simple randomization and adaptive randomization (minimization method) are tools available in the `unbiased` package as `randomize_simple` and `randomize_minimisation_pocock` functions. The comparison aims to demonstrate the superiority of adaptive randomization (minimization method) over other methods in assessing the least imbalance of accompanying variables between therapeutic groups. Monte Carlo simulations were used to generate data, utilizing the `simstudy` package [@goldfeld2020simstudy]. Parameters for the beta distribution of variables were based on data from the publication by @mrozikiewicz2023allogenic and information from researchers. + +The document structure is as follows: first, the distributions of selected parameters from the publication are verified; then, data are generated for a specified number of simulations and patients using Monte-Carlo simulations; subsequently, for each randomization method, including minimization with different weights, for each simulation and for each patient, the treatment group is assigned; based on this, data frames are generated for each method; then, using summaries, the results of frequency and chi^2 test or Fisher's exact test for covariates in each method are displayed; finally, three summaries are presented: a boxplot showing the standardized mean difference (SMD) generated as the average from all covariates, violin plots showing the distribution of each covariate for each method, and a summary of success defined as the percentage of events for which the SMD for each covariate is less than 0.2. + +```{r setup, warning = FALSE, message=FALSE} +# load packages +library(unbiased) +library(dplyr) +library(devtools) +# installed from github +# devtools::install_github('kaneplusplus/bigmemory') +library(bigmemory) +library(simstudy) +library(tableone) +library(checkmate) +library(ggplot2) +library(gt) +library(gtsummary) +library(truncnorm) +library(tidyverse) +library(randomizeR) +``` + +## The randomization methods considered for comparison + +In the process of comparing the balance of covariates among randomization methods, three randomization methods have been selected for evaluation: + +- **simple randomization** - randomization that gives participants equal chances of being assigned to a particular group, which occurs randomly. The method's advantage lies in its simplicity and the elimination of predictability. However, due to its complete randomness, it may lead to imbalance in sample sizes between groups (even assuming a 1:1 randomization ratio) and imbalances between prognostic factors. + +- **block randomization** - a randomization method that takes into account defined covariates and specified allocation ratios for patients in each group. The method involves assigning patients to therapeutic groups in blocks of a fixed size, with the recommendation that the blocks have different sizes. This, to some extent, reduces the risk of researchers predicting future group assignments. In contrast to simple randomization, the block method aims to balance the number of patients in groups, eliminating potential imbalance between groups (@rosenberger2015randomization). + +- **adaptive randomization using minimization method** - a randomization method aiming to balance prognostic factors by determining the total imbalance with the emergence of each new patient in the study. The method selects the smallest imbalance from all total imbalances based on a specified method such as variance, and then assigns the patient to the group with the smallest imbalance with a predetermined probability of allocation. This method was described in the publication by @pocock1975sequential. + +## Assessment of covariate balance + +In the proposed approach to the assessment of randomization methods, the primary objective is to evaluate each method in terms of achieving balance in the specified covariates. The assessment of balance aims to determine whether the distributions of covariates are similarly balanced in each therapeutic group. Based on the literature, standardized mean differences (SMD) have been employed for assessing balance. + +The SMD method is one of the most commonly used statistics for assessing the balance of covariates, regardless of the unit of measurement. It is a statistical measure for comparing differences between two groups.The covariates in the examined case are expressed as binary variables. In the case of categorical variables, SMD is calculated using the following formula (@zhang2019balance): + +\[ SMD = \frac{{p_1 - p_2}}{{\sqrt{\frac{{p_1 \cdot (1 - p_1) + p_2 \cdot (1 - p_2)}}{2}}}} \], + +where: + +- \( p_1 \) is the proportion in the first arm, + +- \( p_2 \) is the proportion in the second arm. + +## Definied number of patients and number of iterations + +Firstly, to create the simulation and generate data, it is crucial to determine the number of patients who will be randomized. In the study, it is assumed that 105 patients will be randomized, with 35 patients in each of the research groups (Group A, Group B, Group C) (ratio 1:1:1). + +The next step is to define the number of iterations. The more iterations are applied, the greater the certainty about the true outcome. For the Monte Carlo simulation, 1000 iterations have been adopted. + +```{r} +# defined number of patients +n <- 105 +# defined number of iterations +no_of_iterations <- 10 +``` + +## Defining parameters for Monte-Carlo simulation{#truncparam} + +In the process of defining the study for randomization, the following covariates have been selected: + +- **gender** [male/female], + +- **diabetes type** [type I/type II], + +- **HbA1c** [up to 9/9 to 11] [%], + +- **tpo2** [up to 50/above 50] [mmHg], + +- **age** [up to 55/above 55] [years], + +- **wound size** [up to 2/above 2] [cm\(^2\)]. + +In the case of the variables gender and diabetes type in the publication @mrozikiewicz2023allogenic, they were expressed in the form of frequencies. The remaining variables were presented in terms of measures of central tendency along with an indication of variability, as well as minimum and maximum values. To determine the parameters alpha and beta for the binary distribution, the truncated normal distribution available in the `truncnorm` package was utilized. The truncated normal distribution is often used in statistics and probability modeling when dealing with data that is constrained to a certain range. It is particularly useful when you want to model a random variable that cannot take values beyond certain limits (@burkardt2014truncated). + +To generate the necessary information for the remaining covariates, a function `simulate_parameters_trunc` was written, utilizing the `rtruncnorm function`. The parameters `mean`, `sd`, `lower`, `upper` were taken from the publication and based on expertise regarding the ranges for the parameters. + +```{r} +# simulation parameters using truncated normal distribution +simulate_parameters_trunc <- + + function(n, lower, upper, mean, sd, number) { + simulate <- sapply(1:1000, function(i) + rtruncnorm( + n = n, + a = lower, + b = upper, + mean = mean, + sd = sd + )) |> + as.data.frame() + + simulate_long <- simulate |> + pivot_longer(cols = everything(), + names_to = "Simulation", + values_to = "Value") |> + arrange(Simulation) + + result <- + simulate_long |> + group_by(Simulation) |> + transmute(Simulation, n = sum(Value <= number)) |> + distinct() + + mean <- mean(result$n) + + return(mean) +} +``` + +```{r} +# Using the function for covariates + +# hba1c +hba1c = + simulate_parameters_trunc(46, 0, 11, 7.41, 1.33, 9) + +# tpo2 +tpo2 = + simulate_parameters_trunc(46, 30, 100, 53.4, 18.4, 50) + +# age +age = + simulate_parameters_trunc(46, 0, 100, 59.2, 9.7, 55) + +# wound_size +wound_size = + simulate_parameters_trunc(46, 0, 20, 2.7, 2.28, 2) +``` + +The results are presented in a table, assuming that the outcome refers to the first category of each parameter. + +```{r, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} +# summary table +data.frame(hba1c, tpo2, age, wound_size) %>% + rename('wound size' = wound_size) %>% + pivot_longer(cols = everything(), + names_to = "parametr", + values_to = "n") %>% + mutate( + n = ceiling(n), + percent = round(n / 46, 2), + strata = c('<=9', '<=50', '<=55', '<=2') + ) %>% + rename('Percent of N = 46' = percent) %>% + gt() +``` + +## Generate data using Monte-Carlo simulations + +To generate data based on the assumptions summarized in the last section and taking into account the suggestions from physicians, a function called `build_prior_df` was created. This function, relying on the conjugate beta distribution, generated random samples based on the defined parameters for various variables. Conjugate priors have the advantageous property that, after incorporating data (likelihood), the posterior distribution is of the same type as the prior, simplifying calculations and result interpretation (@hodel2023beta). + +```{r} +set.seed(123) + +# create beta distribution parameters +build_prior_df <- function(no_of_iterations, n) { + binary_matrix <- tribble( + ~ var, + ~ distribution, + ~ alpha, #1 + ~ beta, #0 + "sex", # Parameters provided by researchers + "beta", + 1 + 900, #men + 1 + 100, #women + "diabetes_type", # Parameters provided by researchers + "beta", + 1 + 150, # type I + 1 + 850, # type II + "hba1c", # (Mrozikiewicz-Rakowska et. al., 2023) + "beta", + 1 + 41, # <=9 + 1 + 5, # (9,11> + "tpo2", # (Mrozikiewicz-Rakowska et. al., 2023) + "beta", + 1 + 17, #<=50 mmHg + 1 + 29, #> 50 mmHg, + "age", # Parameters provided by researchers + "beta", + 1+300, # <=55 + 1+700, # >55 + "wound_size", # Parameters provided by researchers + "beta", + 1+300, # <=2cm^2 + 1+700 # > 2cm^2 + ) |> + rowwise() |> + mutate( + mean = alpha / (alpha + beta), + sd = sqrt(alpha * beta / ((alpha + beta + 1) * (alpha + beta) ** + 2)), + example = rbeta(1, shape1 = alpha, shape2 = beta) + ) |> + ungroup() + + params_binary <- + mapply(function(n, alpha, beta) { + rbeta(n, alpha, beta) + }, + no_of_iterations, + binary_matrix$alpha, + binary_matrix$beta, + SIMPLIFY = FALSE) + names(params_binary) <- binary_matrix$var + + params <- + params_binary |> + bind_rows() + + return(params) +} +``` + +The table contains a summary of the draw results for the distribution of parameters for the first iteration. + +```{r} +params <- + build_prior_df(no_of_iterations = no_of_iterations, n = n) + +params[1,] |> + gt() +``` + +In the next step, additional variables were defined using the `simstudy` package, utilizing the `defData` function. Due to the likely association between the type of diabetes and age – meaning that the older the patient, the higher the probability of having type II diabetes – a relationship with diabetes was established when defining the `age` variable using a logit function (link = "logit"). + +```{r} +# generate outcomes +generate_outcomes <- function(row, n) { + results <- + row |> + with({ + simstudy::defData(varname = "sex", + formula = sex, + dist = "binary") |> + simstudy::defData(varname = "diabetes_type", + formula = diabetes_type, + dist = "binary") |> + simstudy::defData(varname = "hba1c", + formula = hba1c, + dist = "binary") |> + simstudy::defData(varname = "tpo2", + formula = tpo2, + dist = "binary") |> + simstudy::defData( + varname = "age", + # correlation with diabetes type - assumptions - younger patients are more likely to have type I diabetes + formula = "(diabetes_type==0) * (-3.5)", + link = "logit", + dist = "binary" + ) |> + simstudy::defData(varname = "wound_size", + formula = wound_size, + dist = "binary") |> + simstudy::genData(n, dtDefs = _) + }) +} +``` + +Using the `generate_outcomes` function, a data frame was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients in each iteration. + +```{r} +# generate data +data <- + params |> + group_by(simnr = row_number()) |> + tidyr::nest(.key = "params") |> + mutate(results = purrr::map(params, generate_outcomes, n)) |> + select(simnr, results) |> + tidyr::unnest() |> + mutate( + sex = as.character(sex), + age = as.character(age), + diabetes_type = as.character(diabetes_type), + hba1c = as.character(hba1c), + tpo2 = as.character(tpo2), + wound_size = as.character(wound_size) + ) + +data <- + data |> + tibble::add_column(arm = "") +``` + +The table displays an example distribution of variables for the first 5 rows of the `data` frame for the first iteration. + +```{r} +# first 10 rows of the data +data[1:5, 2:ncol(data)] |> + gt() +``` + +## Minimization randomization + +To generate appropriate research arms for each simulation, a function called `minimize_results` was written, utilizing the `randomize_minimisation_pocock` function available within the `unbiased` package. The probability parameter was set at the level defined within the function (p = 0.85). In the case of minimization randomization, to verify which type of minimization (with equal weights or unequal weights) was used, three calls to the minimize_results function were prepared: + +- **minimize_equal_weights** - each covariate weight takes a value equal to 1 divided by the number of covariates. In this case, the weight is 1/6, + +- **minimize_unequal_weights** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 2. The remaining covariates have been assigned a weight of 1. + +- **minimize_unequal_weights_2** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. + + +The tables present information about allocations for the first 5 patients in the initial iteration of the simulation. + +```{r} +# drawing an arm for each patient +minimize_results <- + function(data, arms, weights) { + for (i in unique(data$simnr)) + { + for (j in 1:n) + { + current_data <- data[data$simnr == i, ] + + current_state <- current_data[1:j, 3:ncol(current_data)] + + start <- 1 + (i - 1) * n + end <- i * n + + data[start:end,]$arm[j] <- + randomize_minimisation_pocock( + arms = arms, + current_state = current_state, + weights = weights + ) + + + } + } + return(data) + } +``` + +```{r} +# eqal weights - 1/6 +minimize_equal_weights <- + minimize_results( + data = data, + arms = c("armA", "armB", "armC") + ) + +minimize_equal_weights[1:5, 2:ncol(minimize_equal_weights)] |> + gt() +``` + +```{r} +# double weights where the covariant is of high clinical significance +minimize_unequal_weights <- + minimize_results( + data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 2, + "tpo2" = 2, + "age" = 1, + "wound_size" = 2 + ) + ) + +minimize_unequal_weights[1:5, 2:ncol(minimize_unequal_weights)] |> + gt() +``` + +```{r} +# triple weights where the covariant is of high clinical significance +minimize_unequal_weights_2 <- + minimize_results( + data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 3, + "tpo2" = 3, + "age" = 1, + "wound_size" = 3 + ) + ) + +minimize_unequal_weights_2[1:5, 2:ncol(minimize_unequal_weights_2)] |> + gt() +``` + +The `statistic_table` function was developed to provide information on: the distribution of the number of patients across research arms, and the distribution of covariates across research arms, along with p-value information for statistical analyses used to compare proportions - chi^2, and the exact Fisher's test, typically used for small samples. + +The function relies on the use of the `tbl_summary` function available in the `gtsummary` package. + +```{r} +# generation of frequency and chi^2 statistic values or fisher exact test +statistics_table <- + function(data) { + data |> + filter(simnr == 1) |> + mutate( + sex = ifelse(sex == '1', "men", "women"), + diabetes_type = ifelse(diabetes_type == "1", "type1", "type2"), + hba1c = ifelse(hba1c == '1', "<=9", "(9,11>"), + tpo2 = ifelse(tpo2 == '1', "<=50", ">50"), + age = ifelse(age == '1', "<=55", ">50"), + wound_size = ifelse(wound_size == '1', "<=2", ">2") + ) |> + tbl_summary( + include = c(sex, diabetes_type, hba1c, tpo2, age, wound_size), + by = arm + ) |> + modify_header(label = "") |> + modify_header(all_stat_cols() ~ "**{level}**, N = {n}") |> + bold_labels() |> + add_p() + } +``` + +The table presents a statistical summary of results for the first iteration for: + +- **Minimization with all weights equal to 1/6**. + +```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +statistics_table(minimize_equal_weights) +``` + +- **Minimization with weights 2:1**. + +```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +statistics_table(minimize_unequal_weights) +``` + +- **Minimization with weights 3:1**. + +```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +statistics_table(minimize_unequal_weights_2) +``` + +## Simple randomization + +In the next step, appropriate arms were generated for patients using simple randomization, available through the `unbiased` package - the `randomize_simple` function. The `simple_results` function was called within `simple_data`, considering the initial assumption of assigning patients to three arms in a 1:1:1 ratio. + +Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly. + +```{r} +# simple randomization +simple_results <- + function(data, arms, ratio) { + for (i in unique(data$simnr)) + { + for (j in 1:n) + { + current_data <- data[data$simnr == i, ] + + start <- 1 + (i - 1) * n + end <- i * n + + data[start:end,]$arm[j] <- + randomize_simple(arms, ratio) + + + } + } + return(data) + } +``` + +The table illustrates an example of data output for simple randomization in the first iteration. + +```{r} +simple_data <- + simple_results(data, c("armA", "armB", "armC"), c("armB" = 1L,"armA" = 1L, "armC" = 1L)) + +simple_data[1:5, 2:ncol(simple_data)] |> + gt() +``` +The table presents a statistical summary of results for the first iteration. + +```{r, tab.cap = "Summary of proportion test for simple randomization"} +statistics_table(simple_data) +``` + +## Block randomization + +Block randomization, as opposed to minimization and simple randomization methods, was developed based on the `rbprPar` function available in the `randomizeR` package. Using this, the `block_rand` function was created, which, based on the defined number of patients, arms, and a list of stratifying factors, generates a randomization list with a length equal to the number of patients multiplied by the product of categories in each covariate. In the case of the specified data in the document, for one iteration, it amounts to **105 * 2^6 = 6720 rows**. + +```{r} +# Function to generate a randomisation list +block_rand <- + function(N, block, n_groups, strata) { + strata_grid = expand.grid(strata) + + strata_n = nrow(strata_grid) + + ratio = rep(1, n_groups) + + arm_names = LETTERS[1:n_groups] + + genSeq_list <- lapply(seq_len(strata_n), function(i) { + rand <- rpbrPar( + N = N, + rb = block, + K = n_groups, + ratio = ratio, + groups = arm_names, + filledBlock = FALSE + ) + getRandList(genSeq(rand))[1,] + }) + df_list = tibble::tibble() + for (i in seq_len(strata_n)) { + local_df <- strata_grid %>% + dplyr::slice(i) %>% + dplyr::mutate(count = N) %>% + tidyr::uncount(count) %>% + tibble::add_column(rand_arm = genSeq_list[[i]]) + df_list <- rbind(local_df, df_list) + } + return(df_list) + } +``` + +Lists of randomization codes were generated for each iteration based on the function, assuming a block vector of c(3,6,9). This ensures blinding and facilitates ease in predicting which group the next patient would be assigned to. + +```{r} +# generate randomization lists for each iterations +rand_data <- tibble::tibble() + +for (i in unique(data$simnr)) { + simulation_result <- block_rand( + N = n, + block = c(3, 6, 9), + n_groups = 3, + strata = + list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ) + ) + + rand_data <- dplyr::bind_rows(rand_data, simulation_result) +} +``` + +```{r} +# add number of iterations +rand_data <- + cbind(simnr = rep( + unique(data$simnr), + times = rep(1, times = length(unique(data$simnr))) * nrow(rand_data) / + 2 + ), rand_data) %>% + mutate(simnr = as.factor(simnr)) +``` + +In the next step, patients were assigned to research groups using the `block_results` function. For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). + +```{r} +# Generate a research arm for patients in each iteration +block_results <- function(data, datarand) { + + for (k in unique(data$simnr)) { + + datax <- data[data$simnr == k, ] + + datay <- datarand[datarand$simnr == k, ] + + for (i in data$id) { + + matching_rows <- + which( + datay[2] == datax[3][datax[2] == i] & + datay[3] == datax[4][datax[2] == i] & + datay[4] == datax[5][datax[2] == i] & + datay[5] == datax[6][datax[2] == i] & + datay[6] == datax[7][datax[2] == i] & + datay[7] == datax[8][datax[2] == i] + ) + + if (length(matching_rows) > 0) { + id <- i + arm <- datay$rand_arm[matching_rows[1]] + + start <- 1 + (k - 1) * n + end <- k * n + + data[start:end,]$arm[i] <- arm + + # Delate row with randomization list + datay <- datay[-matching_rows[1], , drop = FALSE] + } + } + } + + return(data) +} +``` + +The table shows the assignment of patients to groups using block randomisation for the first 5 rows, first iteration. + +```{r} +block_data <- + block_results(data, rand_data) + +block_data[1:5, 2:ncol(block_data)] |> + gt() +``` + +The table presents a statistical summary of results for the first iteration. + +```{r, tab.cap = "Summary of proportion test for simple randomization"} +statistics_table(block_data) +``` + +## Check balance using smd test + +In order to select the test and define the precision at a specified level, above which we assume no imbalance, a literature analysis was conducted based on publications such as @lee2021estimating, @austin2009balance, @doah2021impact, @brown2020novel, @nguyen2017double, @sanchez2003effect, @lee2022propensity. + +To assess the balance for covariates between the research groups A, B, C, the Standardized Mean Difference (SMD) test was employed, which compares two groups. Since there are three groups in the example, the SMD test is computed for each pair of comparisons: A vs B, A vs C, and B vs C. The average SMD test for a given covariate is then calculated based on these comparisons. + +In the literature analysis, the precision level ranged between 0.1-0.2. For small samples, it was expected that the SMD test would exceed 0.2 (@austin2009balance). Additionally, according to the publication by @sanchez2003effect, there is no golden standard that dictates a specific threshold for the SMD test to be considered balanced. Generally, the smaller the SMD test, the smaller the difference in covariate imbalance. + +In the analyzed example, due to the sample size of 105 patients, a threshold of 0.2 for the SMD test was adopted. + +```{r} +# definied covariants, and strata +vars = c("sex", "age", "diabetes_type", "wound_size", "tpo2", "hba1c") +strata = "arm" +``` + +A function called `smd_covariants_data` was written to generate frames that produce the SMD test for each covariate in each iteration, utilizing the `CreateTableOne` function available in the `tableone` package. In cases where the test result is <0.001, a value of 0 was assigned. + +The results for each randomization method were stored in the `cov_balance_data`. + +```{r} +smd_covariants_data <- + function(data, vars, strata) { + x <- + rep(unique(data$simnr), times = rep(1, times = length(unique(data$simnr))) * length(vars)) + + result_table <- + data.frame(simnr = x, covariants = rep(vars, length(unique(data$simnr)))) |> + tibble::add_column(results = "") + + for (i in 1:length(unique(data$simnr))) { + current_data <- data[data$simnr == i,] + + # check SMD for any covariants + tab <- + CreateTableOne(vars = vars, + data = current_data, + strata = strata) + results_smd <- print(tab, smd = TRUE, TEST = FALSE) |> + as.data.frame() + results_smd <- + results_smd[2:nrow(results_smd),] |> + mutate(SMD = case_when(SMD == "<0.001" ~ "0", + TRUE ~ SMD), + SMD = as.numeric(SMD)) + results <- as.numeric(results_smd$SMD) + + start <- 1 + (i - 1) * length(vars) + end <- i * length(vars) + + result_table[start:end, ]$results <- results + } + result_table <- + result_table |> + mutate(results = as.numeric(results)) + return(result_table) + } +``` + +```{r, echo = TRUE, results='hide'} +cov1 <- + smd_covariants_data(data = minimize_equal_weights, + vars = vars, + strata = strata) |> + tibble::add_column(method = "minimize equal") +cov2 <- + smd_covariants_data(data = minimize_unequal_weights, + vars = vars, + strata = strata) |> + tibble::add_column(method = "minimize unequal 2:1") +cov3 <- + smd_covariants_data(data = simple_data, + vars = vars, + strata = strata) |> + tibble::add_column(method = "simple randomization") + +cov4 <- + smd_covariants_data( + data = block_data, + vars = vars, + strata = strata + ) |> + tibble::add_column(method = "block randomization") + +cov5 <- + smd_covariants_data( + data = minimize_unequal_weights_2, + vars = vars, + strata = strata + ) |> + tibble::add_column(method = "minimize unequal 3:1") + + +cov_balance_data <- + bind_rows(cov1, cov2, cov3, cov4, cov5) +``` + +Below are the results of the SMD test presented in the form of boxplot and violin plot, depicting the outcomes for each randomization method. The red dashed line indicates the adopted precision threshold. + +- **Boxplot of the combined results** + +```{r, fig.cap= "Summary average smd in each randomization methods", warning=FALSE, fig.width=9, fig.height=6} +# boxplot +cov_balance_data |> + select(simnr, results, method) |> + group_by(simnr, method) |> + mutate(results = mean(results)) |> + distinct() |> + ggplot(aes(x = method, y = results, fill = method)) + + geom_boxplot() + + geom_hline(yintercept = 0.2, linetype = "dashed", color = "red") + + theme_bw() +``` + +- **Violin plot** + +```{r, fig.cap= "Summary smd in each randomization methods in each covariants", warning = FALSE, fig.width=9, fig.height=6} +# violin plot +cov_balance_data |> + ggplot(aes(x = method, y = results, fill = covariants)) + + geom_violin() + + geom_hline(yintercept = 0.2, + linetype = "dashed", + color = "red") + + theme_bw() +``` + +- **Summary table of success** + +Based on the specified precision threshold of 0.2, a function defining randomization success, named `success_power`, was developed. If the SMD test value for each covariate in a given iteration is above 0.2, the function defines the analysis data as 'failure' - 0; otherwise, it is defined as 'success' - 1. + +The final success power is calculated as the sum of successes in each iteration divided by the total number of specified iterations. + +The results defined in variables min1-min5 are summarized in a table as the percentage of success for each randomization method. + +```{r} +# function defining success of randomisation +success_power <- + function(cov_data) { + result_table <- + data.frame(simnr = unique(cov_data$simnr), + results = numeric(length(unique(cov_data$simnr)))) + for (i in 1:length(unique(data$simnr))) { + current_data <- cov_data[cov_data$simnr == i,] + + results <- ifelse(any(current_data$results > 0.2), 0, 1) + result_table$results[i] <- results + + success <- + sum(result_table$results) / nrow(result_table) * 100 + + } + + return(success) + + } +``` + +```{r, echo = TRUE, results='hide'} +min_1 <- + success_power(cov1) +min_2 <- + success_power(cov2) +min_3 <- + success_power(cov3) + +min_4 <- + success_power(cov4) + +min_5 <- + success_power(cov5) +``` + +```{r, tab.cap = "Summary of percent success in each randomization methods"} +data.frame( + method = c( + 'minimize equal weights', + 'minimize unequal weights 2:1', + 'simple randomization', + 'block randomization', + 'minimize unequal weights 3:1' + ), + results_power = c(min_1, min_2, min_3, min_4, min_5) +) |> + as.data.frame() |> + rename(`power results [%]` = results_power) |> + gt() +``` + +## Conclusion + +Considering all three randomization methods: minimization, block randomization, and simple randomization, minimization performs the best in terms of covariate balance. Simple randomization has a significant drawback, as patient allocation to arms occurs randomly with equal probability. This leads to an imbalance in both the number of patients and covariate balance, which is also random. + +On the other hand, block randomization performs very well in balancing the number of patients in groups in a specified allocation ratio. However, its effect size power is lower for covariate balance compared to minimization. + +Minimization, on the other hand, provides the highest success power by ensuring balance across covariates between groups. + +# References + +--- +nocite: '@*' +... + + From ddaa8eaf6939245ccdcde24c88543a5a0560f208 Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 25 Jan 2024 14:49:30 +0000 Subject: [PATCH 126/240] Changed docker build strategy Remove building on cron (schedule), added devel images and appropriate tags --- .github/workflows/docker-publish.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 233a154..52d25c3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker +name: Build and Publish Docker Images # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by @@ -6,14 +6,10 @@ name: Docker # documentation. on: - schedule: - - cron: '15 0 * * *' push: - branches: [ "main" ] + branches: [ "main", "devel" ] # Publish semver tags as releases. tags: [ 'v*.*.*' ] - pull_request: - branches: [ "main" ] workflow_dispatch: env: @@ -21,6 +17,7 @@ env: REGISTRY: ghcr.io # github.repository as / IMAGE_NAME: ${{ github.repository }} + BRANCH_TAG: ${{ github.ref == 'refs/heads/devel' && 'unbiased-dev' || 'latest' }} jobs: @@ -78,7 +75,7 @@ jobs: with: context: . push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.meta.outputs.tags }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.BRANCH_TAG }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max From 90f74452d69183bd65de8a254e36027b79e934fd Mon Sep 17 00:00:00 2001 From: Kamil Sijko Date: Thu, 25 Jan 2024 15:40:43 +0000 Subject: [PATCH 127/240] reverted image tags --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 52d25c3..1490c6a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -75,7 +75,7 @@ jobs: with: context: . push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.BRANCH_TAG }} + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max From d93ff448e102a030bfcee12780ffa65fc896eb88 Mon Sep 17 00:00:00 2001 From: Ola Date: Fri, 26 Jan 2024 10:49:34 +0000 Subject: [PATCH 128/240] add chunk names --- renv.lock | 326 +++++++++++++++++++++++++++++++++++++- vignettes/simulations.Rmd | 70 ++++---- 2 files changed, 360 insertions(+), 36 deletions(-) diff --git a/renv.lock b/renv.lock index 11e6669..926831a 100644 --- a/renv.lock +++ b/renv.lock @@ -1,6 +1,6 @@ { "R": { - "Version": "4.2.1", + "Version": "4.2.3", "Repositories": [ { "Name": "CRAN", @@ -284,6 +284,13 @@ ], "Hash": "40415719b5a479b87949f3aa0aee737c" }, + "brew": { + "Package": "brew", + "Version": "1.0-10", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8f4a384e19dccd8c65356dc096847b76" + }, "brio": { "Package": "brio", "Version": "1.1.3", @@ -523,6 +530,20 @@ ], "Hash": "e8a1e41acf02548751f45c718d55aa6a" }, + "credentials": { + "Package": "credentials", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "curl", + "jsonlite", + "openssl", + "sys" + ], + "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" + }, "curl": { "Package": "curl", "Version": "5.1.0", @@ -586,6 +607,40 @@ ], "Hash": "6b9602c7ebbe87101a9c8edb6e8b6d21" }, + "devtools": { + "Package": "devtools", + "Version": "2.4.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "desc", + "ellipsis", + "fs", + "lifecycle", + "memoise", + "miniUI", + "pkgbuild", + "pkgdown", + "pkgload", + "profvis", + "rcmdcheck", + "remotes", + "rlang", + "roxygen2", + "rversions", + "sessioninfo", + "stats", + "testthat", + "tools", + "urlchecker", + "usethis", + "utils", + "withr" + ], + "Hash": "ea5bc8b4a6a01e4f12d98b58329930bb" + }, "diffobj": { "Package": "diffobj", "Version": "0.3.5", @@ -837,6 +892,21 @@ ], "Hash": "15e9634c0fcd294799e9b2e929ed1b86" }, + "gert": { + "Package": "gert", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "credentials", + "openssl", + "rstudioapi", + "sys", + "zip" + ], + "Hash": "f70d3fe2d9e7654213a946963d1591eb" + }, "ggplot2": { "Package": "ggplot2", "Version": "3.4.4", @@ -862,6 +932,32 @@ ], "Hash": "313d31eff2274ecf4c1d3581db7241f9" }, + "gh": { + "Package": "gh", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gitcreds", + "httr2", + "ini", + "jsonlite", + "rlang" + ], + "Hash": "03533b1c875028233598f848fda44c4c" + }, + "gitcreds": { + "Package": "gitcreds", + "Version": "0.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ab08ac61f3e1be454ae21911eb8bc2fe" + }, "glue": { "Package": "glue", "Version": "1.6.2", @@ -1183,6 +1279,13 @@ ], "Hash": "99df65cfef20e525ed38c3d2577f7190" }, + "ini": { + "Package": "ini", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "6154ec2223172bce8162d4153cda21f7" + }, "insight": { "Package": "insight", "Version": "0.19.7", @@ -1433,6 +1536,18 @@ ], "Hash": "18e9c28c1d3ca1560ce30658b22ce104" }, + "miniUI": { + "Package": "miniUI", + "Version": "0.1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools", + "shiny", + "utils" + ], + "Hash": "fec5f52652d60615fdb3957b3d74324a" + }, "minqa": { "Package": "minqa", "Version": "1.2.6", @@ -1794,6 +1909,21 @@ ], "Hash": "3efbd8ac1be0296a46c55387aeace0f3" }, + "profvis": { + "Package": "profvis", + "Version": "0.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmlwidgets", + "purrr", + "rlang", + "stringr", + "vctrs" + ], + "Hash": "aa5a3864397ce6ae03458f98618395a1" + }, "progress": { "Package": "progress", "Version": "1.2.3", @@ -1920,6 +2050,28 @@ ], "Hash": "5e3c5dc0b071b21fa128676560dbe94d" }, + "rcmdcheck": { + "Package": "rcmdcheck", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "callr", + "cli", + "curl", + "desc", + "digest", + "pkgbuild", + "prettyunits", + "rprojroot", + "sessioninfo", + "utils", + "withr", + "xopen" + ], + "Hash": "8f25ebe2ec38b1f2aef3b0d2ef76f6c4" + }, "reactR": { "Package": "reactR", "Version": "0.5.0", @@ -2000,6 +2152,20 @@ ], "Hash": "76c9e04c712a05848ae7a23d2f170a40" }, + "remotes": { + "Package": "remotes", + "Version": "2.4.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "tools", + "utils" + ], + "Hash": "63d15047eb239f95160112bcadc4fcb9" + }, "renv": { "Package": "renv", "Version": "1.0.0", @@ -2080,6 +2246,32 @@ ], "Hash": "d65e35823c817f09f4de424fcdfa812a" }, + "roxygen2": { + "Package": "roxygen2", + "Version": "7.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "brew", + "cli", + "commonmark", + "cpp11", + "desc", + "knitr", + "methods", + "pkgload", + "purrr", + "rlang", + "stringi", + "stringr", + "utils", + "withr", + "xml2" + ], + "Hash": "c25fe7b2d8cba73d1b63c947bf7afdb9" + }, "rprojroot": { "Package": "rprojroot", "Version": "2.0.3", @@ -2097,6 +2289,18 @@ "Repository": "RSPM", "Hash": "5564500e25cffad9e22244ced1379887" }, + "rversions": { + "Package": "rversions", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "curl", + "utils", + "xml2" + ], + "Hash": "a9881dfed103e83f9de151dc17002cd1" + }, "rvest": { "Package": "rvest", "Version": "1.0.3", @@ -2177,6 +2381,53 @@ ], "Hash": "3838071b66e0c566d55cc26bd6e27bf4" }, + "sessioninfo": { + "Package": "sessioninfo", + "Version": "1.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "tools", + "utils" + ], + "Hash": "3f9796a8d0a0e8c6eb49a4b029359d1f" + }, + "shiny": { + "Package": "shiny", + "Version": "1.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "bslib", + "cachem", + "commonmark", + "crayon", + "ellipsis", + "fastmap", + "fontawesome", + "glue", + "grDevices", + "htmltools", + "httpuv", + "jsonlite", + "later", + "lifecycle", + "methods", + "mime", + "promises", + "rlang", + "sourcetools", + "tools", + "utils", + "withr", + "xtable" + ], + "Hash": "3a1f41807d648a908e3c7f0334bf85e6" + }, "simstudy": { "Package": "simstudy", "Version": "0.7.1", @@ -2202,6 +2453,16 @@ "Repository": "RSPM", "Hash": "bd436c1e48dc1982125e4d955017724e" }, + "sourcetools": { + "Package": "sourcetools", + "Version": "0.1.7-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5f5a7629f956619d519205ec475fe647" + }, "stringi": { "Package": "stringi", "Version": "1.7.12", @@ -2491,6 +2752,51 @@ ], "Hash": "f561504ec2897f4d46f0c7657e488ae1" }, + "urlchecker": { + "Package": "urlchecker", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "curl", + "tools", + "xml2" + ], + "Hash": "409328b8e1253c8d729a7836fe7f7a16" + }, + "usethis": { + "Package": "usethis", + "Version": "2.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "clipr", + "crayon", + "curl", + "desc", + "fs", + "gert", + "gh", + "glue", + "jsonlite", + "lifecycle", + "purrr", + "rappdirs", + "rlang", + "rprojroot", + "rstudioapi", + "stats", + "utils", + "whisker", + "withr", + "yaml" + ], + "Hash": "60e51f0b94d0324dc19e44110098fa9f" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", @@ -2633,6 +2939,17 @@ ], "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" }, + "xopen": { + "Package": "xopen", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "processx" + ], + "Hash": "6c85f015dee9cc7710ddd20f86881f58" + }, "xtable": { "Package": "xtable", "Version": "1.8-4", @@ -2652,6 +2969,13 @@ "Repository": "RSPM", "Hash": "29240487a071f535f5e5d5a323b7afbd" }, + "zip": { + "Package": "zip", + "Version": "2.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "d98c94dacb7e0efcf83b0a133a705504" + }, "zoo": { "Package": "zoo", "Version": "1.8-12", diff --git a/vignettes/simulations.Rmd b/vignettes/simulations.Rmd index d32694a..354778a 100644 --- a/vignettes/simulations.Rmd +++ b/vignettes/simulations.Rmd @@ -89,7 +89,7 @@ Firstly, to create the simulation and generate data, it is crucial to determine The next step is to define the number of iterations. The more iterations are applied, the greater the certainty about the true outcome. For the Monte Carlo simulation, 1000 iterations have been adopted. -```{r} +```{r, define-parameters} # defined number of patients n <- 105 # defined number of iterations @@ -116,7 +116,7 @@ In the case of the variables gender and diabetes type in the publication @mrozik To generate the necessary information for the remaining covariates, a function `simulate_parameters_trunc` was written, utilizing the `rtruncnorm function`. The parameters `mean`, `sd`, `lower`, `upper` were taken from the publication and based on expertise regarding the ranges for the parameters. -```{r} +```{r, parameters-function} # simulation parameters using truncated normal distribution simulate_parameters_trunc <- @@ -149,7 +149,7 @@ simulate_parameters_trunc <- } ``` -```{r} +```{r, parameters-result} # Using the function for covariates # hba1c @@ -171,7 +171,7 @@ wound_size = The results are presented in a table, assuming that the outcome refers to the first category of each parameter. -```{r, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} +```{r, parameters-result-table, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} # summary table data.frame(hba1c, tpo2, age, wound_size) %>% rename('wound size' = wound_size) %>% @@ -191,7 +191,7 @@ data.frame(hba1c, tpo2, age, wound_size) %>% To generate data based on the assumptions summarized in the last section and taking into account the suggestions from physicians, a function called `build_prior_df` was created. This function, relying on the conjugate beta distribution, generated random samples based on the defined parameters for various variables. Conjugate priors have the advantageous property that, after incorporating data (likelihood), the posterior distribution is of the same type as the prior, simplifying calculations and result interpretation (@hodel2023beta). -```{r} +```{r, build-prior} set.seed(123) # create beta distribution parameters @@ -255,7 +255,7 @@ build_prior_df <- function(no_of_iterations, n) { The table contains a summary of the draw results for the distribution of parameters for the first iteration. -```{r} +```{r, params} params <- build_prior_df(no_of_iterations = no_of_iterations, n = n) @@ -265,7 +265,7 @@ params[1,] |> In the next step, additional variables were defined using the `simstudy` package, utilizing the `defData` function. Due to the likely association between the type of diabetes and age – meaning that the older the patient, the higher the probability of having type II diabetes – a relationship with diabetes was established when defining the `age` variable using a logit function (link = "logit"). -```{r} +```{r, generate-outcomes} # generate outcomes generate_outcomes <- function(row, n) { results <- @@ -300,7 +300,7 @@ generate_outcomes <- function(row, n) { Using the `generate_outcomes` function, a data frame was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients in each iteration. -```{r} +```{r, data-generate} # generate data data <- params |> @@ -325,8 +325,8 @@ data <- The table displays an example distribution of variables for the first 5 rows of the `data` frame for the first iteration. -```{r} -# first 10 rows of the data +```{r, data-show} +# first 5 rows of the data data[1:5, 2:ncol(data)] |> gt() ``` @@ -344,7 +344,7 @@ To generate appropriate research arms for each simulation, a function called `mi The tables present information about allocations for the first 5 patients in the initial iteration of the simulation. -```{r} +```{r, minimize-results} # drawing an arm for each patient minimize_results <- function(data, arms, weights) { @@ -373,7 +373,7 @@ minimize_results <- } ``` -```{r} +```{r, minimize-equal} # eqal weights - 1/6 minimize_equal_weights <- minimize_results( @@ -385,7 +385,7 @@ minimize_equal_weights[1:5, 2:ncol(minimize_equal_weights)] |> gt() ``` -```{r} +```{r, minimize-unequal-1} # double weights where the covariant is of high clinical significance minimize_unequal_weights <- minimize_results( @@ -405,7 +405,7 @@ minimize_unequal_weights[1:5, 2:ncol(minimize_unequal_weights)] |> gt() ``` -```{r} +```{r, minimize-unequal-2} # triple weights where the covariant is of high clinical significance minimize_unequal_weights_2 <- minimize_results( @@ -429,7 +429,7 @@ The `statistic_table` function was developed to provide information on: the dist The function relies on the use of the `tbl_summary` function available in the `gtsummary` package. -```{r} +```{r, statistics-table} # generation of frequency and chi^2 statistic values or fisher exact test statistics_table <- function(data) { @@ -458,19 +458,19 @@ The table presents a statistical summary of results for the first iteration for: - **Minimization with all weights equal to 1/6**. -```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +```{r, chi2-1, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} statistics_table(minimize_equal_weights) ``` - **Minimization with weights 2:1**. -```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +```{r, chi2-2, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} statistics_table(minimize_unequal_weights) ``` - **Minimization with weights 3:1**. -```{r, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} +```{r, chi2-3, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} statistics_table(minimize_unequal_weights_2) ``` @@ -480,7 +480,7 @@ In the next step, appropriate arms were generated for patients using simple rand Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly. -```{r} +```{r, simple-result} # simple randomization simple_results <- function(data, arms, ratio) { @@ -505,7 +505,7 @@ simple_results <- The table illustrates an example of data output for simple randomization in the first iteration. -```{r} +```{r, simple-data} simple_data <- simple_results(data, c("armA", "armB", "armC"), c("armB" = 1L,"armA" = 1L, "armC" = 1L)) @@ -514,7 +514,7 @@ simple_data[1:5, 2:ncol(simple_data)] |> ``` The table presents a statistical summary of results for the first iteration. -```{r, tab.cap = "Summary of proportion test for simple randomization"} +```{r, chi2-4, tab.cap = "Summary of proportion test for simple randomization"} statistics_table(simple_data) ``` @@ -522,7 +522,7 @@ statistics_table(simple_data) Block randomization, as opposed to minimization and simple randomization methods, was developed based on the `rbprPar` function available in the `randomizeR` package. Using this, the `block_rand` function was created, which, based on the defined number of patients, arms, and a list of stratifying factors, generates a randomization list with a length equal to the number of patients multiplied by the product of categories in each covariate. In the case of the specified data in the document, for one iteration, it amounts to **105 * 2^6 = 6720 rows**. -```{r} +```{r, block-rand} # Function to generate a randomisation list block_rand <- function(N, block, n_groups, strata) { @@ -560,7 +560,7 @@ block_rand <- Lists of randomization codes were generated for each iteration based on the function, assuming a block vector of c(3,6,9). This ensures blinding and facilitates ease in predicting which group the next patient would be assigned to. -```{r} +```{r, rand-data} # generate randomization lists for each iterations rand_data <- tibble::tibble() @@ -584,7 +584,7 @@ for (i in unique(data$simnr)) { } ``` -```{r} +```{r, rand-data-2} # add number of iterations rand_data <- cbind(simnr = rep( @@ -597,7 +597,7 @@ rand_data <- In the next step, patients were assigned to research groups using the `block_results` function. For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). -```{r} +```{r, block-results} # Generate a research arm for patients in each iteration block_results <- function(data, datarand) { @@ -640,7 +640,7 @@ block_results <- function(data, datarand) { The table shows the assignment of patients to groups using block randomisation for the first 5 rows, first iteration. -```{r} +```{r, block-data-show} block_data <- block_results(data, rand_data) @@ -650,7 +650,7 @@ block_data[1:5, 2:ncol(block_data)] |> The table presents a statistical summary of results for the first iteration. -```{r, tab.cap = "Summary of proportion test for simple randomization"} +```{r, chi2-5, tab.cap = "Summary of proportion test for simple randomization"} statistics_table(block_data) ``` @@ -664,7 +664,7 @@ In the literature analysis, the precision level ranged between 0.1-0.2. For smal In the analyzed example, due to the sample size of 105 patients, a threshold of 0.2 for the SMD test was adopted. -```{r} +```{r, define-strata-vars} # definied covariants, and strata vars = c("sex", "age", "diabetes_type", "wound_size", "tpo2", "hba1c") strata = "arm" @@ -674,7 +674,7 @@ A function called `smd_covariants_data` was written to generate frames that prod The results for each randomization method were stored in the `cov_balance_data`. -```{r} +```{r, smd-covariants-data} smd_covariants_data <- function(data, vars, strata) { x <- @@ -713,7 +713,7 @@ smd_covariants_data <- } ``` -```{r, echo = TRUE, results='hide'} +```{r, cov-balance-data, echo = TRUE, results='hide'} cov1 <- smd_covariants_data(data = minimize_equal_weights, vars = vars, @@ -755,7 +755,7 @@ Below are the results of the SMD test presented in the form of boxplot and violi - **Boxplot of the combined results** -```{r, fig.cap= "Summary average smd in each randomization methods", warning=FALSE, fig.width=9, fig.height=6} +```{r, boxplot, fig.cap= "Summary average smd in each randomization methods", warning=FALSE, fig.width=9, fig.height=6} # boxplot cov_balance_data |> select(simnr, results, method) |> @@ -770,7 +770,7 @@ cov_balance_data |> - **Violin plot** -```{r, fig.cap= "Summary smd in each randomization methods in each covariants", warning = FALSE, fig.width=9, fig.height=6} +```{r, violinplot, fig.cap= "Summary smd in each randomization methods in each covariants", warning = FALSE, fig.width=9, fig.height=6} # violin plot cov_balance_data |> ggplot(aes(x = method, y = results, fill = covariants)) + @@ -789,7 +789,7 @@ The final success power is calculated as the sum of successes in each iteration The results defined in variables min1-min5 are summarized in a table as the percentage of success for each randomization method. -```{r} +```{r, success-power} # function defining success of randomisation success_power <- function(cov_data) { @@ -812,7 +812,7 @@ success_power <- } ``` -```{r, echo = TRUE, results='hide'} +```{r, success-min, echo = TRUE, results='hide'} min_1 <- success_power(cov1) min_2 <- @@ -827,7 +827,7 @@ min_5 <- success_power(cov5) ``` -```{r, tab.cap = "Summary of percent success in each randomization methods"} +```{r, success-result-data, tab.cap = "Summary of percent success in each randomization methods"} data.frame( method = c( 'minimize equal weights', From c367c13eeaa52cd7db0c30670601c27d0aadcad2 Mon Sep 17 00:00:00 2001 From: Ola Date: Fri, 26 Jan 2024 14:33:35 +0000 Subject: [PATCH 129/240] repair of block randomization function --- vignettes/simulations.Rmd | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/vignettes/simulations.Rmd b/vignettes/simulations.Rmd index 354778a..34af028 100644 --- a/vignettes/simulations.Rmd +++ b/vignettes/simulations.Rmd @@ -565,7 +565,8 @@ Lists of randomization codes were generated for each iteration based on the func rand_data <- tibble::tibble() for (i in unique(data$simnr)) { - simulation_result <- block_rand( + simulation_result <- + block_rand( N = n, block = c(3, 6, 9), n_groups = 3, @@ -578,23 +579,13 @@ for (i in unique(data$simnr)) { age = c("0", "1"), wound_size = c("0", "1") ) - ) + ) |> dplyr::mutate(simnr = i) |> + select(simnr, everything()) rand_data <- dplyr::bind_rows(rand_data, simulation_result) } ``` -```{r, rand-data-2} -# add number of iterations -rand_data <- - cbind(simnr = rep( - unique(data$simnr), - times = rep(1, times = length(unique(data$simnr))) * nrow(rand_data) / - 2 - ), rand_data) %>% - mutate(simnr = as.factor(simnr)) -``` - In the next step, patients were assigned to research groups using the `block_results` function. For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). ```{r, block-results} @@ -607,7 +598,7 @@ block_results <- function(data, datarand) { datay <- datarand[datarand$simnr == k, ] - for (i in data$id) { + for (i in datax$id) { matching_rows <- which( From 792f07e5e3c9e89e3056b15f92ee2ab03c5fea5d Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 16 Jan 2024 10:13:10 +0000 Subject: [PATCH 130/240] Added Endpoint Response Structure Test --- inst/plumber/unbiased_api/minimisation_pocock.R | 4 +--- tests/testthat/test-E2E-study-minimisation-pocock.R | 6 ++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index c1e41a9..bde11a1 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -223,8 +223,6 @@ function(study_id, current_state, req, res) { dplyr::select(id) |> dplyr::pull()) - #DF validation - error handling - # Retrieve study details, especially the ones about randomization method_randomization <- dplyr::tbl(db_connection_pool, "study") |> @@ -232,7 +230,7 @@ function(study_id, current_state, req, res) { dplyr::select(method) |> dplyr::pull() - # asercja jeden element + checkmate::assert_scalar(method_randomization, null.ok = FALSE) # Dispatch based on randomization method to parse parameters source("parse_pocock.R") diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 9e49788..de9c08c 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -45,4 +45,10 @@ test_that("endpoint returns the study id, can randomize 2 patients", { expect_number(response_patient$status_code, lower = 200, upper = 200) expect_number(response_patient_body$patient_id, lower = 1, upper = 200) + + # Endpoint Response Structure Test + expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) + expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) }) + + From 2206805ad0bcc4362f9510b03adfb424135ee989 Mon Sep 17 00:00:00 2001 From: Kinga Date: Fri, 26 Jan 2024 14:25:56 +0000 Subject: [PATCH 131/240] Changing data transmission from URL to body in API requests --- .../unbiased_api/minimisation_pocock.R | 38 ++++++++++----- inst/plumber/unbiased_api/plumber.R | 24 ++++++++++ .../test-E2E-study-minimisation-pocock.R | 47 +++++++++++++------ 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index bde11a1..a21a330 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -3,10 +3,10 @@ #* Set up a new study for randomization defining it's parameters #* #* -#* @param identifier:str Study code, at most 12 characters. -#* @param name:str Full study name. -#* @param method:str Function used to compute within-arm variability, must be one of: sd, var, range -#* @param p:dbl Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) +#* @param identifier:object Study code, at most 12 characters. +#* @param name:object Full study name. +#* @param method:object Function used to compute within-arm variability, must be one of: sd, var, range +#* @param p:object Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) #* @param arms:object Arm names (character) with their ratios (integer). #* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). #* @@ -183,7 +183,7 @@ function(identifier, name, method, arms, covariates, p, req, res) { # Response ---------------------------------------------------------------- if (!is.null(r$error)) { - res$status <- 409 + res$status <- 503 return(list( error = "There was a problem creating the study", details = r$error @@ -212,16 +212,19 @@ function(identifier, name, method, arms, covariates, p, req, res) { #* function(study_id, current_state, req, res) { + collection <- checkmate::makeAssertCollection() # Assertion connection with DB - checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection") + checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection", + add = collection) # Check whether study with study_id exists - checkmate::expect_subset(x = req$args$study_id, + checkmate::assert(checkmate::check_subset(x = req$args$study_id, choices = dplyr::tbl(db_connection_pool, "study") |> dplyr::select(id) |> - dplyr::pull()) + dplyr::pull()), .var.name = "Study ID", + add = collection) # Retrieve study details, especially the ones about randomization method_randomization <- @@ -230,7 +233,17 @@ function(study_id, current_state, req, res) { dplyr::select(method) |> dplyr::pull() - checkmate::assert_scalar(method_randomization, null.ok = FALSE) + checkmate::assert(checkmate::check_scalar(method_randomization, null.ok = FALSE), + .var.name = "Randomization method", + add = collection) + + if (length(collection$getMessages()) > 0) { + res$status <- 400 + return(list( + error = "Study input validation failed", + validation_errors = collection$getMessages() + )) + } # Dispatch based on randomization method to parse parameters source("parse_pocock.R") @@ -253,7 +266,6 @@ function(study_id, current_state, req, res) { minimisation_pocock = tryCatch({ do.call(unbiased:::randomize_minimisation_pocock, params) }, error = function(e) { - # browser() res$status <- 400 res$body = glue::glue("Error message: {conditionMessage(e)}") logger::log_error("Error: {err}", err=e) @@ -267,8 +279,8 @@ function(study_id, current_state, req, res) { dplyr::collect() save_patient(study_id, arm$arm_id) |> - dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = id) |> - as.list() + dplyr::mutate(arm_name = arm$name) |> + dplyr::rename(patient_id = id) |> + as.list() } diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 4ffd51c..542f51e 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -22,6 +22,30 @@ function(api) { post$requestBody$ content$`application/json`$schema$properties$ arms$example <- list("placebo" = 1, "active" = 1) + spec$ + paths$ + `/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$schema$properties$ + identifier$example <- "CSN" + spec$ + paths$ + `/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$schema$properties$ + p$example <- 0.85 + spec$ + paths$`/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$ + schema$properties$ + name$example <- "Clinical Study Name" + spec$ + paths$`/study/minimisation_pocock`$ + post$requestBody$ + content$`application/json`$ + schema$properties$ + method$example <- "range" # example of how to define covariates in minimisation pocock spec$ paths$`/study/minimisation_pocock`$ diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index de9c08c..9528eaa 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -2,14 +2,15 @@ test_that("endpoint returns the study id, can randomize 2 patients", { response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> - req_url_query(identifier = "ABC-X", - name = "Study ABC-X", - method = "var", - p = 0.85) |> req_body_json( - data = list(arms = list( - "placebo" = 1, - "active" = 1), + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1), covariates = list( sex = list( weight = 1, @@ -26,8 +27,8 @@ test_that("endpoint returns the study id, can randomize 2 patients", { response |> resp_body_json() - expect_number(response$status_code, lower = 200, upper = 200) - expect_number(response_body$study$id, lower = 1, upper = 200) + testthat::expect_equal(response$status_code, 200) + checkmate::expect_number(response_body$study$id, lower = 1) response_patient <- request(api_url) |> req_url_path("study", response_body$study$id, "patient") |> @@ -43,12 +44,30 @@ test_that("endpoint returns the study id, can randomize 2 patients", { response_patient |> resp_body_json() - expect_number(response_patient$status_code, lower = 200, upper = 200) - expect_number(response_patient_body$patient_id, lower = 1, upper = 200) + testthat::expect_equal(response$status_code, 200) + expect_number(response_patient_body$patient_id, lower = 1) # Endpoint Response Structure Test - expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) - expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) -}) + checkmate::expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) + checkmate::expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) + # Incorrect Study ID + + response_study <- + tryCatch({ + request(api_url) |> + req_url_path("study", response_body$study$id + 1, "patient") |> + req_method("POST") |> + req_body_json( + data = list(current_state = + tibble::tibble("sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", ""))) + ) |> + req_perform() + }, error = function(e) e) + + checkmate::expect_set_equal(response_study$status, 400, label = "HTTP status code") + + }) From 2124c0c7be6e1ccd4313d8c045c78a5d64da7b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kinga=20Sa=C5=82ata?= <109790203+salatak@users.noreply.github.com> Date: Mon, 29 Jan 2024 10:49:34 +0000 Subject: [PATCH 132/240] Removed the assertion about the existence of a connection to the database --- inst/plumber/unbiased_api/minimisation_pocock.R | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/inst/plumber/unbiased_api/minimisation_pocock.R index a21a330..481de82 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/inst/plumber/unbiased_api/minimisation_pocock.R @@ -213,11 +213,7 @@ function(identifier, name, method, arms, covariates, p, req, res) { function(study_id, current_state, req, res) { collection <- checkmate::makeAssertCollection() - # Assertion connection with DB - checkmate::assert(DBI::dbIsValid(db_connection_pool), .var.name = "DB connection", - add = collection) - - + # Check whether study with study_id exists checkmate::assert(checkmate::check_subset(x = req$args$study_id, choices = From 80143a79da9a22480c94a2b3bfc13d7beda05e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 29 Jan 2024 14:52:55 +0000 Subject: [PATCH 133/240] add migrations and cleaning DB on for test that uses database --- .devcontainer/Dockerfile | 10 +- R/run_api.R | 13 +- inst/{postgres => db}/00-metadata.sql | 0 inst/{postgres => db}/01-initialize.sql | 0 inst/{postgres => db}/03-versioning.sql | 0 ...tialize_temporal_tables_extension.down.sql | 1 + ...nitialize_temporal_tables_extension.up.sql | 1 + .../20240129082653_create_tables.down.sql | 8 + .../20240129082653_create_tables.up.sql | 174 ++++++++++++++++++ ...240129082842_main_data_validation.down.sql | 14 ++ ...20240129082842_main_data_validation.up.sql | 134 ++++++++++++++ .../20240129084925_versioning.down.sql | 20 ++ .../20240129084925_versioning.up.sql | 48 +++++ inst/postgres/90-examples.sql | 29 --- migrate_db.sh | 12 ++ run_tests_local.sh | 15 ++ entrypoint.sh => start_unbiased_api.sh | 2 +- start_unbiased_api_for_tests.sh | 31 ++++ tests/testthat/.gitignore | 1 + tests/testthat/fixtures/example_study.yml | 42 +++++ tests/testthat/setup-DB.R | 26 +-- tests/testthat/setup-api.R | 36 +--- tests/testthat/test-DB-0.R | 22 +-- tests/testthat/test-DB-study.R | 56 +++--- tests/testthat/test-helpers.R | 59 ++++++ 25 files changed, 635 insertions(+), 119 deletions(-) rename inst/{postgres => db}/00-metadata.sql (100%) rename inst/{postgres => db}/01-initialize.sql (100%) rename inst/{postgres => db}/03-versioning.sql (100%) create mode 100644 inst/db/migrations/000001_initialize_temporal_tables_extension.down.sql create mode 100644 inst/db/migrations/000001_initialize_temporal_tables_extension.up.sql create mode 100644 inst/db/migrations/20240129082653_create_tables.down.sql create mode 100644 inst/db/migrations/20240129082653_create_tables.up.sql create mode 100644 inst/db/migrations/20240129082842_main_data_validation.down.sql create mode 100644 inst/db/migrations/20240129082842_main_data_validation.up.sql create mode 100644 inst/db/migrations/20240129084925_versioning.down.sql create mode 100644 inst/db/migrations/20240129084925_versioning.up.sql delete mode 100644 inst/postgres/90-examples.sql create mode 100644 migrate_db.sh create mode 100644 run_tests_local.sh rename entrypoint.sh => start_unbiased_api.sh (62%) mode change 100755 => 100644 create mode 100644 start_unbiased_api_for_tests.sh create mode 100644 tests/testthat/.gitignore create mode 100644 tests/testthat/fixtures/example_study.yml create mode 100644 tests/testthat/test-helpers.R diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 02fffe9..392966e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,8 +6,16 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev postgresql-client + libpq-dev libssl-dev postgresql-client \ + # R_X11 + libxt-dev RUN pip install watchdog[watchmedo] ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE + +# Install database migration tool +RUN curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | apt-key add - && \ + echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" > /etc/apt/sources.list.d/migrate.list && \ + apt-get update && \ + apt-get install -y migrate diff --git a/R/run_api.R b/R/run_api.R index 17aa312..1f94fb1 100644 --- a/R/run_api.R +++ b/R/run_api.R @@ -22,13 +22,20 @@ run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { plumber::pr_run(host = host, port = port, ...) } -run_unbiased_local <- function(host = "0.0.0.0", port = 3838, ...) { - assign("db_connection_pool", create_db_connection_pool(), envir = globalenv()) +run_unbiased_local <- function() { + host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") + port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) + + assign("db_connection_pool", + unbiased:::create_db_connection_pool(), + envir = globalenv() + ) on.exit({ + db_connection_pool <- get("db_connection_pool", envir = globalenv()) pool::poolClose(db_connection_pool) assign("db_connection_pool", NULL, envir = globalenv()) }) plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> - plumber::pr_run(host = host, port = port, ...) + plumber::pr_run(host = host, port = port) } diff --git a/inst/postgres/00-metadata.sql b/inst/db/00-metadata.sql similarity index 100% rename from inst/postgres/00-metadata.sql rename to inst/db/00-metadata.sql diff --git a/inst/postgres/01-initialize.sql b/inst/db/01-initialize.sql similarity index 100% rename from inst/postgres/01-initialize.sql rename to inst/db/01-initialize.sql diff --git a/inst/postgres/03-versioning.sql b/inst/db/03-versioning.sql similarity index 100% rename from inst/postgres/03-versioning.sql rename to inst/db/03-versioning.sql diff --git a/inst/db/migrations/000001_initialize_temporal_tables_extension.down.sql b/inst/db/migrations/000001_initialize_temporal_tables_extension.down.sql new file mode 100644 index 0000000..a7ff547 --- /dev/null +++ b/inst/db/migrations/000001_initialize_temporal_tables_extension.down.sql @@ -0,0 +1 @@ +DROP EXTENSION temporal_tables; diff --git a/inst/db/migrations/000001_initialize_temporal_tables_extension.up.sql b/inst/db/migrations/000001_initialize_temporal_tables_extension.up.sql new file mode 100644 index 0000000..0f70cfe --- /dev/null +++ b/inst/db/migrations/000001_initialize_temporal_tables_extension.up.sql @@ -0,0 +1 @@ +CREATE EXTENSION temporal_tables; diff --git a/inst/db/migrations/20240129082653_create_tables.down.sql b/inst/db/migrations/20240129082653_create_tables.down.sql new file mode 100644 index 0000000..18deffd --- /dev/null +++ b/inst/db/migrations/20240129082653_create_tables.down.sql @@ -0,0 +1,8 @@ +DROP TABLE patient_stratum; +DROP TABLE patient; +DROP TABLE numeric_constraint; +DROP TABLE factor_constraint; +DROP TABLE stratum_level; +DROP TABLE stratum; +DROP TABLE arm; +DROP TABLE study; \ No newline at end of file diff --git a/inst/db/migrations/20240129082653_create_tables.up.sql b/inst/db/migrations/20240129082653_create_tables.up.sql new file mode 100644 index 0000000..3ef3774 --- /dev/null +++ b/inst/db/migrations/20240129082653_create_tables.up.sql @@ -0,0 +1,174 @@ +CREATE TABLE study ( + id SERIAL PRIMARY KEY, + identifier VARCHAR(12) NOT NULL, + name VARCHAR(255) NOT NULL, + method VARCHAR(255) NOT NULL, + parameters JSONB, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + sys_period TSTZRANGE NOT NULL +); + +COMMENT ON TABLE study IS 'Stores information about various studies conducted.'; +COMMENT ON COLUMN study.id IS 'An auto-incrementing primary key uniquely identifying each study.'; +COMMENT ON COLUMN study.identifier IS 'A unique, short textual identifier for the study (max 12 characters).'; +COMMENT ON COLUMN study.name IS 'Provides the full name or title of the study.'; +COMMENT ON COLUMN study.method IS 'A randomization method name.'; +COMMENT ON COLUMN study.parameters IS 'JSONB column to store parameters related to the study.'; +COMMENT ON COLUMN study.timestamp IS 'Timestamp of when the record was created, defaults to current time.'; +COMMENT ON COLUMN study.sys_period IS 'TSTZRANGE type used for temporal versioning to track the validity period of each record.'; + +CREATE TABLE arm ( + id SERIAL PRIMARY KEY, + study_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + ratio INT NOT NULL DEFAULT 1, + sys_period TSTZRANGE NOT NULL, + CONSTRAINT arm_study + FOREIGN KEY (study_id) + REFERENCES study (id) ON DELETE CASCADE, + CONSTRAINT uc_arm_study + UNIQUE (id, study_id), + CONSTRAINT ratio_positive + CHECK (ratio > 0) +); + +COMMENT ON TABLE arm IS 'Represents the treatment arms within each study.'; +COMMENT ON COLUMN arm.id IS 'An auto-incrementing primary key that uniquely identifies each arm.'; +COMMENT ON COLUMN arm.study_id IS 'A foreign key that links each arm to its corresponding study.'; +COMMENT ON COLUMN arm.name IS 'Provides a descriptive name for the treatment arm.'; +COMMENT ON COLUMN arm.ratio IS 'Specifies the proportion of patients allocated to this arm. It defaults to 1 and must always be positive.'; +COMMENT ON COLUMN arm.sys_period IS 'TSTZRANGE type used for temporal versioning to track the validity period of each record.'; + +CREATE TABLE stratum ( + id SERIAL PRIMARY KEY, + study_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + value_type VARCHAR(12), + sys_period TSTZRANGE NOT NULL, + CONSTRAINT fk_study + FOREIGN KEY (study_id) + REFERENCES study (id) ON DELETE CASCADE, + CONSTRAINT chk_value_type + CHECK (value_type IN ('factor', 'numeric')) +); + +COMMENT ON TABLE stratum IS 'Defines the strata for patient categorization within each study.'; + +COMMENT ON COLUMN stratum.id IS 'An auto-incrementing primary key that uniquely identifies each stratum.'; +COMMENT ON COLUMN stratum.study_id IS 'A foreign key that links the stratum to a specific study.'; +COMMENT ON COLUMN stratum.name IS 'Provides a descriptive name for the stratum, such as a particular demographic or clinical characteristic.'; +COMMENT ON COLUMN stratum.value_type IS 'Indicates the type of value the stratum represents, limited to two types: ''factor'' or ''numeric''. ''factor'' represents categorical data, while ''numeric'' represents numerical data. This distinction is crucial as it informs the data validation logic applied in the system.'; +COMMENT ON COLUMN stratum.sys_period IS 'TSTZRANGE type used for temporal versioning to track the validity period of each record.'; + +CREATE TABLE stratum_level ( + stratum_id INT NOT NULL, + level VARCHAR(255) NOT NULL, + CONSTRAINT fk_stratum_level + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum_level + UNIQUE (stratum_id, level) +); +COMMENT ON TABLE stratum_level IS 'Keeps allowed stratum factor levels.'; + +COMMENT ON COLUMN stratum_level.stratum_id IS 'A foreign key that links the stratum level to a specific stratum.'; +COMMENT ON COLUMN stratum_level.level IS 'Level label, has to be unique within stratum.'; + +CREATE TABLE factor_constraint ( + stratum_id INT NOT NULL, + value VARCHAR(255) NOT NULL, + sys_period TSTZRANGE NOT NULL, + CONSTRAINT factor_stratum + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum_value + UNIQUE (stratum_id, value) +); + +COMMENT ON TABLE factor_constraint IS 'Defines constraints for strata of the ''factor'' type in studies. This table stores allowable values for each factor stratum, ensuring data consistency and integrity.'; + +COMMENT ON COLUMN factor_constraint.stratum_id IS 'A foreign key that links the constraint to a specific stratum in the ''stratum'' table.'; +COMMENT ON COLUMN factor_constraint.value IS 'Represents the specific allowable value for the factor stratum. This could be a categorical label like ''male'' or ''female'' for a gender stratum, for example.'; +COMMENT ON COLUMN factor_constraint.sys_period IS 'TSTZRANGE type used for temporal versioning to track the validity period of each record.'; + +CREATE TABLE numeric_constraint ( + stratum_id INT NOT NULL, + min_value FLOAT, + max_value FLOAT, + sys_period TSTZRANGE NOT NULL, + CONSTRAINT numeric_stratum + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT uc_stratum + UNIQUE (stratum_id), + CONSTRAINT chk_min_max + -- NULL is ok in checks, no need to test for it + CHECK (min_value <= max_value) +); + +COMMENT ON TABLE numeric_constraint IS 'Specifies constraints for strata of the ''numeric'' type in studies. This table defines the permissible range (minimum and maximum values) for each numeric stratum.'; + +COMMENT ON COLUMN numeric_constraint.stratum_id IS 'A foreign key that links the constraint to a specific numeric stratum in the ''stratum'' table.'; +COMMENT ON COLUMN numeric_constraint.min_value IS 'Defines the minimum allowable value for the stratum''s numeric values. Can be NULL, indicating that there is no lower bound.'; +COMMENT ON COLUMN numeric_constraint.max_value IS 'Defines the maximum allowable value for the stratum''s numeric values. Can be NULL, indicating that there is no upper bound.'; +COMMENT ON COLUMN numeric_constraint.sys_period IS 'TSTZRANGE type used for temporal versioning to track the validity period of each record.'; + +CREATE TABLE patient ( + id SERIAL PRIMARY KEY, + study_id INT NOT NULL, + arm_id INT, + used BOOLEAN NOT NULL DEFAULT false, + -- timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + sys_period TSTZRANGE NOT NULL, + CONSTRAINT patient_arm_study + FOREIGN KEY (arm_id, study_id) + REFERENCES arm (id, study_id) ON DELETE CASCADE, + CONSTRAINT used_with_arm + CHECK (NOT used OR arm_id IS NOT NULL) +); + + +COMMENT ON TABLE patient IS 'Represents individual patients participating in the studies.'; +COMMENT ON COLUMN patient.id IS 'An auto-incrementing primary key that uniquely identifies each patient.'; +COMMENT ON COLUMN patient.study_id IS 'A foreign key linking the patient to a specific study.'; +COMMENT ON COLUMN patient.arm_id IS 'An optional foreign key that links the patient to a specific treatment arm within the study.'; +COMMENT ON COLUMN patient.used IS 'A boolean flag indicating the state of the patient in the randomization process.'; +COMMENT ON COLUMN patient.sys_period IS 'Type TSTZRANGE, used for temporal versioning to track the validity period of each record.'; +COMMENT ON CONSTRAINT patient_arm_study ON patient IS 'Ensures referential integrity between patients, studies, and arms. It also cascades deletions to maintain consistency when a study or arm is deleted.'; +COMMENT ON CONSTRAINT used_with_arm ON patient IS 'Ensures logical consistency by allowing ''used'' to be true only if the patient is assigned to an arm (i.e., ''arm_id'' is not NULL). This prevents scenarios where a patient is marked as used but not assigned to any treatment arm.'; + + +CREATE TABLE patient_stratum ( + patient_id INT NOT NULL, + stratum_id INT NOT NULL, + fct_value VARCHAR(255), + num_value FLOAT, + sys_period TSTZRANGE NOT NULL, + CONSTRAINT fk_patient + FOREIGN KEY (patient_id) + REFERENCES patient (id) ON DELETE CASCADE, + CONSTRAINT fk_stratum_2 + FOREIGN KEY (stratum_id) + REFERENCES stratum (id) ON DELETE CASCADE, + CONSTRAINT chk_value_exists + -- Either factor or numeric value must be given + CHECK (fct_value IS NOT NULL OR num_value IS NOT NULL), + CONSTRAINT chk_one_value_only + -- Can't give both factor and numeric value + CHECK (fct_value IS NULL OR num_value IS NULL), + CONSTRAINT uc_patient_stratum + UNIQUE (patient_id, stratum_id) +); + + +COMMENT ON TABLE patient_stratum IS 'Associates patients with specific strata and records the corresponding stratum values.'; +COMMENT ON COLUMN patient_stratum.patient_id IS 'A foreign key that links to the ''patient'' table, identifying the patient.'; +COMMENT ON COLUMN patient_stratum.stratum_id IS 'A foreign key that links to the ''stratum'' table, identifying the stratum to which the patient belongs.'; +COMMENT ON COLUMN patient_stratum.fct_value IS 'Stores the categorical (factor) value for the patient in the corresponding stratum, if applicable.'; +COMMENT ON COLUMN patient_stratum.num_value IS 'Stores the numerical value for the patient in the corresponding stratum, if applicable.'; +COMMENT ON COLUMN patient_stratum.sys_period IS 'Type TSTZRANGE, used for temporal versioning to track the validity period of each record.'; +COMMENT ON CONSTRAINT fk_patient ON patient_stratum IS 'Links each patient-stratum pairing to the respective tables.'; +COMMENT ON CONSTRAINT fk_stratum_2 ON patient_stratum IS 'Links each patient-stratum pairing to the respective tables.'; +COMMENT ON CONSTRAINT chk_value_exists ON patient_stratum IS 'Ensures that either a factor or numeric value is provided for each record, aligning with the nature of the stratum.'; +COMMENT ON CONSTRAINT chk_one_value_only ON patient_stratum IS 'Ensures that each record has either a factor or a numeric value, but not both, maintaining the integrity of the data by ensuring it matches the stratum type (factor or numeric).'; +COMMENT ON CONSTRAINT uc_patient_stratum ON patient_stratum IS 'Ensures that each patient-stratum pairing is unique.'; diff --git a/inst/db/migrations/20240129082842_main_data_validation.down.sql b/inst/db/migrations/20240129082842_main_data_validation.down.sql new file mode 100644 index 0000000..4ffef4b --- /dev/null +++ b/inst/db/migrations/20240129082842_main_data_validation.down.sql @@ -0,0 +1,14 @@ +DROP TRIGGER patient_num_constraint ON patient_stratum; +DROP FUNCTION check_num_patient(); + +DROP TRIGGER patient_fct_constraint ON patient_stratum; +DROP FUNCTION check_fct_patient(); + +DROP TRIGGER patient_stratum_study_constraint ON patient_stratum; +DROP FUNCTION check_patient_stratum_study(); + +DROP TRIGGER stratum_num_constraint ON numeric_constraint; +DROP FUNCTION check_num_stratum(); + +DROP TRIGGER stratum_fct_constraint ON factor_constraint; +DROP FUNCTION check_fct_stratum(); \ No newline at end of file diff --git a/inst/db/migrations/20240129082842_main_data_validation.up.sql b/inst/db/migrations/20240129082842_main_data_validation.up.sql new file mode 100644 index 0000000..7d89f55 --- /dev/null +++ b/inst/db/migrations/20240129082842_main_data_validation.up.sql @@ -0,0 +1,134 @@ +-- Stratum constraint checks + +CREATE FUNCTION check_fct_stratum() +RETURNS trigger AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM stratum + -- Checks that column value is correct + WHERE id = NEW.stratum_id AND value_type = 'factor' + ) THEN + RAISE EXCEPTION 'Can''t set factor constraint for non-factor stratum.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER stratum_fct_constraint +BEFORE INSERT ON factor_constraint +FOR EACH ROW +EXECUTE PROCEDURE check_fct_stratum(); + + +CREATE FUNCTION check_num_stratum() +RETURNS trigger AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM stratum + -- Checks that column value is correct + WHERE id = NEW.stratum_id AND value_type = 'numeric' + ) THEN + RAISE EXCEPTION 'Can''t set numeric constraint for non-numeric stratum.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER stratum_num_constraint +BEFORE INSERT ON numeric_constraint +FOR EACH ROW +EXECUTE PROCEDURE check_num_stratum(); + +-- Patient stratum value checks + +-- Ensure that patients and strata are assigned to the same study. +CREATE FUNCTION check_patient_stratum_study() +RETURNS trigger AS $$ +BEGIN + DECLARE + patient_study INT := ( + SELECT study_id FROM patient + WHERE id = NEW.patient_id + ); + stratum_study INT := ( + SELECT study_id FROM stratum + WHERE id = NEW.stratum_id + ); + BEGIN + IF (patient_study <> stratum_study) THEN + RAISE EXCEPTION 'Stratum and patient must be assigned to the same study.'; + END IF; + END; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER patient_stratum_study_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_patient_stratum_study(); + +-- Validate and enforce factor stratum values. +CREATE FUNCTION check_fct_patient() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + WHERE id = NEW.stratum_id AND value_type = 'factor' + ) THEN + IF (NEW.fct_value IS NULL) THEN + RAISE EXCEPTION 'Factor stratum requires a factor value.'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM factor_constraint + WHERE stratum_id = NEW.stratum_id AND value = NEW.fct_value + ) THEN + RAISE EXCEPTION 'Factor value not specified as allowed.'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER patient_fct_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_fct_patient(); + +-- Validate and enforce numeric stratum values within specified constraints. +CREATE FUNCTION check_num_patient() +RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM stratum + WHERE id = NEW.stratum_id AND value_type = 'numeric' + ) THEN + IF (NEW.num_value IS NULL) THEN + RAISE EXCEPTION 'Numeric stratum requires a numeric value.'; + END IF; + DECLARE + min_value FLOAT := ( + SELECT min_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + max_value FLOAT := ( + SELECT max_value FROM numeric_constraint + WHERE stratum_id = NEW.stratum_id + ); + BEGIN + IF (min_value IS NOT NULL AND NEW.num_value < min_value) THEN + RAISE EXCEPTION 'New value is lower than minimum allowed value.'; + END IF; + IF (max_value IS NOT NULL AND NEW.num_value > max_value) THEN + RAISE EXCEPTION 'New value is greater than maximum allowed value.'; + END IF; + END; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER patient_num_constraint +BEFORE INSERT ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE check_num_patient(); diff --git a/inst/db/migrations/20240129084925_versioning.down.sql b/inst/db/migrations/20240129084925_versioning.down.sql new file mode 100644 index 0000000..73a9d56 --- /dev/null +++ b/inst/db/migrations/20240129084925_versioning.down.sql @@ -0,0 +1,20 @@ +DROP TRIGGER patient_stratum_versioning ON patient_stratum; +DROP TABLE patient_stratum_history; + +DROP TRIGGER patient_versioning ON patient; +DROP TABLE patient_history; + +DROP TRIGGER num_constraint_versioning ON numeric_constraint; +DROP TABLE numeric_constraint_history; + +DROP TRIGGER fct_constraint_versioning ON factor_constraint; +DROP TABLE factor_constraint_history; + +DROP TRIGGER stratum_versioning ON stratum; +DROP TABLE stratum_history; + +DROP TRIGGER arm_versioning ON arm; +DROP TABLE arm_history; + +DROP TRIGGER study_versioning ON study; +DROP TABLE study_history; \ No newline at end of file diff --git a/inst/db/migrations/20240129084925_versioning.up.sql b/inst/db/migrations/20240129084925_versioning.up.sql new file mode 100644 index 0000000..9572597 --- /dev/null +++ b/inst/db/migrations/20240129084925_versioning.up.sql @@ -0,0 +1,48 @@ +CREATE TABLE study_history (LIKE study); + +CREATE TRIGGER study_versioning +BEFORE INSERT OR UPDATE OR DELETE ON study +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'study_history', true); + +CREATE TABLE arm_history (LIKE arm); + +CREATE TRIGGER arm_versioning +BEFORE INSERT OR UPDATE OR DELETE ON arm +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'arm_history', true); + +CREATE TABLE stratum_history (LIKE stratum); + +CREATE TRIGGER stratum_versioning +BEFORE INSERT OR UPDATE OR DELETE ON stratum +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'stratum_history', true); + +CREATE TABLE factor_constraint_history (LIKE factor_constraint); + +CREATE TRIGGER fct_constraint_versioning +BEFORE INSERT OR UPDATE OR DELETE ON factor_constraint +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'factor_constraint_history', true); + +CREATE TABLE numeric_constraint_history (LIKE numeric_constraint); + +CREATE TRIGGER num_constraint_versioning +BEFORE INSERT OR UPDATE OR DELETE ON numeric_constraint +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'numeric_constraint_history', true); + +CREATE TABLE patient_history (LIKE patient); + +CREATE TRIGGER patient_versioning +BEFORE INSERT OR UPDATE OR DELETE ON patient +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'patient_history', true); + +CREATE TABLE patient_stratum_history (LIKE patient_stratum); + +CREATE TRIGGER patient_stratum_versioning +BEFORE INSERT OR UPDATE OR DELETE ON patient_stratum +FOR EACH ROW +EXECUTE PROCEDURE versioning('sys_period', 'patient_stratum_history', true); diff --git a/inst/postgres/90-examples.sql b/inst/postgres/90-examples.sql deleted file mode 100644 index 7b2f98b..0000000 --- a/inst/postgres/90-examples.sql +++ /dev/null @@ -1,29 +0,0 @@ -INSERT INTO study (identifier, name, method, parameters) -VALUES ('TEST', 'Badanie testowe', 'minimise_pocock', '{"method": "var", "p": 0.85, "weights": {"gender": 1}}'); - - -INSERT INTO arm (study_id, name, ratio) -VALUES (1, 'placebo', 2), - (1, 'active', 1); - -INSERT INTO stratum (study_id, name, value_type) -VALUES (1, 'gender', 'factor'); - -INSERT INTO factor_constraint (stratum_id, value) -VALUES (1, 'F'), (1, 'M'); - -INSERT INTO patient (study_id, arm_id) -VALUES (1, 1); - -INSERT INTO patient_stratum (patient_id, stratum_id, fct_value) -VALUES (1, 1, 'F'); - -UPDATE patient -SET used = true -WHERE id = 1; - --- Trigger properly raises an error here -/* -INSERT INTO numeric_constraint (stratum_id) -VALUES (1); -*/ diff --git a/migrate_db.sh b/migrate_db.sh new file mode 100644 index 0000000..e4d342a --- /dev/null +++ b/migrate_db.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +echo "Running database migrations" + +echo "Using database $POSTGRES_DB" + +DB_CONNECTION_STRING="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB?sslmode=disable" + +# Run the migrations, pass command line arguments to the migration tool +migrate -database "$DB_CONNECTION_STRING" -path ./inst/db/migrations "$@" \ No newline at end of file diff --git a/run_tests_local.sh b/run_tests_local.sh new file mode 100644 index 0000000..cd284fc --- /dev/null +++ b/run_tests_local.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +DB_NAME_SUFFIX="__test" + +ORIGINAL_DB_NAME="$POSTGRES_DB" +POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" +UNBIASED_PORT=3899 +UNBIASED_HOST="localhost" + +echo "Running tests" + +export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST +R --quiet --no-save -e "devtools::load_all(); testthat::test_package('unbiased')" diff --git a/entrypoint.sh b/start_unbiased_api.sh old mode 100755 new mode 100644 similarity index 62% rename from entrypoint.sh rename to start_unbiased_api.sh index d3370c3..008e121 --- a/entrypoint.sh +++ b/start_unbiased_api.sh @@ -5,4 +5,4 @@ set -e echo "Running unbiased" # R -e "devtools::install(quick = TRUE, upgrade = FALSE); unbiased::run_unbiased()" -R -e "devtools::load_all(); unbiased:::run_unbiased_local()" +R --quiet --no-save -e "devtools::load_all(); unbiased:::run_unbiased_local()" diff --git a/start_unbiased_api_for_tests.sh b/start_unbiased_api_for_tests.sh new file mode 100644 index 0000000..2386528 --- /dev/null +++ b/start_unbiased_api_for_tests.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +DB_NAME_SUFFIX="__test" + +ORIGINAL_DB_NAME="$POSTGRES_DB" +TEST_DB_NAME="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" +UNBIASED_PORT=3899 + +# Set a dummy GITHUB_SHA based on git ref HEAD +GITHUB_SHA=$(git rev-parse HEAD) + +# Create a new database for testing +echo "Creating test database $TEST_DB_NAME" +./run_psql.sh -c "CREATE DATABASE $TEST_DB_NAME" || \ + echo "Cannot create $TEST_DB_NAME, assuming it already exists" + +echo "Using test database $TEST_DB_NAME" +POSTGRES_DB="${TEST_DB_NAME}" + +export POSTGRES_DB UNBIASED_PORT GITHUB_SHA + +# Clear the test database +./clear_db.sh + +# Run the migrations +./migrate_db.sh up + +# Run the unbiased API +./start_unbiased_api.sh \ No newline at end of file diff --git a/tests/testthat/.gitignore b/tests/testthat/.gitignore new file mode 100644 index 0000000..6a5a9b5 --- /dev/null +++ b/tests/testthat/.gitignore @@ -0,0 +1 @@ +testthat-problems.rds \ No newline at end of file diff --git a/tests/testthat/fixtures/example_study.yml b/tests/testthat/fixtures/example_study.yml new file mode 100644 index 0000000..083c9f6 --- /dev/null +++ b/tests/testthat/fixtures/example_study.yml @@ -0,0 +1,42 @@ +study: + - identifier: 'TEST' + name: 'Test Study' + method: 'minimisation_pocock' + parameters: '{"method": "var", "p": 0.85, "weights": {"gender": 1}}' + # Waring: id is set automatically by the database + # do not set it manually because sequences will be out of sync + # and you will get errors + # id: 1 + +arm: + - study_id: 1 + name: 'placebo' + ratio: 2 + # id: 1 + - study_id: 1 + name: 'active' + ratio: 1 + # id: 2 + +stratum: + - study_id: 1 + name: 'gender' + value_type: 'factor' + # id: 1 + +factor_constraint: + - stratum_id: 1 + value: 'F' + - stratum_id: 1 + value: 'M' + +patient: + - study_id: 1 + arm_id: 1 + used: true + # id: 1 + +patient_stratum: + - patient_id: 1 + stratum_id: 1 + fct_value: 'F' diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R index 50d2e3f..45b735a 100644 --- a/tests/testthat/setup-DB.R +++ b/tests/testthat/setup-DB.R @@ -1,14 +1,14 @@ -if (is_CI()) { - # Define connection ---- - db_pool <- create_db_connection_pool() - conn <- pool::poolCheckout(db_pool) +db_pool <- create_db_connection_pool() +conn <- pool::poolCheckout(db_pool) - # Close DB connection upon exiting - withr::defer( - { - pool::poolReturn(conn) - pool::poolClose(db_pool) - }, - teardown_env() - ) -} +assign("conn", conn, envir = .GlobalEnv) +assign("db_pool", db_pool, envir = .GlobalEnv) + +# Close DB connection upon exiting +withr::defer( + { + pool::poolReturn(conn) + pool::poolClose(db_pool) + }, + teardown_env() +) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 081b53e..9632bb3 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -3,39 +3,11 @@ library(dplyr) library(dbplyr) library(httr2) -api_url <- "http://api:3838" +api_host <- Sys.getenv("UNBIASED_HOST", "api") +api_port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) -if (!isTRUE(as.logical(Sys.getenv("CI")))) { - withr::local_envvar( - # Extract current SHA and set it as a temporary env var - list(GITHUB_SHA = system("git rev-parse HEAD", intern = TRUE)) - ) - - # Overwrite API URL if not on CI - api_url <- "http://localhost:3838" - api_path <- tempdir() - - # Start the API - api <- callr::r_bg(\(path) { - # 1. Set path to `path` - # 2. Build a plumber API - plumber::plumb_api("unbiased", "unbiased_api") |> - plumber::pr_run(port = 3838) - }, args = list(path = api_path)) - - # Wait until started - while (!api$is_alive()) { - Sys.sleep(.2) - } - - # Close API upon exiting - withr::defer( - { - api$kill() - }, - teardown_env() - ) -} +api_url <- glue::glue("http://{api_host}:{api_port}") +print(glue::glue("API URL: {api_url}")) # Retry a request until the API starts request(api_url) |> diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index 3bdc64e..10cd7a8 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -1,16 +1,15 @@ # Named with '0' to make sure that this one runs first because it validates # basic properties of the database -skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") +# skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") + +source("./test-helpers.R") # Setup constants ---- -versioned_tables <- c( - "study", "arm", "stratum", "factor_constraint", - "numeric_constraint", "patient", "patient_stratum" -) -nonversioned_tables <- c("settings") + # Test values ---- test_that("database contains base tables", { + with_db_fixtures("fixtures/example_study.yml") expect_contains( DBI::dbListTables(conn), c(versioned_tables, nonversioned_tables) @@ -18,18 +17,9 @@ test_that("database contains base tables", { }) test_that("database contains history tables", { + with_db_fixtures("fixtures/example_study.yml") expect_contains( DBI::dbListTables(conn), glue::glue("{versioned_tables}_history") ) }) - -test_that("database version is the same as package version (did you update /inst/postgres/00-metadata.sql?)", { - expect_identical( - tbl(conn, "settings") |> - filter(key == "schema_version") |> - pull(value), - packageVersion("unbiased") |> - as.character() - ) -}) diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 3cd4b37..83371be 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -1,23 +1,26 @@ -skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") - -test_that("there's a study named 'Badanie testowe' in 'study' table", { - expect_contains( - tbl(conn, "study") |> - pull(name), - "Badanie testowe" - ) -}) - -test_that("study named 'Badanie testowe' has an identifier 'TEST'", { - expect_identical( - tbl(conn, "study") |> - filter(name == "Badanie testowe") |> - pull(identifier), - "TEST" - ) -}) +# skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") +source("./test-helpers.R") +# test_that("there's a study named 'Badanie testowe' in 'study' table", { +# with_db_fixtures("fixtures/example_study.yml") +# expect_contains( +# tbl(conn, "study") |> +# pull(name), +# "Badanie testowe" +# ) +# }) + +# test_that("study named 'Badanie testowe' has an identifier 'TEST'", { +# with_db_fixtures("fixtures/example_study.yml") +# expect_identical( +# tbl(conn, "study") |> +# filter(name == "Badanie testowe") |> +# pull(identifier), +# "TEST" +# ) +# }) test_that("it is enough to provide a name, an identifier, and a method id", { + with_db_fixtures("fixtures/example_study.yml") expect_no_error({ tbl(conn, "study") |> rows_append( @@ -31,11 +34,11 @@ test_that("it is enough to provide a name, an identifier, and a method id", { }) }) -new_study_id <- tbl(conn, "study") |> - filter(identifier == "FINE") |> - pull(id) +# first study id is 1 +new_study_id <- 1 |> as.integer() test_that("deleting archivizes a study", { + with_db_fixtures("fixtures/example_study.yml") expect_no_error({ tbl(conn, "study") |> rows_delete( @@ -51,14 +54,15 @@ test_that("deleting archivizes a study", { collect(), tibble( id = new_study_id, - identifier = "FINE", - name = "Correctly working study", + identifier = "TEST", + name = "Test Study", method = "minimisation_pocock" ) ) }) test_that("can't push arm with negative ratio", { + with_db_fixtures("fixtures/example_study.yml") expect_error({ tbl(conn, "arm") |> rows_append( @@ -73,6 +77,7 @@ test_that("can't push arm with negative ratio", { }) test_that("can't push stratum other than factor or numeric", { + with_db_fixtures("fixtures/example_study.yml") expect_error({ tbl(conn, "stratum") |> rows_append( @@ -87,6 +92,7 @@ test_that("can't push stratum other than factor or numeric", { }) test_that("can't push stratum level outside of defined levels", { + with_db_fixtures("fixtures/example_study.yml") # create a new patient return <- expect_no_error({ @@ -100,7 +106,7 @@ test_that("can't push stratum level outside of defined levels", { dbplyr::get_returned_rows() }) - added_patient_id <<- return$id + added_patient_id <- return$id expect_error({ tbl(conn, "patient_stratum") |> @@ -125,6 +131,8 @@ test_that("can't push stratum level outside of defined levels", { }) test_that("numerical constraints are enforced", { + with_db_fixtures("fixtures/example_study.yml") + added_patient_id <- 1 |> as.integer() return <- expect_no_error({ tbl(conn, "stratum") |> diff --git a/tests/testthat/test-helpers.R b/tests/testthat/test-helpers.R new file mode 100644 index 0000000..df73feb --- /dev/null +++ b/tests/testthat/test-helpers.R @@ -0,0 +1,59 @@ + +versioned_tables <- c( + "study", "arm", "stratum", "factor_constraint", + "numeric_constraint", "patient", "patient_stratum" +) +nonversioned_tables <- c() + +all_tables <- c( + versioned_tables, + nonversioned_tables, + versioned_tables |> paste0("_history") +) + +with_db_fixtures <- function(test_data_path, env = parent.frame()) { + conn <- get("conn", envir = .GlobalEnv) + + # load test data in yaml format + test_data <- yaml::read_yaml(test_data_path) + + # truncate tables before inserting data + truncate_tables(all_tables) + + for (table_name in names(test_data)) { + # get table data + table_data <- test_data[table_name] |> dplyr::bind_rows() + + DBI::dbWriteTable( + conn, + table_name, + table_data, + append = TRUE, + row.names = FALSE + ) + } + + withr::defer( + { + truncate_tables(all_tables) + }, + env + ) +} + +truncate_tables <- function(tables) { + DBI::dbExecute( + "SET client_min_messages TO WARNING;", + conn = get("conn", envir = .GlobalEnv) + ) + tables |> + rev() |> + purrr::walk( + \(table_name) { + glue::glue_sql( + "TRUNCATE TABLE {`table_name`} RESTART IDENTITY CASCADE;", + .con = get("conn", envir = .GlobalEnv) + ) |> DBI::dbExecute(conn = get("conn", envir = .GlobalEnv)) + } + ) +} \ No newline at end of file From 5d642912a83ba54f2d59a5c6d437edabb1e53e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 29 Jan 2024 14:53:14 +0000 Subject: [PATCH 134/240] autoreloader that works in WSL (without inotify support) --- autoreload_wsl_ntfs.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 autoreload_wsl_ntfs.sh diff --git a/autoreload_wsl_ntfs.sh b/autoreload_wsl_ntfs.sh new file mode 100644 index 0000000..a0b9b3d --- /dev/null +++ b/autoreload_wsl_ntfs.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +COMMAND=$1 + +echo "Running $COMMAND" + +watchmedo auto-restart \ + --patterns="*.R;*.txt" \ + --ignore-patterns="renv" \ + --recursive \ + --directory="./R" \ + --directory="./inst" \ + --directory="./tests" \ + --debounce-interval 1 \ + --debug-force-polling \ + -v \ + --no-restart-on-command-exit \ + "$@" \ No newline at end of file From 1f3a4309d3ce0808f5a5df5aaf489cf114601e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 29 Jan 2024 14:53:51 +0000 Subject: [PATCH 135/240] remove redundant sql files --- inst/db/00-metadata.sql | 11 -- inst/db/01-initialize.sql | 337 -------------------------------------- inst/db/03-versioning.sql | 48 ------ 3 files changed, 396 deletions(-) delete mode 100644 inst/db/00-metadata.sql delete mode 100644 inst/db/01-initialize.sql delete mode 100644 inst/db/03-versioning.sql diff --git a/inst/db/00-metadata.sql b/inst/db/00-metadata.sql deleted file mode 100644 index 2d5a30e..0000000 --- a/inst/db/00-metadata.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Create a table for storing application settings -CREATE EXTENSION temporal_tables; - -CREATE TABLE settings ( - key TEXT NOT NULL, - value TEXT NOT NULL -); - --- Insert initial schema version setting if it doesn't exist -INSERT INTO settings (key, value) -VALUES ('schema_version', '0.0.0.9003'); diff --git a/inst/db/01-initialize.sql b/inst/db/01-initialize.sql deleted file mode 100644 index 62022c9..0000000 --- a/inst/db/01-initialize.sql +++ /dev/null @@ -1,337 +0,0 @@ --- Table: study --- Purpose: Stores information about various studies conducted. --- 'id' is an auto-incrementing primary key uniquely identifying each study. --- 'identifier' is a unique, short textual identifier for the study (max 12 characters). --- 'name' provides the full name or title of the study. --- 'method' is a randomization method name --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. -CREATE TABLE study ( - id SERIAL PRIMARY KEY, - identifier VARCHAR(12) NOT NULL, - name VARCHAR(255) NOT NULL, - method VARCHAR(255) NOT NULL, - parameters JSONB, - timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - sys_period TSTZRANGE NOT NULL -); - --- Table: arm --- Purpose: Represents the treatment arms within each study. --- 'id' is an auto-incrementing primary key that uniquely identifies each arm. --- 'study_id' is a foreign key that links each arm to its corresponding study. --- 'name' provides a descriptive name for the treatment arm. --- 'ratio' specifies the proportion of patients allocated to this arm. It defaults to 1 and must always be positive. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'arm_study' foreign key constraint ensures that each arm is associated with a valid study. --- The 'uc_arm_study' unique constraint ensures that each combination of 'id' and 'study_id' is unique, --- which is important for maintaining data integrity across studies. --- The 'ratio_positive' check constraint ensures that the ratio is always greater than 0, --- maintaining logical consistency in the patient allocation process. -CREATE TABLE arm ( - id SERIAL PRIMARY KEY, - study_id INT NOT NULL, - name VARCHAR(255) NOT NULL, - ratio INT NOT NULL DEFAULT 1, - sys_period TSTZRANGE NOT NULL, - CONSTRAINT arm_study - FOREIGN KEY (study_id) - REFERENCES study (id) ON DELETE CASCADE, - CONSTRAINT uc_arm_study - UNIQUE (id, study_id), - CONSTRAINT ratio_positive - CHECK (ratio > 0) -); - --- Table: stratum --- Purpose: Defines the strata for patient categorization within each study. --- 'id' is an auto-incrementing primary key that uniquely identifies each stratum. --- 'study_id' is a foreign key that links the stratum to a specific study. --- 'name' provides a descriptive name for the stratum, such as a particular demographic or clinical characteristic. --- 'value_type' indicates the type of value the stratum represents, limited to two types: 'factor' or 'numeric'. --- 'factor' represents categorical data, while 'numeric' represents numerical data. --- This distinction is crucial as it informs the data validation logic applied in the system. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'fk_study' foreign key constraint ensures that each stratum is associated with a valid study and cascades deletions. --- The 'chk_value_type' check constraint ensures that the 'value_type' field only contains allowed values ('factor' or 'numeric'), --- enforcing data integrity and consistency in the type of stratum values. --- Subsequent validation checks in the system (like 'check_fct_stratum') use the 'value_type' field to ensure data integrity, --- by verifying that constraints on data (factor or numeric) align with the stratum type. -CREATE TABLE stratum ( - id SERIAL PRIMARY KEY, - study_id INT NOT NULL, - name VARCHAR(255) NOT NULL, - value_type VARCHAR(12), - sys_period TSTZRANGE NOT NULL, - CONSTRAINT fk_study - FOREIGN KEY (study_id) - REFERENCES study (id) ON DELETE CASCADE, - CONSTRAINT chk_value_type - CHECK (value_type IN ('factor', 'numeric')) -); - --- Table: stratum_level --- Purpose: Keeps allowed stratum factor levels --- 'id' is an auto-incrementing primary key that uniquely identifies each stratum. --- 'level' level label, has to be unique within stratum -CREATE TABLE stratum_level ( - stratum_id INT NOT NULL, - level VARCHAR(255) NOT NULL, - CONSTRAINT fk_stratum_level - FOREIGN KEY (stratum_id) - REFERENCES stratum (id) ON DELETE CASCADE, - CONSTRAINT uc_stratum_level - UNIQUE (stratum_id, level) -); - --- Table: factor_constraint --- Purpose: Defines constraints for strata of the 'factor' type in studies. --- This table stores allowable values for each factor stratum, ensuring data consistency and integrity. --- 'stratum_id' is a foreign key that links the constraint to a specific stratum in the 'stratum' table. --- 'value' represents the specific allowable value for the factor stratum. --- This could be a categorical label like 'male' or 'female' for a gender stratum, for example. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'factor_stratum' foreign key constraint ensures that each constraint is associated with a valid factor type stratum. --- The 'uc_stratum_value' unique constraint ensures that each combination of 'stratum_id' and 'value' is unique within the table. --- This prevents duplicate entries for the same stratum and value, maintaining the integrity of the constraint data. -CREATE TABLE factor_constraint ( - stratum_id INT NOT NULL, - value VARCHAR(255) NOT NULL, - sys_period TSTZRANGE NOT NULL, - CONSTRAINT factor_stratum - FOREIGN KEY (stratum_id) - REFERENCES stratum (id) ON DELETE CASCADE, - CONSTRAINT uc_stratum_value - UNIQUE (stratum_id, value) -); - --- Table: numeric_constraint --- Purpose: Specifies constraints for strata of the 'numeric' type in studies. --- This table defines the permissible range (minimum and maximum values) for each numeric stratum. --- 'stratum_id' is a foreign key that links the constraint to a specific numeric stratum in the 'stratum' table. --- 'min_value' and 'max_value' define the allowable range for the stratum's numeric values. --- For example, if the stratum represents age, 'min_value' and 'max_value' might define the age range for a study group. --- Either of these columns can be NULL, indicating that there is no lower or upper bound, respectively. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'numeric_stratum' foreign key constraint ensures that each constraint is associated with a valid numeric type stratum. --- The 'uc_stratum' unique constraint ensures that there is only one constraint entry per 'stratum_id'. --- The 'chk_min_max' check constraint ensures that 'min_value' is always less than or equal to 'max_value', --- maintaining logical consistency. If either value is NULL, the check constraint still holds valid as per SQL standards. -CREATE TABLE numeric_constraint ( - stratum_id INT NOT NULL, - min_value FLOAT, - max_value FLOAT, - sys_period TSTZRANGE NOT NULL, - CONSTRAINT numeric_stratum - FOREIGN KEY (stratum_id) - REFERENCES stratum (id) ON DELETE CASCADE, - CONSTRAINT uc_stratum - UNIQUE (stratum_id), - CONSTRAINT chk_min_max - -- NULL is ok in checks, no need to test for it - CHECK (min_value <= max_value) -); - --- Table: patient --- Purpose: Represents individual patients participating in the studies. --- 'id' is an auto-incrementing primary key that uniquely identifies each patient. --- 'study_id' is a foreign key linking the patient to a specific study. --- 'arm_id' is an optional foreign key that links the patient to a specific treatment arm within the study. --- For instance, in methods like simple randomization, 'arm_id' is assigned as patients are randomized. --- Conversely, in methods such as block randomization, 'arm_id' might be pre-assigned based on a predetermined randomization list. --- This flexible approach allows for accommodating various randomization methods and their unique requirements. --- 'used' is a boolean flag indicating the state of the patient in the randomization process. --- In methods like simple randomization, patients are entered into this table only when they are randomized, --- meaning 'used' will always be true for these entries, as there are no pre-plans in this method. --- For other methods, such as block randomization, 'used' is utilized to mark patients as 'used' --- according to a pre-planned randomization list, accommodating pre-assignment in these scenarios. --- This design allows the system to adapt to different randomization strategies effectively. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'patient_arm_study' foreign key constraint ensures referential integrity between patients, studies, and arms. --- It also cascades deletions to maintain consistency when a study or arm is deleted. --- The 'used_with_arm' check constraint ensures logical consistency by allowing 'used' to be true only if the patient --- is assigned to an arm (i.e., 'arm_id' is not NULL). --- This prevents scenarios where a patient is marked as used but not assigned to any treatment arm. -CREATE TABLE patient ( - id SERIAL PRIMARY KEY, - study_id INT NOT NULL, - arm_id INT, - used BOOLEAN NOT NULL DEFAULT false, - -- timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), - sys_period TSTZRANGE NOT NULL, - CONSTRAINT patient_arm_study - FOREIGN KEY (arm_id, study_id) - REFERENCES arm (id, study_id) ON DELETE CASCADE, - CONSTRAINT used_with_arm - CHECK (NOT used OR arm_id IS NOT NULL) -); - --- Table: patient_stratum --- Purpose: Associates patients with specific strata and records the corresponding stratum values. --- 'patient_id' is a foreign key that links to the 'patient' table, identifying the patient. --- 'stratum_id' is a foreign key that links to the 'stratum' table, identifying the stratum to which the patient belongs. --- 'fct_value' stores the categorical (factor) value for the patient in the corresponding stratum, if applicable. --- 'num_value' stores the numerical value for the patient in the corresponding stratum, if applicable. --- For example, if a stratum represents a demographic category, 'fct_value' might be used; --- if it represents a measurable characteristic like age, 'num_value' might be used. --- 'sys_period' is of type TSTZRANGE, used for temporal versioning to track the validity period of each record. --- The 'fk_patient' and 'fk_stratum_2' foreign key constraints link each patient-stratum pairing to the respective tables. --- The 'chk_value_exists' check constraint ensures that either a factor or numeric value is provided for each record, --- aligning with the nature of the stratum. --- The 'chk_one_value_only' check constraint ensures that each record has either a factor or a numeric value, but not both, --- maintaining the integrity of the data by ensuring it matches the stratum type (factor or numeric). -CREATE TABLE patient_stratum ( - patient_id INT NOT NULL, - stratum_id INT NOT NULL, - fct_value VARCHAR(255), - num_value FLOAT, - sys_period TSTZRANGE NOT NULL, - CONSTRAINT fk_patient - FOREIGN KEY (patient_id) - REFERENCES patient (id) ON DELETE CASCADE, - CONSTRAINT fk_stratum_2 - FOREIGN KEY (stratum_id) - REFERENCES stratum (id) ON DELETE CASCADE, - CONSTRAINT chk_value_exists - -- Either factor or numeric value must be given - CHECK (fct_value IS NOT NULL OR num_value IS NOT NULL), - CONSTRAINT chk_one_value_only - -- Can't give both factor and numeric value - CHECK (fct_value IS NULL OR num_value IS NULL), - CONSTRAINT uc_patient_stratum - UNIQUE (patient_id, stratum_id) -); - --- Stratum constraint checks - -CREATE OR REPLACE FUNCTION check_fct_stratum() -RETURNS trigger AS $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM stratum - -- Checks that column value is correct - WHERE id = NEW.stratum_id AND value_type = 'factor' - ) THEN - RAISE EXCEPTION 'Can''t set factor constraint for non-factor stratum.'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER stratum_fct_constraint -BEFORE INSERT ON factor_constraint -FOR EACH ROW -EXECUTE PROCEDURE check_fct_stratum(); - - -CREATE OR REPLACE FUNCTION check_num_stratum() -RETURNS trigger AS $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM stratum - -- Checks that column value is correct - WHERE id = NEW.stratum_id AND value_type = 'numeric' - ) THEN - RAISE EXCEPTION 'Can''t set numeric constraint for non-numeric stratum.'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER stratum_num_constraint -BEFORE INSERT ON numeric_constraint -FOR EACH ROW -EXECUTE PROCEDURE check_num_stratum(); - --- Patient stratum value checks - --- Ensure that patients and strata are assigned to the same study. -CREATE OR REPLACE FUNCTION check_patient_stratum_study() -RETURNS trigger AS $$ -BEGIN - DECLARE - patient_study INT := ( - SELECT study_id FROM patient - WHERE id = NEW.patient_id - ); - stratum_study INT := ( - SELECT study_id FROM stratum - WHERE id = NEW.stratum_id - ); - BEGIN - IF (patient_study <> stratum_study) THEN - RAISE EXCEPTION 'Stratum and patient must be assigned to the same study.'; - END IF; - END; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER patient_stratum_study_constraint -BEFORE INSERT ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE check_patient_stratum_study(); - --- Validate and enforce factor stratum values. -CREATE OR REPLACE FUNCTION check_fct_patient() -RETURNS trigger AS $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM stratum - WHERE id = NEW.stratum_id AND value_type = 'factor' - ) THEN - IF (NEW.fct_value IS NULL) THEN - RAISE EXCEPTION 'Factor stratum requires a factor value.'; - END IF; - IF NOT EXISTS ( - SELECT 1 FROM factor_constraint - WHERE stratum_id = NEW.stratum_id AND value = NEW.fct_value - ) THEN - RAISE EXCEPTION 'Factor value not specified as allowed.'; - END IF; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER patient_fct_constraint -BEFORE INSERT ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE check_fct_patient(); - --- Validate and enforce numeric stratum values within specified constraints. -CREATE OR REPLACE FUNCTION check_num_patient() -RETURNS trigger AS $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM stratum - WHERE id = NEW.stratum_id AND value_type = 'numeric' - ) THEN - IF (NEW.num_value IS NULL) THEN - RAISE EXCEPTION 'Numeric stratum requires a numeric value.'; - END IF; - DECLARE - min_value FLOAT := ( - SELECT min_value FROM numeric_constraint - WHERE stratum_id = NEW.stratum_id - ); - max_value FLOAT := ( - SELECT max_value FROM numeric_constraint - WHERE stratum_id = NEW.stratum_id - ); - BEGIN - IF (min_value IS NOT NULL AND NEW.num_value < min_value) THEN - RAISE EXCEPTION 'New value is lower than minimum allowed value.'; - END IF; - IF (max_value IS NOT NULL AND NEW.num_value > max_value) THEN - RAISE EXCEPTION 'New value is greater than maximum allowed value.'; - END IF; - END; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER patient_num_constraint -BEFORE INSERT ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE check_num_patient(); diff --git a/inst/db/03-versioning.sql b/inst/db/03-versioning.sql deleted file mode 100644 index 9572597..0000000 --- a/inst/db/03-versioning.sql +++ /dev/null @@ -1,48 +0,0 @@ -CREATE TABLE study_history (LIKE study); - -CREATE TRIGGER study_versioning -BEFORE INSERT OR UPDATE OR DELETE ON study -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'study_history', true); - -CREATE TABLE arm_history (LIKE arm); - -CREATE TRIGGER arm_versioning -BEFORE INSERT OR UPDATE OR DELETE ON arm -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'arm_history', true); - -CREATE TABLE stratum_history (LIKE stratum); - -CREATE TRIGGER stratum_versioning -BEFORE INSERT OR UPDATE OR DELETE ON stratum -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'stratum_history', true); - -CREATE TABLE factor_constraint_history (LIKE factor_constraint); - -CREATE TRIGGER fct_constraint_versioning -BEFORE INSERT OR UPDATE OR DELETE ON factor_constraint -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'factor_constraint_history', true); - -CREATE TABLE numeric_constraint_history (LIKE numeric_constraint); - -CREATE TRIGGER num_constraint_versioning -BEFORE INSERT OR UPDATE OR DELETE ON numeric_constraint -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'numeric_constraint_history', true); - -CREATE TABLE patient_history (LIKE patient); - -CREATE TRIGGER patient_versioning -BEFORE INSERT OR UPDATE OR DELETE ON patient -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'patient_history', true); - -CREATE TABLE patient_stratum_history (LIKE patient_stratum); - -CREATE TRIGGER patient_stratum_versioning -BEFORE INSERT OR UPDATE OR DELETE ON patient_stratum -FOR EACH ROW -EXECUTE PROCEDURE versioning('sys_period', 'patient_stratum_history', true); From 99c2ee684521f52fd1fe97f23e37e5092081d090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 29 Jan 2024 14:57:36 +0000 Subject: [PATCH 136/240] cleanup --- tests/testthat/setup-CI.R | 3 --- tests/testthat/test-DB-0.R | 1 - tests/testthat/test-DB-study.R | 19 ------------------- 3 files changed, 23 deletions(-) delete mode 100644 tests/testthat/setup-CI.R diff --git a/tests/testthat/setup-CI.R b/tests/testthat/setup-CI.R deleted file mode 100644 index 4d76a08..0000000 --- a/tests/testthat/setup-CI.R +++ /dev/null @@ -1,3 +0,0 @@ -is_CI <- function() { - isTRUE(as.logical(Sys.getenv("CI"))) -} diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index 10cd7a8..95f069c 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -1,6 +1,5 @@ # Named with '0' to make sure that this one runs first because it validates # basic properties of the database -# skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") source("./test-helpers.R") diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 83371be..218188f 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -1,23 +1,4 @@ -# skip_if_not(is_CI(), "DB tests require complex setup through Docker Compose") source("./test-helpers.R") -# test_that("there's a study named 'Badanie testowe' in 'study' table", { -# with_db_fixtures("fixtures/example_study.yml") -# expect_contains( -# tbl(conn, "study") |> -# pull(name), -# "Badanie testowe" -# ) -# }) - -# test_that("study named 'Badanie testowe' has an identifier 'TEST'", { -# with_db_fixtures("fixtures/example_study.yml") -# expect_identical( -# tbl(conn, "study") |> -# filter(name == "Badanie testowe") |> -# pull(identifier), -# "TEST" -# ) -# }) test_that("it is enough to provide a name, an identifier, and a method id", { with_db_fixtures("fixtures/example_study.yml") From 845c5d5f751a480ceb6be0db1d8418694886965c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 30 Jan 2024 07:23:57 +0000 Subject: [PATCH 137/240] coverage support for api testing --- R/run_api.R | 9 +++++- renv.lock | 17 +++++------ run_in_test_context.sh | 16 ++++++++++ run_tests_local.sh => run_tests.sh | 7 +++-- run_tests_with_coverage.sh | 18 ++++++++++++ tests/testthat/setup-api.R | 47 ++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 run_in_test_context.sh rename run_tests_local.sh => run_tests.sh (60%) create mode 100644 run_tests_with_coverage.sh diff --git a/R/run_api.R b/R/run_api.R index 1f94fb1..1914d9e 100644 --- a/R/run_api.R +++ b/R/run_api.R @@ -12,8 +12,15 @@ #' #' @export run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { - assign("db_connection_pool", create_db_connection_pool(), envir = globalenv()) + host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") + port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) + assign("db_connection_pool", + unbiased:::create_db_connection_pool(), + envir = globalenv() + ) + on.exit({ + db_connection_pool <- get("db_connection_pool", envir = globalenv()) pool::poolClose(db_connection_pool) assign("db_connection_pool", NULL, envir = globalenv()) }) diff --git a/renv.lock b/renv.lock index 92b9151..5b09e72 100644 --- a/renv.lock +++ b/renv.lock @@ -421,14 +421,14 @@ }, "glue": { "Package": "glue", - "Version": "1.6.2", + "Version": "1.7.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" + "Hash": "e0b3a53876554bd45879e596cdb10a52" }, "highr": { "Package": "highr", @@ -912,14 +912,14 @@ }, "rlang": { "Package": "rlang", - "Version": "1.1.2", + "Version": "1.1.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "50a6dbdc522936ca35afc5e2082ea91b" + "Hash": "42548638fae05fd9a9b5f3f437fbbbe2" }, "rmarkdown": { "Package": "rmarkdown", @@ -1212,16 +1212,15 @@ }, "withr": { "Package": "withr", - "Version": "2.5.2", + "Version": "3.0.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "grDevices", - "graphics", - "stats" + "graphics" ], - "Hash": "4b25e70111b7d644322e9513f403a272" + "Hash": "d31b6c62c10dcf11ec530ca6b0dd5d35" }, "xfun": { "Package": "xfun", diff --git a/run_in_test_context.sh b/run_in_test_context.sh new file mode 100644 index 0000000..2039d5c --- /dev/null +++ b/run_in_test_context.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +DB_NAME_SUFFIX="__test" + +ORIGINAL_DB_NAME="$POSTGRES_DB" +POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" +UNBIASED_PORT=3899 +UNBIASED_HOST="127.0.0.1" + +echo "Running provided command in test context" + +export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST + +"$@" \ No newline at end of file diff --git a/run_tests_local.sh b/run_tests.sh similarity index 60% rename from run_tests_local.sh rename to run_tests.sh index cd284fc..e0e9d5f 100644 --- a/run_tests_local.sh +++ b/run_tests.sh @@ -7,9 +7,12 @@ DB_NAME_SUFFIX="__test" ORIGINAL_DB_NAME="$POSTGRES_DB" POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" UNBIASED_PORT=3899 -UNBIASED_HOST="localhost" +UNBIASED_HOST="127.0.0.1" + +# Set a dummy GITHUB_SHA based on git ref HEAD +GITHUB_SHA=$(git rev-parse HEAD) echo "Running tests" -export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST +export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST GITHUB_SHA R --quiet --no-save -e "devtools::load_all(); testthat::test_package('unbiased')" diff --git a/run_tests_with_coverage.sh b/run_tests_with_coverage.sh new file mode 100644 index 0000000..cda576c --- /dev/null +++ b/run_tests_with_coverage.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +DB_NAME_SUFFIX="__test" + +ORIGINAL_DB_NAME="$POSTGRES_DB" +POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" +UNBIASED_PORT=3899 +UNBIASED_HOST="127.0.0.1" + +# Set a dummy GITHUB_SHA based on git ref HEAD +GITHUB_SHA=$(git rev-parse HEAD) + +echo "Running tests" + +export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST GITHUB_SHA +R --quiet --no-save -e "devtools::load_all(); covr::package_coverage('.')" diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 9632bb3..816f91f 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -9,6 +9,53 @@ api_port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) api_url <- glue::glue("http://{api_host}:{api_port}") print(glue::glue("API URL: {api_url}")) +working_directory <- + glue::glue(getwd(), "/../../") |> + normalizePath() + +plumber_process <- callr::r_bg( + \(working_directory) { + setwd(working_directory) + if (!requireNamespace("unbiased", quietly = TRUE)) { + print("Installing unbiased package using devtools") + devtools::load_all() + unbiased:::run_unbiased_local() + } else { + print("Running installed unbiased package") + unbiased:::run_unbiased() + } + }, + args = list(working_directory = working_directory), +) + +withr::defer( + { + print("Server STDOUT:") + while (length(lines <- plumber_process$read_output_lines())) { + print( + lines |> + paste(collapse = "\n") |> + stringr::str_squish() + ) + } + print("Server STDERR:") + while (length(lines <- plumber_process$read_error_lines())) { + message( + lines |> + paste(collapse = "\n") |> + stringr::str_squish() + ) + } + print("Sending SIGINT to plumber process") + plumber_process$interrupt() + + print("Waiting for plumber process to exit") + plumber_process$wait() + }, + teardown_env() +) + + # Retry a request until the API starts request(api_url) |> # Endpoint that should be always available From e02ddeaa2f79506507bc1cf26efe5a0904475d33 Mon Sep 17 00:00:00 2001 From: Ola Date: Wed, 31 Jan 2024 09:15:58 +0000 Subject: [PATCH 138/240] amendments to the adaptive randomisation simulation document --- renv.lock | 283 +---------- ...minimization_randomization_comparison.Rmd} | 438 ++++++------------ vignettes/references.bib | 43 +- vignettes/renv.lock | 2 +- 4 files changed, 194 insertions(+), 572 deletions(-) rename vignettes/{simulations.Rmd => minimization_randomization_comparison.Rmd} (56%) diff --git a/renv.lock b/renv.lock index 926831a..f450c87 100644 --- a/renv.lock +++ b/renv.lock @@ -31,7 +31,7 @@ "Package": "MASS", "Version": "7.3-57", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "grDevices", @@ -46,7 +46,7 @@ "Package": "Matrix", "Version": "1.4-1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "graphics", @@ -385,18 +385,6 @@ ], "Hash": "9b2191ede20fa29828139b9900922e51" }, - "cellranger": { - "Package": "cellranger", - "Version": "1.1.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "rematch", - "tibble" - ], - "Hash": "f61dbaec772ccd2e17705c1e872e9e7c" - }, "checkmate": { "Package": "checkmate", "Version": "2.2.0", @@ -413,7 +401,7 @@ "Package": "class", "Version": "7.3-20", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "MASS", "R", @@ -447,7 +435,7 @@ "Package": "codetools", "Version": "0.2-18", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R" ], @@ -495,19 +483,6 @@ "Repository": "RSPM", "Hash": "d691c61bff84bd63c383874d2d0c3307" }, - "conflicted": { - "Package": "conflicted", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "memoise", - "rlang" - ], - "Hash": "bb097fccb22d156624fd07cd2894ddb6" - }, "cpp11": { "Package": "cpp11", "Version": "0.4.6", @@ -710,25 +685,6 @@ ], "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" }, - "dtplyr": { - "Package": "dtplyr", - "Version": "1.3.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "data.table", - "dplyr", - "glue", - "lifecycle", - "rlang", - "tibble", - "tidyselect", - "vctrs" - ], - "Hash": "54ed3ea01b11e81a86544faaecfef8e2" - }, "e1071": { "Package": "e1071", "Version": "1.7-14", @@ -846,28 +802,6 @@ ], "Hash": "47b5f30c720c23999b913a1a635cf0bb" }, - "gargle": { - "Package": "gargle", - "Version": "1.5.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "fs", - "glue", - "httr", - "jsonlite", - "lifecycle", - "openssl", - "rappdirs", - "rlang", - "stats", - "utils", - "withr" - ], - "Hash": "fc0b272e5847c58cd5da9b20eedbd026" - }, "gdata": { "Package": "gdata", "Version": "3.0.0", @@ -981,59 +915,6 @@ ], "Hash": "6713a242cb6909e492d8169a35dfe0b0" }, - "googledrive": { - "Package": "googledrive", - "Version": "2.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "gargle", - "glue", - "httr", - "jsonlite", - "lifecycle", - "magrittr", - "pillar", - "purrr", - "rlang", - "tibble", - "utils", - "uuid", - "vctrs", - "withr" - ], - "Hash": "e99641edef03e2a5e87f0a0b1fcc97f4" - }, - "googlesheets4": { - "Package": "googlesheets4", - "Version": "1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cellranger", - "cli", - "curl", - "gargle", - "glue", - "googledrive", - "httr", - "ids", - "lifecycle", - "magrittr", - "methods", - "purrr", - "rematch2", - "rlang", - "tibble", - "utils", - "vctrs", - "withr" - ], - "Hash": "d6db1667059d027da730decdc214b959" - }, "gsDesign": { "Package": "gsDesign", "Version": "3.6.0", @@ -1268,17 +1149,6 @@ ], "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" }, - "ids": { - "Package": "ids", - "Version": "1.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "openssl", - "uuid" - ], - "Hash": "99df65cfef20e525ed38c3d2577f7190" - }, "ini": { "Package": "ini", "Version": "0.3.1", @@ -1513,7 +1383,7 @@ "Package": "mgcv", "Version": "1.8-40", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "Matrix", "R", @@ -1570,24 +1440,6 @@ ], "Hash": "a4b659bd0528226724d55034f11ed7cb" }, - "modelr": { - "Package": "modelr", - "Version": "0.1.11", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "magrittr", - "purrr", - "rlang", - "tibble", - "tidyr", - "tidyselect", - "vctrs" - ], - "Hash": "4f50122dc256b1b6996a4703fecea821" - }, "modeltools": { "Package": "modeltools", "Version": "0.2-23", @@ -1669,7 +1521,7 @@ "Package": "nlme", "Version": "3.1-157", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "graphics", @@ -2120,28 +1972,6 @@ ], "Hash": "b5047343b3825f37ad9d3b5d89aa1078" }, - "readxl": { - "Package": "readxl", - "Version": "1.4.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cellranger", - "cpp11", - "progress", - "tibble", - "utils" - ], - "Hash": "8cf9c239b96df1bbb133b74aef77ad0a" - }, - "rematch": { - "Package": "rematch", - "Version": "2.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "cbff1b666c6fa6d21202f07e2318d4f1" - }, "rematch2": { "Package": "rematch2", "Version": "2.1.2", @@ -2176,28 +2006,6 @@ ], "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" }, - "reprex": { - "Package": "reprex", - "Version": "2.0.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "callr", - "cli", - "clipr", - "fs", - "glue", - "knitr", - "lifecycle", - "rlang", - "rmarkdown", - "rstudioapi", - "utils", - "withr" - ], - "Hash": "d66fe009d4c20b7ab1927eb405db9ee2" - }, "reshape2": { "Package": "reshape2", "Version": "1.4.4", @@ -2213,14 +2021,14 @@ }, "rlang": { "Package": "rlang", - "Version": "1.1.2", + "Version": "1.1.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "50a6dbdc522936ca35afc5e2082ea91b" + "Hash": "42548638fae05fd9a9b5f3f437fbbbe2" }, "rmarkdown": { "Package": "rmarkdown", @@ -2301,26 +2109,6 @@ ], "Hash": "a9881dfed103e83f9de151dc17002cd1" }, - "rvest": { - "Package": "rvest", - "Version": "1.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "httr", - "lifecycle", - "magrittr", - "rlang", - "selectr", - "tibble", - "withr", - "xml2" - ], - "Hash": "a4a5ac819a467808c60e36e92ddf195e" - }, "sandwich": { "Package": "sandwich", "Version": "3.1-0", @@ -2368,19 +2156,6 @@ ], "Hash": "c19df082ba346b0ffa6f833e92de34d1" }, - "selectr": { - "Package": "selectr", - "Version": "0.4-2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "methods", - "stringr" - ], - "Hash": "3838071b66e0c566d55cc26bd6e27bf4" - }, "sessioninfo": { "Package": "sessioninfo", "Version": "1.2.2", @@ -2518,7 +2293,7 @@ "Package": "survival", "Version": "3.3-1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "Matrix", "R", @@ -2670,46 +2445,6 @@ ], "Hash": "79540e5fcd9e0435af547d885f184fd5" }, - "tidyverse": { - "Package": "tidyverse", - "Version": "2.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "cli", - "conflicted", - "dbplyr", - "dplyr", - "dtplyr", - "forcats", - "ggplot2", - "googledrive", - "googlesheets4", - "haven", - "hms", - "httr", - "jsonlite", - "lubridate", - "magrittr", - "modelr", - "pillar", - "purrr", - "ragg", - "readr", - "readxl", - "reprex", - "rlang", - "rstudioapi", - "rvest", - "stringr", - "tibble", - "tidyr", - "xml2" - ], - "Hash": "c328568cd14ea89a83bd4ca7f54ae07e" - }, "timechange": { "Package": "timechange", "Version": "0.2.0", diff --git a/vignettes/simulations.Rmd b/vignettes/minimization_randomization_comparison.Rmd similarity index 56% rename from vignettes/simulations.Rmd rename to vignettes/minimization_randomization_comparison.Rmd index 34af028..abb0b0b 100644 --- a/vignettes/simulations.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -15,39 +15,27 @@ link-citations: true ```{r, include = FALSE} knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" + collapse = TRUE ) ``` -```{=html} - -``` - ## Introduction -Randomization in clinical trials is the gold standard and is considered the best widely accepted design for evaluating the effectiveness of new treatments compared to alternative treatments (standard of care) or placebo. Randomization has many advantages. Firstly, it allows the elimination of random errors, including selection biases. This helps avoid any bias in the planning stage of the study protocol, influencing the quality of such a study. Additionally, through randomization, it is possible to match groups based on similarity using specified stratifying factors. This enables the even distribution of factors among groups. If the results of the treated group and the control group show differences, it will be the only difference between the study arms. This means that this difference is caused by the treatment, not the variation in groups based on baseline characteristics [@lim2019randomization]. +Randomization in clinical trials is the gold standard and is widely considered the best design for evaluating the effectiveness of new treatments compared to alternative treatments (standard of care) or placebo. Indeed, the selection of an appropriate randomisation is as important as the selection of an appropriate statistical analysis for the study and the analysis strategy, whether based on randomisation or on a population model (@berger2021roadmap). + +One of the primary advantages of randomization, particularly simple randomization (usually using flipping a coin method), is its ability to balance confounding variables across treatment groups. This is especially effective in large sample sizes (n > 200), where the random allocation of participants helps to ensure that both known and unknown confounders are evenly distributed between the study arms. This balanced distribution contributes significantly to the internal validity of the study, as it minimizes the risk of selection bias and confounding influencing the results (@lim2019randomization). + +It's important to note, however, that while simple randomization is powerful in large trials, it may not always guarantee an even distribution of confounding factors in trials with smaller sample sizes (n < 100). In such cases, the random allocation might result in imbalances in baseline characteristics between groups, which can affect the interpretation of the treatment's effectiveness. This potential limitation sets the stage for considering additional methods, such as stratified randomization, or dynamic minimization algorithms to address these challenges in smaller trials (@kang2008issues). -This document provides a summary of the comparison of three randomization methods: simple randomization, block randomization, and adaptive randomization. Simple randomization and adaptive randomization (minimization method) are tools available in the `unbiased` package as `randomize_simple` and `randomize_minimisation_pocock` functions. The comparison aims to demonstrate the superiority of adaptive randomization (minimization method) over other methods in assessing the least imbalance of accompanying variables between therapeutic groups. Monte Carlo simulations were used to generate data, utilizing the `simstudy` package [@goldfeld2020simstudy]. Parameters for the beta distribution of variables were based on data from the publication by @mrozikiewicz2023allogenic and information from researchers. +This document provides a summary of the comparison of three randomization methods: simple randomization, block randomization, and adaptive randomization. Simple randomization and adaptive randomization (minimization method) are tools available in the `unbiased` package as `randomize_simple` and `randomize_minimisation_pocock` functions (@unbiased). The comparison aims to demonstrate the superiority of adaptive randomization (minimization method) over other methods in assessing the least imbalance of accompanying variables between therapeutic groups. Monte Carlo simulations were used to generate data, utilizing the `simstudy` package (@goldfeld2020simstudy). Parameters for the binary distribution of variables were based on data from the publication by @mrozikiewicz2023allogenic and information from researchers. -The document structure is as follows: first, the distributions of selected parameters from the publication are verified; then, data are generated for a specified number of simulations and patients using Monte-Carlo simulations; subsequently, for each randomization method, including minimization with different weights, for each simulation and for each patient, the treatment group is assigned; based on this, data frames are generated for each method; then, using summaries, the results of frequency and chi^2 test or Fisher's exact test for covariates in each method are displayed; finally, three summaries are presented: a boxplot showing the standardized mean difference (SMD) generated as the average from all covariates, violin plots showing the distribution of each covariate for each method, and a summary of success defined as the percentage of events for which the SMD for each covariate is less than 0.2. +The document structure is as follows: first, data will be simulated using the Monte-Carlo method, in the next step, for each of the given simulations, study groups will be assigned to all patients using the three randomisation methods - these data will be summarised for the first iteration using statistical tests, finally, the results based on the standardised mean difference (SMD) test will be discussed in visual form (boxplot, violin plot) and as a percentage of success achieved in each method for the given precision (tabular summary). ```{r setup, warning = FALSE, message=FALSE} # load packages library(unbiased) library(dplyr) library(devtools) -# installed from github -# devtools::install_github('kaneplusplus/bigmemory') -library(bigmemory) library(simstudy) library(tableone) library(checkmate) @@ -55,7 +43,7 @@ library(ggplot2) library(gt) library(gtsummary) library(truncnorm) -library(tidyverse) +library(tidyr) library(randomizeR) ``` @@ -63,17 +51,17 @@ library(randomizeR) In the process of comparing the balance of covariates among randomization methods, three randomization methods have been selected for evaluation: -- **simple randomization** - randomization that gives participants equal chances of being assigned to a particular group, which occurs randomly. The method's advantage lies in its simplicity and the elimination of predictability. However, due to its complete randomness, it may lead to imbalance in sample sizes between groups (even assuming a 1:1 randomization ratio) and imbalances between prognostic factors. +- **simple randomization** - simple coin toss, algorithm that gives participants equal chances of being assigned to a particular arm. The method's advantage lies in its simplicity and the elimination of predictability. However, due to its complete randomness, it may lead to imbalance in sample sizes between arms and imbalances between prognostic factors. For a large sample size (n > 200), simple randomisation gives a similar number of generated participants in each group. For a small sample size (n < 100), it results in an imbalance (@kang2008issues). -- **block randomization** - a randomization method that takes into account defined covariates and specified allocation ratios for patients in each group. The method involves assigning patients to therapeutic groups in blocks of a fixed size, with the recommendation that the blocks have different sizes. This, to some extent, reduces the risk of researchers predicting future group assignments. In contrast to simple randomization, the block method aims to balance the number of patients in groups, eliminating potential imbalance between groups (@rosenberger2015randomization). +- **block randomization** - a randomization method that takes into account defined covariates for patients. The method involves assigning patients to therapeutic arms in blocks of a fixed size, with the recommendation that the blocks have different sizes. This, to some extent, reduces the risk of researchers predicting future arm assignments. In contrast to simple randomization, the block method aims to balance the number of patients within the block, hence reducing the overall imbalance between arms (@rosenberger2015randomization). -- **adaptive randomization using minimization method** - a randomization method aiming to balance prognostic factors by determining the total imbalance with the emergence of each new patient in the study. The method selects the smallest imbalance from all total imbalances based on a specified method such as variance, and then assigns the patient to the group with the smallest imbalance with a predetermined probability of allocation. This method was described in the publication by @pocock1975sequential. +- **adaptive randomization using minimization method** based on @pocock1975sequential algorithm - - this randomization approach aims to balance prognostic factors across treatment arms within a clinical study. It functions by evaluating the total imbalance of these factors each time a new patient is considered for the study. The minimization method computes the overall imbalance for each potential arm assignment of the new patient, considering factors like variance or other specified criteria. The patient is then assigned to the arm where their addition results in the smallest total imbalance. This assignment is not deterministic but is made with a predetermined probability, ensuring some level of randomness in arm allocation. This method is particularly useful in trials with multiple prognostic factors or in smaller studies where traditional randomization might fail to achieve balance. ## Assessment of covariate balance -In the proposed approach to the assessment of randomization methods, the primary objective is to evaluate each method in terms of achieving balance in the specified covariates. The assessment of balance aims to determine whether the distributions of covariates are similarly balanced in each therapeutic group. Based on the literature, standardized mean differences (SMD) have been employed for assessing balance. +In the proposed approach to the assessment of randomization methods, the primary objective is to evaluate each method in terms of achieving balance in the specified covariates. The assessment of balance aims to determine whether the distributions of covariates are similarly balanced in each therapeutic group. Based on the literature, standardized mean differences (SMD) have been employed for assessing balance (@berger2021roadmap). -The SMD method is one of the most commonly used statistics for assessing the balance of covariates, regardless of the unit of measurement. It is a statistical measure for comparing differences between two groups.The covariates in the examined case are expressed as binary variables. In the case of categorical variables, SMD is calculated using the following formula (@zhang2019balance): +The SMD method is one of the most commonly used statistics for assessing the balance of covariates, regardless of the unit of measurement. It is a statistical measure for comparing differences between two groups. The covariates in the examined case are expressed as binary variables. In the case of categorical variables, SMD is calculated using the following formula (@zhang2019balance): \[ SMD = \frac{{p_1 - p_2}}{{\sqrt{\frac{{p_1 \cdot (1 - p_1) + p_2 \cdot (1 - p_2)}}{2}}}} \], @@ -85,18 +73,22 @@ where: ## Definied number of patients and number of iterations -Firstly, to create the simulation and generate data, it is crucial to determine the number of patients who will be randomized. In the study, it is assumed that 105 patients will be randomized, with 35 patients in each of the research groups (Group A, Group B, Group C) (ratio 1:1:1). +In this simulation, we are using a real use case - the planned FootCell study - non-commercial clinical research in the area of civilisation diseases - to guide our data generation process. For the FootCell study, it is anticipated that a total of 105 patients will be randomized into the trial. These patients will be equally divided among three research groups - Group A, Group B, and Group C - with each group comprising 35 patients. -The next step is to define the number of iterations. The more iterations are applied, the greater the certainty about the true outcome. For the Monte Carlo simulation, 1000 iterations have been adopted. +The number of iterations, indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters. ```{r, define-parameters} # defined number of patients n <- 105 # defined number of iterations -no_of_iterations <- 10 +no_of_iterations <- 20 ``` -## Defining parameters for Monte-Carlo simulation{#truncparam} +## Defining parameters for Monte-Carlo simulation + +The distribution of parameters for individual covariates, which will subsequently be used to validate randomization methods, has been defined using the publication @mrozikiewicz2023allogenic on allogenic interventions.. + +The publication describes the effectiveness of comparing therapy using ADSC (Adipose-Derived Stem Cells) gel versus standard therapy with fibrin gel for patients in diabetic foot ulcer treatment. The FootCell study also aims to assess the safety of advanced therapy involving live ASCs (Adipose-Derived Stem Cells) in the treatment of diabetic foot syndrome, considering two groups treated with ADSCs (one or two administrations) compared to fibrin gel. Therefore, appropriate population data have been extracted from the publication to determine distributions that can be maintained when designing the FootCell study. In the process of defining the study for randomization, the following covariates have been selected: @@ -112,203 +104,86 @@ In the process of defining the study for randomization, the following covariates - **wound size** [up to 2/above 2] [cm\(^2\)]. -In the case of the variables gender and diabetes type in the publication @mrozikiewicz2023allogenic, they were expressed in the form of frequencies. The remaining variables were presented in terms of measures of central tendency along with an indication of variability, as well as minimum and maximum values. To determine the parameters alpha and beta for the binary distribution, the truncated normal distribution available in the `truncnorm` package was utilized. The truncated normal distribution is often used in statistics and probability modeling when dealing with data that is constrained to a certain range. It is particularly useful when you want to model a random variable that cannot take values beyond certain limits (@burkardt2014truncated). +In the case of the variables gender and diabetes type in the publication @mrozikiewicz2023allogenic, they were expressed in the form of frequencies. The remaining variables were presented in terms of measures of central tendency along with an indication of variability, as well as minimum and maximum values. To determine the parameters for the binary distribution, the truncated normal distribution available in the `truncnorm` package was utilized. The truncated normal distribution is often used in statistics and probability modeling when dealing with data that is constrained to a certain range. It is particularly useful when you want to model a random variable that cannot take values beyond certain limits (@burkardt2014truncated). -To generate the necessary information for the remaining covariates, a function `simulate_parameters_trunc` was written, utilizing the `rtruncnorm function`. The parameters `mean`, `sd`, `lower`, `upper` were taken from the publication and based on expertise regarding the ranges for the parameters. +To generate the necessary information for the remaining covariates, a function `simulate_proportions_trunc` was written, utilizing the `rtruncnorm function` (@truncnorm). The parameters `mean`, `sd`, `lower`, `upper` were taken from the publication and based on expertise regarding the ranges for the parameters. -```{r, parameters-function} -# simulation parameters using truncated normal distribution -simulate_parameters_trunc <- - - function(n, lower, upper, mean, sd, number) { - simulate <- sapply(1:1000, function(i) +The results are presented in a table, assuming that the outcome refers to the first category of each parameter. + +```{r, simulate-proportions-function} +# simulate parameters using truncated normal distribution +simulate_proportions_trunc <- + function(n, lower, upper, mean, sd, threshold) { + simulate_data <- rtruncnorm( n = n, a = lower, b = upper, mean = mean, - sd = sd - )) |> - as.data.frame() - - simulate_long <- simulate |> - pivot_longer(cols = everything(), - names_to = "Simulation", - values_to = "Value") |> - arrange(Simulation) + sd = sd) <= threshold - result <- - simulate_long |> - group_by(Simulation) |> - transmute(Simulation, n = sum(Value <= number)) |> - distinct() - - mean <- mean(result$n) - - return(mean) -} + sum(simulate_data == TRUE)/n + } ``` -```{r, parameters-result} -# Using the function for covariates - -# hba1c -hba1c = - simulate_parameters_trunc(46, 0, 11, 7.41, 1.33, 9) - -# tpo2 -tpo2 = - simulate_parameters_trunc(46, 30, 100, 53.4, 18.4, 50) - -# age -age = - simulate_parameters_trunc(46, 0, 100, 59.2, 9.7, 55) - -# wound_size -wound_size = - simulate_parameters_trunc(46, 0, 20, 2.7, 2.28, 2) -``` +```{r, parameters-result-table, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} -The results are presented in a table, assuming that the outcome refers to the first category of each parameter. +set.seed(123) -```{r, parameters-result-table, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} -# summary table -data.frame(hba1c, tpo2, age, wound_size) %>% - rename('wound size' = wound_size) %>% +data.frame( + hba1c = simulate_proportions_trunc(1000, 0, 11, 7.41, 1.33, 9), + tpo2 = simulate_proportions_trunc(1000, 30, 100, 53.4, 18.4, 50), + age = simulate_proportions_trunc(1000, 0, 100, 59.2, 9.7, 55), + wound_size = simulate_proportions_trunc(1000, 0, 20, 2.7, 2.28, 2) +) |> + rename('wound size' = wound_size) |> pivot_longer(cols = everything(), names_to = "parametr", - values_to = "n") %>% - mutate( - n = ceiling(n), - percent = round(n / 46, 2), - strata = c('<=9', '<=50', '<=55', '<=2') - ) %>% - rename('Percent of N = 46' = percent) %>% - gt() + values_to = "proportions") |> +mutate('first catogory of strata' = c('<=9', '<=50', '<=55', '<=2')) |> +gt() ``` ## Generate data using Monte-Carlo simulations -To generate data based on the assumptions summarized in the last section and taking into account the suggestions from physicians, a function called `build_prior_df` was created. This function, relying on the conjugate beta distribution, generated random samples based on the defined parameters for various variables. Conjugate priors have the advantageous property that, after incorporating data (likelihood), the posterior distribution is of the same type as the prior, simplifying calculations and result interpretation (@hodel2023beta). - -```{r, build-prior} -set.seed(123) - -# create beta distribution parameters -build_prior_df <- function(no_of_iterations, n) { - binary_matrix <- tribble( - ~ var, - ~ distribution, - ~ alpha, #1 - ~ beta, #0 - "sex", # Parameters provided by researchers - "beta", - 1 + 900, #men - 1 + 100, #women - "diabetes_type", # Parameters provided by researchers - "beta", - 1 + 150, # type I - 1 + 850, # type II - "hba1c", # (Mrozikiewicz-Rakowska et. al., 2023) - "beta", - 1 + 41, # <=9 - 1 + 5, # (9,11> - "tpo2", # (Mrozikiewicz-Rakowska et. al., 2023) - "beta", - 1 + 17, #<=50 mmHg - 1 + 29, #> 50 mmHg, - "age", # Parameters provided by researchers - "beta", - 1+300, # <=55 - 1+700, # >55 - "wound_size", # Parameters provided by researchers - "beta", - 1+300, # <=2cm^2 - 1+700 # > 2cm^2 - ) |> - rowwise() |> - mutate( - mean = alpha / (alpha + beta), - sd = sqrt(alpha * beta / ((alpha + beta + 1) * (alpha + beta) ** - 2)), - example = rbeta(1, shape1 = alpha, shape2 = beta) - ) |> - ungroup() - - params_binary <- - mapply(function(n, alpha, beta) { - rbeta(n, alpha, beta) - }, - no_of_iterations, - binary_matrix$alpha, - binary_matrix$beta, - SIMPLIFY = FALSE) - names(params_binary) <- binary_matrix$var - - params <- - params_binary |> - bind_rows() - - return(params) -} -``` - -The table contains a summary of the draw results for the distribution of parameters for the first iteration. - -```{r, params} -params <- - build_prior_df(no_of_iterations = no_of_iterations, n = n) - -params[1,] |> - gt() -``` - -In the next step, additional variables were defined using the `simstudy` package, utilizing the `defData` function. Due to the likely association between the type of diabetes and age – meaning that the older the patient, the higher the probability of having type II diabetes – a relationship with diabetes was established when defining the `age` variable using a logit function (link = "logit"). - -```{r, generate-outcomes} -# generate outcomes -generate_outcomes <- function(row, n) { - results <- - row |> - with({ - simstudy::defData(varname = "sex", - formula = sex, - dist = "binary") |> - simstudy::defData(varname = "diabetes_type", - formula = diabetes_type, - dist = "binary") |> - simstudy::defData(varname = "hba1c", - formula = hba1c, - dist = "binary") |> - simstudy::defData(varname = "tpo2", - formula = tpo2, - dist = "binary") |> - simstudy::defData( - varname = "age", - # correlation with diabetes type - assumptions - younger patients are more likely to have type I diabetes - formula = "(diabetes_type==0) * (-3.5)", - link = "logit", - dist = "binary" - ) |> - simstudy::defData(varname = "wound_size", - formula = wound_size, - dist = "binary") |> - simstudy::genData(n, dtDefs = _) - }) -} +Monte-Carlo simulations were used to accumulate the data. This method is designed to model variables based on defined parameters. Variables were defined using the `simstudy` package, utilizing the `defData` function (@goldfeld2020simstudy). As all variables specify proportions, `dist = 'binary'` was used to define the variables. Due to the likely association between the type of diabetes and age – meaning that the older the patient, the higher the probability of having type II diabetes – a relationship with diabetes was established when defining the `age` variable using a logit function `link = "logit"`. The proportions for gender and diabetes were defined by the researchers and were consistent with the literature @mrozikiewicz2023allogenic. + +Using the `simulate_data_monte_carlo` function (using `genData` function - `simstudy` package), a data frame was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients in each iteration. + +```{r, defdata} +# male - 0.9 +def <- simstudy::defData(varname = "sex", formula = "0.9", dist = "binary") +# type I - 0.15 +def <- simstudy::defData(def, varname = "diabetes_type", formula = "0.15", dist = "binary") +# <= 9 - 0.888 +def <- simstudy::defData(def, varname = "hba1c", formula = "0.888", dist = "binary") +# <= 50 - 0.354 +def <- simstudy::defData(def, varname = "tpo2", formula = "0.354", dist = "binary") +# correlation with diabetes type +def = simstudy::defData(def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary") +# <= 2 - 0.302 +def = simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") +``` + +```{r, monte-carlo-function} +simulate_data_monte_carlo <- + function(def, n, iterations) { + data <- tibble::tibble() + + for (i in 1:iterations) { + generated_data <- genData(n, def) |> + dplyr::mutate(simnr = i) + data <- rbind(data, generated_data) |> + select(simnr, everything()) + + } + return(data) + } ``` -Using the `generate_outcomes` function, a data frame was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients in each iteration. - -```{r, data-generate} -# generate data +```{r, create-data} data <- - params |> - group_by(simnr = row_number()) |> - tidyr::nest(.key = "params") |> - mutate(results = purrr::map(params, generate_outcomes, n)) |> - select(simnr, results) |> - tidyr::unnest() |> + simulate_data_monte_carlo(def, n, iterations = no_of_iterations)|> mutate( sex = as.character(sex), age = as.character(age), @@ -316,15 +191,16 @@ data <- hba1c = as.character(hba1c), tpo2 = as.character(tpo2), wound_size = as.character(wound_size) - ) + ) |> as_tibble() +``` +```{r, data-generate} +# add arm to tibble data <- data |> tibble::add_column(arm = "") ``` -The table displays an example distribution of variables for the first 5 rows of the `data` frame for the first iteration. - ```{r, data-show} # first 5 rows of the data data[1:5, 2:ncol(data)] |> @@ -333,7 +209,7 @@ data[1:5, 2:ncol(data)] |> ## Minimization randomization -To generate appropriate research arms for each simulation, a function called `minimize_results` was written, utilizing the `randomize_minimisation_pocock` function available within the `unbiased` package. The probability parameter was set at the level defined within the function (p = 0.85). In the case of minimization randomization, to verify which type of minimization (with equal weights or unequal weights) was used, three calls to the minimize_results function were prepared: +To generate appropriate research arms for each simulation, a function called `minimize_results` was written, utilizing the `randomize_minimisation_pocock` function available within the `unbiased` package (@unbiased). The probability parameter was set at the level defined within the function (p = 0.85). In the case of minimization randomization, to verify which type of minimization (with equal weights or unequal weights) was used, three calls to the minimize_results function were prepared: - **minimize_equal_weights** - each covariate weight takes a value equal to 1 divided by the number of covariates. In this case, the weight is 1/6, @@ -341,7 +217,6 @@ To generate appropriate research arms for each simulation, a function called `mi - **minimize_unequal_weights_2** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. - The tables present information about allocations for the first 5 patients in the initial iteration of the simulation. ```{r, minimize-results} @@ -374,6 +249,7 @@ minimize_results <- ``` ```{r, minimize-equal} +set.seed(123) # eqal weights - 1/6 minimize_equal_weights <- minimize_results( @@ -386,6 +262,7 @@ minimize_equal_weights[1:5, 2:ncol(minimize_equal_weights)] |> ``` ```{r, minimize-unequal-1} +set.seed(123) # double weights where the covariant is of high clinical significance minimize_unequal_weights <- minimize_results( @@ -406,6 +283,7 @@ minimize_unequal_weights[1:5, 2:ncol(minimize_unequal_weights)] |> ``` ```{r, minimize-unequal-2} +set.seed(123) # triple weights where the covariant is of high clinical significance minimize_unequal_weights_2 <- minimize_results( @@ -427,7 +305,7 @@ minimize_unequal_weights_2[1:5, 2:ncol(minimize_unequal_weights_2)] |> The `statistic_table` function was developed to provide information on: the distribution of the number of patients across research arms, and the distribution of covariates across research arms, along with p-value information for statistical analyses used to compare proportions - chi^2, and the exact Fisher's test, typically used for small samples. -The function relies on the use of the `tbl_summary` function available in the `gtsummary` package. +The function relies on the use of the `tbl_summary` function available in the `gtsummary` package (@gtsummary). ```{r, statistics-table} # generation of frequency and chi^2 statistic values or fisher exact test @@ -476,9 +354,9 @@ statistics_table(minimize_unequal_weights_2) ## Simple randomization -In the next step, appropriate arms were generated for patients using simple randomization, available through the `unbiased` package - the `randomize_simple` function. The `simple_results` function was called within `simple_data`, considering the initial assumption of assigning patients to three arms in a 1:1:1 ratio. +In the next step, appropriate arms were generated for patients using simple randomization, available through the `unbiased` package - the `randomize_simple` function (@unbiased). The `simple_results` function was called within `simple_data`, considering the initial assumption of assigning patients to three arms in a 1:1:1 ratio. -Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly. +Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly (flip coin method). The tables illustrate an example of data output for simple randomization in the first iteration and summary statistics including a summary of the statistical tests. ```{r, simple-result} # simple randomization @@ -503,16 +381,15 @@ simple_results <- } ``` -The table illustrates an example of data output for simple randomization in the first iteration. - ```{r, simple-data} +set.seed(123) + simple_data <- simple_results(data, c("armA", "armB", "armC"), c("armB" = 1L,"armA" = 1L, "armC" = 1L)) simple_data[1:5, 2:ncol(simple_data)] |> gt() ``` -The table presents a statistical summary of results for the first iteration. ```{r, chi2-4, tab.cap = "Summary of proportion test for simple randomization"} statistics_table(simple_data) @@ -520,7 +397,13 @@ statistics_table(simple_data) ## Block randomization -Block randomization, as opposed to minimization and simple randomization methods, was developed based on the `rbprPar` function available in the `randomizeR` package. Using this, the `block_rand` function was created, which, based on the defined number of patients, arms, and a list of stratifying factors, generates a randomization list with a length equal to the number of patients multiplied by the product of categories in each covariate. In the case of the specified data in the document, for one iteration, it amounts to **105 * 2^6 = 6720 rows**. +Block randomization, as opposed to minimization and simple randomization methods, was developed based on the `rbprPar` function available in the `randomizeR` package (@randomizeR). Using this, the `block_rand` function was created, which, based on the defined number of patients, arms, and a list of stratifying factors, generates a randomization list with a length equal to the number of patients multiplied by the product of categories in each covariate. In the case of the specified data in the document, for one iteration, it amounts to **105 * 2^6 = 6720 rows**. This ensures that there is an appropriate number of randomisation codes for each opportunity. In the case of equal characteristics, it is certain that there are the right number of codes for the defined `n` patients. + +Based on the `block_rand` function, it is possible to generate a randomisation list, based on which patients will be allocated, with characteristics from the output `data` frame. Due to the 3 arms and the need to blind the allocation of consecutive patients, block sizes 3,6 and 9 were used for the calculations. + +In the next step, patients were assigned to research groups using the `block_results` function (based on the list generated by the function `block_rand`). For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). + +The tables show the assignment of patients to groups using block randomisation for the first 5 rows, first iteration and summary statistics including a summary of the statistical tests. ```{r, block-rand} # Function to generate a randomisation list @@ -547,10 +430,10 @@ block_rand <- }) df_list = tibble::tibble() for (i in seq_len(strata_n)) { - local_df <- strata_grid %>% - dplyr::slice(i) %>% - dplyr::mutate(count = N) %>% - tidyr::uncount(count) %>% + local_df <- strata_grid |> + dplyr::slice(i) |> + dplyr::mutate(count = N) |> + tidyr::uncount(count) |> tibble::add_column(rand_arm = genSeq_list[[i]]) df_list <- rbind(local_df, df_list) } @@ -558,49 +441,41 @@ block_rand <- } ``` -Lists of randomization codes were generated for each iteration based on the function, assuming a block vector of c(3,6,9). This ensures blinding and facilitates ease in predicting which group the next patient would be assigned to. - -```{r, rand-data} -# generate randomization lists for each iterations -rand_data <- tibble::tibble() - -for (i in unique(data$simnr)) { - simulation_result <- - block_rand( - N = n, - block = c(3, 6, 9), - n_groups = 3, - strata = - list( - sex = c("0", "1"), - diabetes_type = c("0", "1"), - hba1c = c("0", "1"), - tpo2 = c("0", "1"), - age = c("0", "1"), - wound_size = c("0", "1") - ) - ) |> dplyr::mutate(simnr = i) |> - select(simnr, everything()) - - rand_data <- dplyr::bind_rows(rand_data, simulation_result) -} -``` - -In the next step, patients were assigned to research groups using the `block_results` function. For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). - ```{r, block-results} # Generate a research arm for patients in each iteration -block_results <- function(data, datarand) { +block_results <- function(data) { + rand_data <- tibble::tibble() - for (k in unique(data$simnr)) { + for (i in unique(data$simnr)) { + simulation_result <- + block_rand( + N = n, + block = c(3, 6, 9), + n_groups = 3, + strata = + list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ) + ) |> dplyr::mutate(simnr = i) |> + select(simnr, everything()) + rand_data <- dplyr::bind_rows(rand_data, simulation_result) + } + + datarand <- rand_data + + for (k in unique(data$simnr)) { datax <- data[data$simnr == k, ] - + datay <- datarand[datarand$simnr == k, ] - - for (i in datax$id) { - - matching_rows <- + + for (i in data$id) { + matching_rows <- which( datay[2] == datax[3][datax[2] == i] & datay[3] == datax[4][datax[2] == i] & @@ -629,25 +504,23 @@ block_results <- function(data, datarand) { } ``` -The table shows the assignment of patients to groups using block randomisation for the first 5 rows, first iteration. - ```{r, block-data-show} +set.seed(123) + block_data <- - block_results(data, rand_data) + block_results(data) block_data[1:5, 2:ncol(block_data)] |> gt() ``` -The table presents a statistical summary of results for the first iteration. - ```{r, chi2-5, tab.cap = "Summary of proportion test for simple randomization"} statistics_table(block_data) ``` ## Check balance using smd test -In order to select the test and define the precision at a specified level, above which we assume no imbalance, a literature analysis was conducted based on publications such as @lee2021estimating, @austin2009balance, @doah2021impact, @brown2020novel, @nguyen2017double, @sanchez2003effect, @lee2022propensity. +In order to select the test and define the precision at a specified level, above which we assume no imbalance, a literature analysis was conducted based on publications such as @lee2021estimating, @austin2009balance, @doah2021impact, @brown2020novel, @nguyen2017double, @sanchez2003effect, @lee2022propensity, @berger2021roadmap. To assess the balance for covariates between the research groups A, B, C, the Standardized Mean Difference (SMD) test was employed, which compares two groups. Since there are three groups in the example, the SMD test is computed for each pair of comparisons: A vs B, A vs C, and B vs C. The average SMD test for a given covariate is then calculated based on these comparisons. @@ -655,16 +528,16 @@ In the literature analysis, the precision level ranged between 0.1-0.2. For smal In the analyzed example, due to the sample size of 105 patients, a threshold of 0.2 for the SMD test was adopted. +A function called `smd_covariants_data` was written to generate frames that produce the SMD test for each covariate in each iteration, utilizing the `CreateTableOne` function available in the `tableone` package (@tableone). In cases where the test result is <0.001, a value of 0 was assigned. + +The results for each randomization method were stored in the `cov_balance_data`. + ```{r, define-strata-vars} # definied covariants, and strata vars = c("sex", "age", "diabetes_type", "wound_size", "tpo2", "hba1c") strata = "arm" ``` -A function called `smd_covariants_data` was written to generate frames that produce the SMD test for each covariate in each iteration, utilizing the `CreateTableOne` function available in the `tableone` package. In cases where the test result is <0.001, a value of 0 was assigned. - -The results for each randomization method were stored in the `cov_balance_data`. - ```{r, smd-covariants-data} smd_covariants_data <- function(data, vars, strata) { @@ -778,7 +651,7 @@ Based on the specified precision threshold of 0.2, a function defining randomiza The final success power is calculated as the sum of successes in each iteration divided by the total number of specified iterations. -The results defined in variables min1-min5 are summarized in a table as the percentage of success for each randomization method. +The results are summarized in a table as the percentage of success for each randomization method. ```{r, success-power} # function defining success of randomisation @@ -803,21 +676,6 @@ success_power <- } ``` -```{r, success-min, echo = TRUE, results='hide'} -min_1 <- - success_power(cov1) -min_2 <- - success_power(cov2) -min_3 <- - success_power(cov3) - -min_4 <- - success_power(cov4) - -min_5 <- - success_power(cov5) -``` - ```{r, success-result-data, tab.cap = "Summary of percent success in each randomization methods"} data.frame( method = c( @@ -827,7 +685,7 @@ data.frame( 'block randomization', 'minimize unequal weights 3:1' ), - results_power = c(min_1, min_2, min_3, min_4, min_5) + results_power = c(success_power(cov1), success_power(cov2), success_power(cov3), success_power(cov4), success_power(cov5)) ) |> as.data.frame() |> rename(`power results [%]` = results_power) |> @@ -836,16 +694,14 @@ data.frame( ## Conclusion -Considering all three randomization methods: minimization, block randomization, and simple randomization, minimization performs the best in terms of covariate balance. Simple randomization has a significant drawback, as patient allocation to arms occurs randomly with equal probability. This leads to an imbalance in both the number of patients and covariate balance, which is also random. +Considering all three randomization methods: minimization, block randomization, and simple randomization, minimization performs the best in terms of covariate balance. Simple randomization has a significant drawback, as patient allocation to arms occurs randomly with equal probability. This leads to an imbalance in both the number of patients and covariate balance, which is also random. This is particularly the case with small samples. Balancing the number of patients is possible for larger samples for n > 200. -On the other hand, block randomization performs very well in balancing the number of patients in groups in a specified allocation ratio. However, its effect size power is lower for covariate balance compared to minimization. +On the other hand, block randomization performs very well in balancing the number of patients in groups in a specified allocation ratio. However, compared to adaptive randomisation using the minimisation method, block randomisation has a lower probability in terms of balancing the co-variables. -Minimization, on the other hand, provides the highest success power by ensuring balance across covariates between groups. +Minimization method, provides the highest success power by ensuring balance across covariates between groups. This is made possible by an appropriate algorithm implemented as part of minimisation randomisation. When assigning the next patient to a group, the method examines the total imbalance and then assigns the patient to the appropriate study group with a specified probability to balance the sample in terms of size, and covariates. # References --- nocite: '@*' ... - - diff --git a/vignettes/references.bib b/vignettes/references.bib index 39abe6a..cdbe561 100644 --- a/vignettes/references.bib +++ b/vignettes/references.bib @@ -152,12 +152,6 @@ @article{burkardt2014truncated year={2014} } -@article{hodel2023beta, - title={The Beta-Binomial Distribution}, - author={Hodel, Florian and Booth, James}, - year={2023} -} - @Manual{tableone, title = {tableone: Create 'Table 1' to Describe Baseline Characteristics with or without Propensity Score Weights}, @@ -189,3 +183,40 @@ @article{gtsummary issue = {1}, pages = {570-580}, } + +@article{berger2021roadmap, + title={A roadmap to using randomization in clinical trials}, + author={Berger, Vance W and Bour, Louis Joseph and Carter, Kerstine and Chipman, Jonathan J and Everett, Colin C and Heussen, Nicole and Hewitt, Catherine and Hilgers, Ralf-Dieter and Luo, Yuqun Abigail and Renteria, Jone and others}, + journal={BMC Medical Research Methodology}, + volume={21}, + pages={1--24}, + year={2021}, + publisher={Springer} +} + +@article{kang2008issues, + title={Issues in outcomes research: an overview of randomization techniques for clinical trials}, + author={Kang, Minsoo and Ragan, Brian G and Park, Jae-Hyeon}, + journal={Journal of athletic training}, + volume={43}, + number={2}, + pages={215--221}, + year={2008}, + publisher={The National Athletic Trainers' Association, Inc c/o Hughston Sports~…} +} + + @Manual{truncnorm, + title = {truncnorm: Truncated Normal Distribution}, + author = {Olaf Mersmann and Heike Trautmann and Detlef Steuer and Björn Bornkamp}, + year = {2023}, + note = {R package version 1.0-9}, + url = {https://github.com/olafmersmann/truncnorm}, + } + + @Manual{unbiased, + title = {unbiased: Diverse Randomization Algorithms for Clinical Trials}, + author = {Kamil Sijko and Kinga Sałata and Aleksandra Duda and Łukasz Wałejko}, + year = {2024}, + note = {R package version 0.0.0.9003}, + url = {https://ttscience.github.io/unbiased/}, + } diff --git a/vignettes/renv.lock b/vignettes/renv.lock index b7f6c22..07ee0a4 100644 --- a/vignettes/renv.lock +++ b/vignettes/renv.lock @@ -1,6 +1,6 @@ { "R": { - "Version": "4.2.1", + "Version": "4.2.3", "Repositories": [ { "Name": "CRAN", From 55a8d7a194224250a511dc5f891a5632c55d7492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 11:50:33 +0000 Subject: [PATCH 139/240] coverage --- NAMESPACE | 3 - .../api_create_study.R | 106 +---- R/api_randomize.R | 123 ++++++ .../unbiased_api/study-repository.R => R/db.R | 13 +- R/run-api.R | 41 ++ R/run_api.R | 48 --- R/run_db.R | 12 - R/study-details.R | 58 --- R/study-list.R | 32 -- .../unbiased_api => R}/validation-utils.R | 0 ...eload_wsl_ntfs.sh => autoreload_polling.sh | 0 clear_db.sh | 13 - docker-compose.test.yaml | 26 +- inst/plumber/unbiased_api/parse_pocock.R | 62 --- inst/plumber/unbiased_api/plumber.R | 4 +- inst/plumber/unbiased_api/study.R | 41 ++ populate_db.sh | 16 - renv.lock | 375 ++++++++++++++++++ run_in_test_context.sh | 16 - run_tests.sh | 12 - run_tests_with_coverage.sh | 11 - start_unbiased_api.sh | 3 +- start_unbiased_api_for_tests.sh | 31 -- tests/testthat/setup-api.R | 176 +++++++- 24 files changed, 765 insertions(+), 457 deletions(-) rename inst/plumber/unbiased_api/minimisation_pocock.R => R/api_create_study.R (54%) create mode 100644 R/api_randomize.R rename inst/plumber/unbiased_api/study-repository.R => R/db.R (90%) create mode 100644 R/run-api.R delete mode 100644 R/run_api.R delete mode 100644 R/run_db.R delete mode 100644 R/study-details.R delete mode 100644 R/study-list.R rename {inst/plumber/unbiased_api => R}/validation-utils.R (100%) rename autoreload_wsl_ntfs.sh => autoreload_polling.sh (100%) delete mode 100755 clear_db.sh delete mode 100644 inst/plumber/unbiased_api/parse_pocock.R create mode 100644 inst/plumber/unbiased_api/study.R delete mode 100755 populate_db.sh delete mode 100644 run_in_test_context.sh delete mode 100644 start_unbiased_api_for_tests.sh diff --git a/NAMESPACE b/NAMESPACE index c96d202..3b254dd 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,11 +1,8 @@ # Generated by roxygen2: do not edit by hand -export(list_studies) export(randomize_minimisation_pocock) export(randomize_simple) -export(read_study_details) export(run_unbiased) -export(study_exists) import(checkmate) import(dplyr) import(mathjaxr) diff --git a/inst/plumber/unbiased_api/minimisation_pocock.R b/R/api_create_study.R similarity index 54% rename from inst/plumber/unbiased_api/minimisation_pocock.R rename to R/api_create_study.R index 481de82..fbd1e47 100644 --- a/inst/plumber/unbiased_api/minimisation_pocock.R +++ b/R/api_create_study.R @@ -1,23 +1,6 @@ -#* Initialize a study with Pocock's minimisation randomization -#* -#* Set up a new study for randomization defining it's parameters -#* -#* -#* @param identifier:object Study code, at most 12 characters. -#* @param name:object Full study name. -#* @param method:object Function used to compute within-arm variability, must be one of: sd, var, range -#* @param p:object Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) -#* @param arms:object Arm names (character) with their ratios (integer). -#* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). -#* -#* @tag initialize -#* -#* @post /minimisation_pocock -#* @serializer unboxedJSON -#* -function(identifier, name, method, arms, covariates, p, req, res) { - source("study-repository.R") - source("validation-utils.R") +api__create_study_minimization_pocock <- function( + identifier, name, method, arms, covariates, p, req, res +) { validation_errors <- vector() err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) @@ -198,85 +181,4 @@ function(identifier, name, method, arms, covariates, p, req, res) { } return(response) -} - -#* Randomize one patient -#* -#* -#* @param study_id:int Study identifier -#* @param current_state:object -#* -#* @tag randomize -#* @post //patient -#* @serializer unboxedJSON -#* - -function(study_id, current_state, req, res) { - collection <- checkmate::makeAssertCollection() - - # Check whether study with study_id exists - checkmate::assert(checkmate::check_subset(x = req$args$study_id, - choices = - dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(id) |> - dplyr::pull()), .var.name = "Study ID", - add = collection) - - # Retrieve study details, especially the ones about randomization - method_randomization <- - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::select(method) |> - dplyr::pull() - - checkmate::assert(checkmate::check_scalar(method_randomization, null.ok = FALSE), - .var.name = "Randomization method", - add = collection) - - if (length(collection$getMessages()) > 0) { - res$status <- 400 - return(list( - error = "Study input validation failed", - validation_errors = collection$getMessages() - )) - } - - # Dispatch based on randomization method to parse parameters - source("parse_pocock.R") - params <- - switch( - method_randomization, - minimisation_pocock = tryCatch({ - do.call(parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) - }, error = function(e) { - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - }) - ) - - arm_name <- - switch( - method_randomization, - # simple = do.call(unbiased:::randomize_simple, params), - minimisation_pocock = tryCatch({ - do.call(unbiased:::randomize_minimisation_pocock, params) - }, error = function(e) { - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - } - ) - ) - - arm <- dplyr::tbl(db_connection_pool, "arm") |> - dplyr::filter(study_id == !!study_id & name == arm_name) |> - dplyr::select(arm_id = id, name, ratio) |> - dplyr::collect() - - save_patient(study_id, arm$arm_id) |> - dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = id) |> - as.list() -} - +} \ No newline at end of file diff --git a/R/api_randomize.R b/R/api_randomize.R new file mode 100644 index 0000000..5840dc9 --- /dev/null +++ b/R/api_randomize.R @@ -0,0 +1,123 @@ +api__randomize_patient <- function(study_id, current_state, req, res) { + collection <- checkmate::makeAssertCollection() + + # Check whether study with study_id exists + checkmate::assert(checkmate::check_subset(x = req$args$study_id, + choices = + dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(id) |> + dplyr::pull()), .var.name = "Study ID", + add = collection) + + # Retrieve study details, especially the ones about randomization + method_randomization <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::select(method) |> + dplyr::pull() + + checkmate::assert(checkmate::check_scalar(method_randomization, null.ok = FALSE), + .var.name = "Randomization method", + add = collection) + + if (length(collection$getMessages()) > 0) { + res$status <- 400 + return(list( + error = "Study input validation failed", + validation_errors = collection$getMessages() + )) + } + + # Dispatch based on randomization method to parse parameters + params <- + switch( + method_randomization, + minimisation_pocock = tryCatch({ + do.call(parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) + }, error = function(e) { + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) + }) + ) + + arm_name <- + switch( + method_randomization, + # simple = do.call(unbiased:::randomize_simple, params), + minimisation_pocock = tryCatch({ + do.call(unbiased:::randomize_minimisation_pocock, params) + }, error = function(e) { + res$status <- 400 + res$body = glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err=e) + } + ) + ) + + arm <- dplyr::tbl(db_connection_pool, "arm") |> + dplyr::filter(study_id == !!study_id & name == arm_name) |> + dplyr::select(arm_id = id, name, ratio) |> + dplyr::collect() + + unbiased:::save_patient(study_id, arm$arm_id) |> + dplyr::mutate(arm_name = arm$name) |> + dplyr::rename(patient_id = id) |> + as.list() +} + +parse_pocock_parameters <- function(db_connetion_pool, study_id, current_state){ + parameters <- + dplyr::tbl(db_connetion_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::select(parameters) |> + dplyr::pull() + + parameters <- jsonlite::fromJSON(parameters) + + if (!checkmate::test_list(parameters, null.ok = FALSE)){ + message <- checkmate::test_list(parameters, null.ok = FALSE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Parse validation failed. 'Parameters' must be a list: {message}") + ) + ) + return(res) + } + + # do testowania + # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') + + ratio_arms <- + dplyr::tbl(db_connetion_pool, "arm") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::select(name, ratio) |> + dplyr::collect() + + params <- list( + arms = ratio_arms$name, + current_state = tibble::as_tibble(current_state), + ratio = setNames(ratio_arms$ratio, ratio_arms$name), + method = parameters$method, + p = parameters$p, + weights = parameters$weights |> unlist() + ) + + if (!checkmate::test_list(params, null.ok = FALSE)){ + message <- checkmate::test_list(params, null.ok = FALSE) + res$status <- 400 + res$body <- + c( + response, + list( + error = glue::glue("Parse validation failed. Input parameters must be a list: {message}") + ) + ) + return(res) + } + + return(params) +} diff --git a/inst/plumber/unbiased_api/study-repository.R b/R/db.R similarity index 90% rename from inst/plumber/unbiased_api/study-repository.R rename to R/db.R index 0375cde..37aa7f1 100644 --- a/inst/plumber/unbiased_api/study-repository.R +++ b/R/db.R @@ -1,5 +1,17 @@ #' Defines methods for interacting with the study in the database +create_db_connection_pool <- purrr::insistently(function() { + pool::dbPool( + RPostgres::Postgres(), + dbname = Sys.getenv("POSTGRES_DB"), + host = Sys.getenv("POSTGRES_HOST"), + port = Sys.getenv("POSTGRES_PORT", 5432), + user = Sys.getenv("POSTGRES_USER"), + password = Sys.getenv("POSTGRES_PASSWORD") + ) +}, rate = purrr::rate_delay(2, max_times = 5)) + + get_similar_studies <- function(name, identifier) { similar <- dplyr::tbl(db_connection_pool, "study") |> @@ -122,4 +134,3 @@ save_patient <- function(study_id, arm_id){ return(randomized_patient) } - diff --git a/R/run-api.R b/R/run-api.R new file mode 100644 index 0000000..e5a80d9 --- /dev/null +++ b/R/run-api.R @@ -0,0 +1,41 @@ +#' Run API +#' +#' @description +#' Starts \pkg{unbiased} API. +#' +#' @param host `character(1)`\cr +#' Host URL. +#' @param port `integer(1)`\cr +#' Port to serve API under. +#' +#' @return Function called to serve the API in the caller thread. +#' +#' @export +run_unbiased <- function() { + host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") + port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) + assign("db_connection_pool", + unbiased:::create_db_connection_pool(), + envir = globalenv() + ) + + on.exit({ + db_connection_pool <- get("db_connection_pool", envir = globalenv()) + pool::poolClose(db_connection_pool) + assign("db_connection_pool", NULL, envir = globalenv()) + }) + + # if "inst" directory is not present, we assume that the package is installed + # and inst directory content is copied to the root directory + # so we can use plumb_api method + if (!dir.exists("inst")) { + plumber::plumb_api("unbiased", "unbiased_api") |> + plumber::pr_run(host = host, port = port) + } else { + # otherwise we assume that we are in the root directory of the repository + # and we can use plumb method to run the API from the plumber.R file + plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> + plumber::pr_run(host = host, port = port) + } + +} \ No newline at end of file diff --git a/R/run_api.R b/R/run_api.R deleted file mode 100644 index 1914d9e..0000000 --- a/R/run_api.R +++ /dev/null @@ -1,48 +0,0 @@ -#' Run API -#' -#' @description -#' Starts \pkg{unbiased} API. -#' -#' @param host `character(1)`\cr -#' Host URL. -#' @param port `integer(1)`\cr -#' Port to serve API under. -#' -#' @return Function called to serve the API in the caller thread. -#' -#' @export -run_unbiased <- function(host = "0.0.0.0", port = 3838, ...) { - host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") - port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) - assign("db_connection_pool", - unbiased:::create_db_connection_pool(), - envir = globalenv() - ) - - on.exit({ - db_connection_pool <- get("db_connection_pool", envir = globalenv()) - pool::poolClose(db_connection_pool) - assign("db_connection_pool", NULL, envir = globalenv()) - }) - - plumber::plumb_api("unbiased", "unbiased_api") |> - plumber::pr_run(host = host, port = port, ...) -} - -run_unbiased_local <- function() { - host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") - port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) - - assign("db_connection_pool", - unbiased:::create_db_connection_pool(), - envir = globalenv() - ) - on.exit({ - db_connection_pool <- get("db_connection_pool", envir = globalenv()) - pool::poolClose(db_connection_pool) - assign("db_connection_pool", NULL, envir = globalenv()) - }) - - plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> - plumber::pr_run(host = host, port = port) -} diff --git a/R/run_db.R b/R/run_db.R deleted file mode 100644 index d664911..0000000 --- a/R/run_db.R +++ /dev/null @@ -1,12 +0,0 @@ -# db_connection_pool <- NULL - -create_db_connection_pool <- purrr::insistently(function() { - pool::dbPool( - RPostgres::Postgres(), - dbname = Sys.getenv("POSTGRES_DB"), - host = Sys.getenv("POSTGRES_HOST"), - port = Sys.getenv("POSTGRES_PORT", 5432), - user = Sys.getenv("POSTGRES_USER"), - password = Sys.getenv("POSTGRES_PASSWORD") - ) -}, rate = purrr::rate_delay(2, max_times = 5)) diff --git a/R/study-details.R b/R/study-details.R deleted file mode 100644 index 595c59e..0000000 --- a/R/study-details.R +++ /dev/null @@ -1,58 +0,0 @@ -#' Read study details -#' -#' @description -#' Queries the DB for the study parameters, including declared arms and strata. -#' -#' @param study_id `integer(1)`\cr -#' ID of the study. -#' -#' @return A tibble with study details, containing potentially complex columns, -#' like `arms`. -#' -#' @export -read_study_details <- function(study_id) { - arms <- tbl(db_connection_pool, "arm") |> - filter(study_id == !!study_id) |> - select(name, ratio) |> - collect() - - strata <- tbl(db_connection_pool, "stratum") |> - filter(study_id == !!study_id) |> - select(id, name, value_type) |> - collect() |> - mutate(values = list(read_stratum_values(id, value_type)), .by = id) |> - select(-id) - - tbl(db_connection_pool, "study") |> - filter(id == !!study_id) |> - select(id, name, identifier, method_id, parameters) |> - left_join( - tbl(db_connection_pool, "method") |> - select(id, method = name), - join_by(method_id == id) - ) |> - select(-method_id) |> - collect() |> - mutate( - parameters = list(jsonlite::fromJSON(parameters)), - arms = list(arms), - strata = list(strata) - ) -} - -read_stratum_values <- function(stratum_id, value_type) { - switch( - value_type, - "factor" = { - tbl(db_connection_pool, "factor_constraint") |> - filter(stratum_id == !!stratum_id) |> - pull(value) - }, - "numeric" = { - tbl(db_connection_pool, "numeric_constraint") |> - filter(stratum_id == !!stratum_id) |> - select(min_value, max_value) |> - collect() - } - ) -} diff --git a/R/study-list.R b/R/study-list.R deleted file mode 100644 index b21375f..0000000 --- a/R/study-list.R +++ /dev/null @@ -1,32 +0,0 @@ -#' List available studies -#' -#' @description -#' Queries the DB for the basic information about existing studies. -#' -#' @return A tibble with basic study info, including ID. -#' -#' @export -list_studies <- function() { - tbl(db_connection_pool, "study") |> - select(id, identifier, name, timestamp) |> - arrange(desc(timestamp)) |> - collect() -} - -#' Validate study existence -#' -#' @description -#' Checks the database for the existence of given ID. -#' -#' @param study_id `integer(1)`\cr -#' ID of the study. -#' -#' @return `TRUE` or `FALSE`, depending whether given ID exists in the DB. -#' -#' @export -study_exists <- function(study_id) { - row_id <- tbl(db_connection_pool, "study") |> - filter(id == !!study_id) |> - pull(id) - test_int(row_id) -} diff --git a/inst/plumber/unbiased_api/validation-utils.R b/R/validation-utils.R similarity index 100% rename from inst/plumber/unbiased_api/validation-utils.R rename to R/validation-utils.R diff --git a/autoreload_wsl_ntfs.sh b/autoreload_polling.sh similarity index 100% rename from autoreload_wsl_ntfs.sh rename to autoreload_polling.sh diff --git a/clear_db.sh b/clear_db.sh deleted file mode 100755 index 6764408..0000000 --- a/clear_db.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -e - -export PGPASSWORD="$POSTGRES_PASSWORD" - -# Clear the database -psql -v ON_ERROR_STOP=1 \ - --host "$POSTGRES_HOST" \ - --port "${POSTGRES_PORT:-5432}" \ - --username "$POSTGRES_USER" \ - --dbname "$POSTGRES_DB" \ - -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 1dc9bb3..41550c4 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -5,39 +5,17 @@ services: build: context: . dockerfile: Dockerfile.postgres - container_name: unbiased_postgres environment: - POSTGRES_PASSWORD=postgres networks: - test_net - volumes: - - type: bind - source: ./inst/postgres/ - target: /docker-entrypoint-initdb.d/ - api: - image: unbiased - build: - context: . - dockerfile: Dockerfile - container_name: unbiased_api - depends_on: - - postgres - environment: - - POSTGRES_DB=postgres - - POSTGRES_HOST=postgres - - POSTGRES_PORT=5432 - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - networks: - - test_net tests: # image: unbiased build: context: . dockerfile: Dockerfile - container_name: unbiased_tests depends_on: - - api + - postgres environment: - CI=true - POSTGRES_DB=postgres @@ -47,7 +25,7 @@ services: - POSTGRES_PASSWORD=postgres networks: - test_net - command: R -e "testthat::test_package('unbiased')" + command: R -e "install.packages('covr'); covr::package_coverage()" networks: test_net: diff --git a/inst/plumber/unbiased_api/parse_pocock.R b/inst/plumber/unbiased_api/parse_pocock.R deleted file mode 100644 index 9d8334d..0000000 --- a/inst/plumber/unbiased_api/parse_pocock.R +++ /dev/null @@ -1,62 +0,0 @@ -#' Parse parameters for Pocock randomization method -#' -#' Function to parse and process parameters for the Pocock randomization method. -#' -#' @return params List of parameters - - -parse_pocock_parameters <- function(db_connetion_pool, study_id, current_state){ - parameters <- - dplyr::tbl(db_connetion_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::select(parameters) |> - dplyr::pull() - - parameters <- jsonlite::fromJSON(parameters) - - if (!checkmate::test_list(parameters, null.ok = FALSE)){ - message <- checkmate::test_list(parameters, null.ok = FALSE) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Parse validation failed. 'Parameters' must be a list: {message}") - ) - ) - return(res) - } - - # do testowania - # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') - - ratio_arms <- - dplyr::tbl(db_connetion_pool, "arm") |> - dplyr::filter(study_id == !!study_id) |> - dplyr::select(name, ratio) |> - dplyr::collect() - - params <- list( - arms = ratio_arms$name, - current_state = tibble::as_tibble(current_state), - ratio = setNames(ratio_arms$ratio, ratio_arms$name), - method = parameters$method, - p = parameters$p, - weights = parameters$weights |> unlist() - ) - - if (!checkmate::test_list(params, null.ok = FALSE)){ - message <- checkmate::test_list(params, null.ok = FALSE) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Parse validation failed. Input parameters must be a list: {message}") - ) - ) - return(res) - } - - return(params) -} diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 542f51e..5eca066 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -10,11 +10,11 @@ #* @plumber function(api) { meta <- plumber::pr("meta.R") - minimisation_pocock <- plumber::pr("minimisation_pocock.R") + study <- plumber::pr("study.R") api |> plumber::pr_mount("/meta", meta) |> - plumber::pr_mount("/study", minimisation_pocock) |> + plumber::pr_mount("/study", study) |> plumber::pr_set_api_spec(function(spec) { spec$ paths$ diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R new file mode 100644 index 0000000..d42c5ec --- /dev/null +++ b/inst/plumber/unbiased_api/study.R @@ -0,0 +1,41 @@ +#* Initialize a study with Pocock's minimisation randomization +#* +#* Set up a new study for randomization defining it's parameters +#* +#* +#* @param identifier:object Study code, at most 12 characters. +#* @param name:object Full study name. +#* @param method:object Function used to compute within-arm variability, must be one of: sd, var, range +#* @param p:object Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) +#* @param arms:object Arm names (character) with their ratios (integer). +#* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). +#* +#* @tag initialize +#* +#* @post /minimisation_pocock +#* @serializer unboxedJSON +#* +function(identifier, name, method, arms, covariates, p, req, res) { + return( + unbiased:::api__create_study_minimization_pocock( + identifier, name, method, arms, covariates, p, req, res + ) + ) +} + +#* Randomize one patient +#* +#* +#* @param study_id:int Study identifier +#* @param current_state:object +#* +#* @tag randomize +#* @post //patient +#* @serializer unboxedJSON +#* + +function(study_id, current_state, req, res) { + return( + unbiased:::api__randomize_patient(study_id, current_state, req, res) + ) +} diff --git a/populate_db.sh b/populate_db.sh deleted file mode 100755 index ad9d1e4..0000000 --- a/populate_db.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e - -export PGPASSWORD="$POSTGRES_PASSWORD" - -# List all sql files in inst/postgres directory and execute them in alphabetical order -for f in inst/postgres/*.sql; do - echo "Executing $f" - psql -v ON_ERROR_STOP=1 \ - --host "$POSTGRES_HOST" \ - --port "${POSTGRES_PORT:-5432}" \ - --username "$POSTGRES_USER" \ - --dbname "$POSTGRES_DB" \ - -f "$f" -done \ No newline at end of file diff --git a/renv.lock b/renv.lock index 5b09e72..f303d39 100644 --- a/renv.lock +++ b/renv.lock @@ -126,6 +126,13 @@ ], "Hash": "40415719b5a479b87949f3aa0aee737c" }, + "brew": { + "Package": "brew", + "Version": "1.0-10", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "8f4a384e19dccd8c65356dc096847b76" + }, "brio": { "Package": "brio", "Version": "1.1.3", @@ -201,6 +208,23 @@ ], "Hash": "89e6d8219950eac806ae0c489052048a" }, + "clipr": { + "Package": "clipr", + "Version": "0.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "utils" + ], + "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" + }, + "commonmark": { + "Package": "commonmark", + "Version": "1.9.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "d691c61bff84bd63c383874d2d0c3307" + }, "cpp11": { "Package": "cpp11", "Version": "0.4.6", @@ -223,6 +247,20 @@ ], "Hash": "e8a1e41acf02548751f45c718d55aa6a" }, + "credentials": { + "Package": "credentials", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "curl", + "jsonlite", + "openssl", + "sys" + ], + "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" + }, "curl": { "Package": "curl", "Version": "5.1.0", @@ -275,6 +313,40 @@ ], "Hash": "6b9602c7ebbe87101a9c8edb6e8b6d21" }, + "devtools": { + "Package": "devtools", + "Version": "2.4.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "desc", + "ellipsis", + "fs", + "lifecycle", + "memoise", + "miniUI", + "pkgbuild", + "pkgdown", + "pkgload", + "profvis", + "rcmdcheck", + "remotes", + "rlang", + "roxygen2", + "rversions", + "sessioninfo", + "stats", + "testthat", + "tools", + "urlchecker", + "usethis", + "utils", + "withr" + ], + "Hash": "ea5bc8b4a6a01e4f12d98b58329930bb" + }, "diffobj": { "Package": "diffobj", "Version": "0.3.5", @@ -419,6 +491,47 @@ ], "Hash": "15e9634c0fcd294799e9b2e929ed1b86" }, + "gert": { + "Package": "gert", + "Version": "2.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "askpass", + "credentials", + "openssl", + "rstudioapi", + "sys", + "zip" + ], + "Hash": "f70d3fe2d9e7654213a946963d1591eb" + }, + "gh": { + "Package": "gh", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "gitcreds", + "httr2", + "ini", + "jsonlite", + "rlang" + ], + "Hash": "03533b1c875028233598f848fda44c4c" + }, + "gitcreds": { + "Package": "gitcreds", + "Version": "0.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ab08ac61f3e1be454ae21911eb8bc2fe" + }, "glue": { "Package": "glue", "Version": "1.7.0", @@ -472,6 +585,21 @@ ], "Hash": "2d7b3857980e0e0d0a1fd6f11928ab0f" }, + "htmlwidgets": { + "Package": "htmlwidgets", + "Version": "1.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grDevices", + "htmltools", + "jsonlite", + "knitr", + "rmarkdown", + "yaml" + ], + "Hash": "04291cc45198225444a397606810ac37" + }, "httpuv": { "Package": "httpuv", "Version": "1.6.11", @@ -523,6 +651,13 @@ ], "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" }, + "ini": { + "Package": "ini", + "Version": "0.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "6154ec2223172bce8162d4153cda21f7" + }, "jquerylib": { "Package": "jquerylib", "Version": "0.1.4", @@ -644,6 +779,18 @@ ], "Hash": "18e9c28c1d3ca1560ce30658b22ce104" }, + "miniUI": { + "Package": "miniUI", + "Version": "0.1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "htmltools", + "shiny", + "utils" + ], + "Hash": "fec5f52652d60615fdb3957b3d74324a" + }, "openssl": { "Package": "openssl", "Version": "2.1.1", @@ -827,6 +974,21 @@ ], "Hash": "3efbd8ac1be0296a46c55387aeace0f3" }, + "profvis": { + "Package": "profvis", + "Version": "0.3.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "htmlwidgets", + "purrr", + "rlang", + "stringr", + "vctrs" + ], + "Hash": "aa5a3864397ce6ae03458f98618395a1" + }, "promises": { "Package": "promises", "Version": "1.2.1", @@ -890,6 +1052,28 @@ ], "Hash": "5e3c5dc0b071b21fa128676560dbe94d" }, + "rcmdcheck": { + "Package": "rcmdcheck", + "Version": "1.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "callr", + "cli", + "curl", + "desc", + "digest", + "pkgbuild", + "prettyunits", + "rprojroot", + "sessioninfo", + "utils", + "withr", + "xopen" + ], + "Hash": "8f25ebe2ec38b1f2aef3b0d2ef76f6c4" + }, "rematch2": { "Package": "rematch2", "Version": "2.1.2", @@ -900,6 +1084,20 @@ ], "Hash": "76c9e04c712a05848ae7a23d2f170a40" }, + "remotes": { + "Package": "remotes", + "Version": "2.4.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "tools", + "utils" + ], + "Hash": "63d15047eb239f95160112bcadc4fcb9" + }, "renv": { "Package": "renv", "Version": "1.0.0", @@ -945,6 +1143,32 @@ ], "Hash": "d65e35823c817f09f4de424fcdfa812a" }, + "roxygen2": { + "Package": "roxygen2", + "Version": "7.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "brew", + "cli", + "commonmark", + "cpp11", + "desc", + "knitr", + "methods", + "pkgload", + "purrr", + "rlang", + "stringi", + "stringr", + "utils", + "withr", + "xml2" + ], + "Hash": "c25fe7b2d8cba73d1b63c947bf7afdb9" + }, "rprojroot": { "Package": "rprojroot", "Version": "2.0.3", @@ -955,6 +1179,25 @@ ], "Hash": "1de7ab598047a87bba48434ba35d497d" }, + "rstudioapi": { + "Package": "rstudioapi", + "Version": "0.15.0", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "5564500e25cffad9e22244ced1379887" + }, + "rversions": { + "Package": "rversions", + "Version": "2.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "curl", + "utils", + "xml2" + ], + "Hash": "a9881dfed103e83f9de151dc17002cd1" + }, "sass": { "Package": "sass", "Version": "0.4.8", @@ -969,6 +1212,53 @@ ], "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" }, + "sessioninfo": { + "Package": "sessioninfo", + "Version": "1.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "tools", + "utils" + ], + "Hash": "3f9796a8d0a0e8c6eb49a4b029359d1f" + }, + "shiny": { + "Package": "shiny", + "Version": "1.8.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "bslib", + "cachem", + "commonmark", + "crayon", + "ellipsis", + "fastmap", + "fontawesome", + "glue", + "grDevices", + "htmltools", + "httpuv", + "jsonlite", + "later", + "lifecycle", + "methods", + "mime", + "promises", + "rlang", + "sourcetools", + "tools", + "utils", + "withr", + "xtable" + ], + "Hash": "3a1f41807d648a908e3c7f0334bf85e6" + }, "sodium": { "Package": "sodium", "Version": "1.3.0", @@ -976,6 +1266,16 @@ "Repository": "RSPM", "Hash": "bd436c1e48dc1982125e4d955017724e" }, + "sourcetools": { + "Package": "sourcetools", + "Version": "0.1.7-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "5f5a7629f956619d519205ec475fe647" + }, "stringi": { "Package": "stringi", "Version": "1.7.12", @@ -1151,6 +1451,51 @@ ], "Hash": "5ac22900ae0f386e54f1c307eca7d843" }, + "urlchecker": { + "Package": "urlchecker", + "Version": "1.0.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "curl", + "tools", + "xml2" + ], + "Hash": "409328b8e1253c8d729a7836fe7f7a16" + }, + "usethis": { + "Package": "usethis", + "Version": "2.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cli", + "clipr", + "crayon", + "curl", + "desc", + "fs", + "gert", + "gh", + "glue", + "jsonlite", + "lifecycle", + "purrr", + "rappdirs", + "rlang", + "rprojroot", + "rstudioapi", + "stats", + "utils", + "whisker", + "withr", + "yaml" + ], + "Hash": "60e51f0b94d0324dc19e44110098fa9f" + }, "utf8": { "Package": "utf8", "Version": "1.2.3", @@ -1246,12 +1591,42 @@ ], "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" }, + "xopen": { + "Package": "xopen", + "Version": "1.0.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "processx" + ], + "Hash": "6c85f015dee9cc7710ddd20f86881f58" + }, + "xtable": { + "Package": "xtable", + "Version": "1.8-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "b8acdf8af494d9ec19ccb2481a9b11c2" + }, "yaml": { "Package": "yaml", "Version": "2.3.8", "Source": "Repository", "Repository": "RSPM", "Hash": "29240487a071f535f5e5d5a323b7afbd" + }, + "zip": { + "Package": "zip", + "Version": "2.3.1", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "fcc4bd8e6da2d2011eb64a5e5cc685ab" } } } diff --git a/run_in_test_context.sh b/run_in_test_context.sh deleted file mode 100644 index 2039d5c..0000000 --- a/run_in_test_context.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -e - -DB_NAME_SUFFIX="__test" - -ORIGINAL_DB_NAME="$POSTGRES_DB" -POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" -UNBIASED_PORT=3899 -UNBIASED_HOST="127.0.0.1" - -echo "Running provided command in test context" - -export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST - -"$@" \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh index e0e9d5f..39ef2f9 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,18 +1,6 @@ #!/bin/bash set -e - -DB_NAME_SUFFIX="__test" - -ORIGINAL_DB_NAME="$POSTGRES_DB" -POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" -UNBIASED_PORT=3899 -UNBIASED_HOST="127.0.0.1" - -# Set a dummy GITHUB_SHA based on git ref HEAD -GITHUB_SHA=$(git rev-parse HEAD) - echo "Running tests" -export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST GITHUB_SHA R --quiet --no-save -e "devtools::load_all(); testthat::test_package('unbiased')" diff --git a/run_tests_with_coverage.sh b/run_tests_with_coverage.sh index cda576c..4a36b50 100644 --- a/run_tests_with_coverage.sh +++ b/run_tests_with_coverage.sh @@ -2,17 +2,6 @@ set -e -DB_NAME_SUFFIX="__test" - -ORIGINAL_DB_NAME="$POSTGRES_DB" -POSTGRES_DB="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" -UNBIASED_PORT=3899 -UNBIASED_HOST="127.0.0.1" - -# Set a dummy GITHUB_SHA based on git ref HEAD -GITHUB_SHA=$(git rev-parse HEAD) - echo "Running tests" -export POSTGRES_DB UNBIASED_PORT UNBIASED_HOST GITHUB_SHA R --quiet --no-save -e "devtools::load_all(); covr::package_coverage('.')" diff --git a/start_unbiased_api.sh b/start_unbiased_api.sh index 008e121..991cccc 100644 --- a/start_unbiased_api.sh +++ b/start_unbiased_api.sh @@ -4,5 +4,4 @@ set -e echo "Running unbiased" -# R -e "devtools::install(quick = TRUE, upgrade = FALSE); unbiased::run_unbiased()" -R --quiet --no-save -e "devtools::load_all(); unbiased:::run_unbiased_local()" +R --quiet --no-save -e "devtools::load_all(); unbiased:::run_unbiased()" diff --git a/start_unbiased_api_for_tests.sh b/start_unbiased_api_for_tests.sh deleted file mode 100644 index 2386528..0000000 --- a/start_unbiased_api_for_tests.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -set -e - -DB_NAME_SUFFIX="__test" - -ORIGINAL_DB_NAME="$POSTGRES_DB" -TEST_DB_NAME="${ORIGINAL_DB_NAME}${DB_NAME_SUFFIX}" -UNBIASED_PORT=3899 - -# Set a dummy GITHUB_SHA based on git ref HEAD -GITHUB_SHA=$(git rev-parse HEAD) - -# Create a new database for testing -echo "Creating test database $TEST_DB_NAME" -./run_psql.sh -c "CREATE DATABASE $TEST_DB_NAME" || \ - echo "Cannot create $TEST_DB_NAME, assuming it already exists" - -echo "Using test database $TEST_DB_NAME" -POSTGRES_DB="${TEST_DB_NAME}" - -export POSTGRES_DB UNBIASED_PORT GITHUB_SHA - -# Clear the test database -./clear_db.sh - -# Run the migrations -./migrate_db.sh up - -# Run the unbiased API -./start_unbiased_api.sh \ No newline at end of file diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-api.R index 816f91f..6cc9fee 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-api.R @@ -3,29 +3,173 @@ library(dplyr) library(dbplyr) library(httr2) -api_host <- Sys.getenv("UNBIASED_HOST", "api") -api_port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) +run_psql <- function(statement) { + withr::local_envvar( + PGPASSWORD = Sys.getenv("POSTGRES_PASSWORD") + ) + + # Construct the command + command <- paste( + "psql", + "--host", shQuote(Sys.getenv("POSTGRES_HOST")), + "--port", shQuote(Sys.getenv("POSTGRES_PORT")), + "--username", shQuote(Sys.getenv("POSTGRES_USER")), + "--dbname", shQuote(Sys.getenv("POSTGRES_DB")), + "--command", shQuote(statement), + sep = " " + ) + + system(command, intern = TRUE) +} + +run_migrations <- function() { + # Construct the connection string + user <- Sys.getenv("POSTGRES_USER") + password <- Sys.getenv("POSTGRES_PASSWORD") + host <- Sys.getenv("POSTGRES_HOST") + port <- Sys.getenv("POSTGRES_PORT", "5432") + db <- Sys.getenv("POSTGRES_DB") + + migrations_path <- glue::glue( + "{root_repo_directory}/inst/db/migrations" + ) + if (!dir.exists(migrations_path)) { + # If the migrations directory does not exist + # we will assume that the package is installed + # and inst directory content is copied to the root directory + migrations_path <- glue::glue( + "{root_repo_directory}/db/migrations" + ) + } + + db_connection_string <- + glue::glue( + "postgres://{user}:{password}@{host}:{port}/{db}?sslmode=disable" + ) + command <- "migrate" + args <- c( + "-database", + db_connection_string, + "-path", + migrations_path, + "up" + ) + + system2(command, args) +} + + +# We will always run the API on the localhost +# and on a random port +api_host <- "127.0.0.1" +api_port <- httpuv::randomPort() api_url <- glue::glue("http://{api_host}:{api_port}") print(glue::glue("API URL: {api_url}")) -working_directory <- - glue::glue(getwd(), "/../../") |> +# make sure we are in the root directory of the repository +# this is necessary to run the database migrations +# as well as to run the plumber API +current_working_dir <- getwd() +root_repo_directory <- + glue::glue(current_working_dir, "/../../") |> normalizePath() +setwd(root_repo_directory) + +# append __test suffix to the database name +# we will use this as a convention to create a test database +# we have to avoid messing with the original database +db_name <- Sys.getenv("POSTGRES_DB") +db_name_test <- glue::glue("{db_name}__test") + +# create the test database using connection with the original database +run_psql( + glue::glue( + "CREATE DATABASE {db_name_test}" + ) +) + +# now that the database is created, we can set the environment variable +# to the test database name +# we will be working on the test database from now on +withr::local_envvar( + list( + POSTGRES_DB = db_name_test + ) +) + +# drop the test database upon exiting +withr::defer( + { + # make sure db_name_test ends with __test before dropping it + assert( + stringr::str_detect(db_name_test, "__test$"), + "db_name_test should end with __test" + ) + setwd(root_repo_directory) + print( + glue::glue( + "Dropping test database {db_name_test}" + ) + ) + run_psql( + glue::glue( + "DROP DATABASE {db_name_test}" + ) + ) + }, + teardown_env() +) + +# run migrations +exit_code <- run_migrations() +if (exit_code != 0) { + stop( + glue::glue( + "Failed to run database migrations", + "exit code: {exit_code}" + ) + ) +} + +# We will run the unbiased API in the background +# and wait until it starts +# We are setting the environment variables +# so that the unbiased API will start an HTTP server +# on the specified host and port without coliision +# with the main API that might be running on the same machine +withr::local_envvar( + list( + UNBIASED_HOST = api_host, + UNBIASED_PORT = api_port + ) +) + +# Mock GITHUB_SHA as valid sha if it is not set +github_sha <- Sys.getenv( + "GITHUB_SHA", + "6e21b5b689cc9737ba0d24147ed4b634c7146a28" +) +withr::local_envvar( + list( + GITHUB_SHA = github_sha + ) +) plumber_process <- callr::r_bg( - \(working_directory) { - setwd(working_directory) + \() { if (!requireNamespace("unbiased", quietly = TRUE)) { + # There is no installed unbiased package + # In that case, we will assume that we are running + # on the development machine + # and we will load the package using devtools print("Installing unbiased package using devtools") devtools::load_all() - unbiased:::run_unbiased_local() - } else { - print("Running installed unbiased package") - unbiased:::run_unbiased() } + + unbiased:::run_unbiased() }, - args = list(working_directory = working_directory), + supervise = TRUE ) withr::defer( @@ -55,11 +199,19 @@ withr::defer( teardown_env() ) +# go back to the original working directory +# that is used by the testthat package +setwd(current_working_dir) # Retry a request until the API starts +print("Waiting for the API to start...") request(api_url) |> # Endpoint that should be always available req_url_path("meta", "sha") |> req_method("GET") |> - req_retry(max_tries = 5) |> + req_retry( + max_tries = 25, + backoff = \(x) 0.3 + ) |> req_perform() +print("API started, running tests...") \ No newline at end of file From 11913fb486d2f9a8d9207a80d5d763e30b39ee8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 13:34:05 +0000 Subject: [PATCH 140/240] renv and docker --- Dockerfile | 2 +- Dockerfile.postgres | 24 ++- R/db.R | 8 +- docker-compose.test.yaml | 3 +- renv.lock | 423 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 443 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 095b19b..4afd66f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY inst/ ./inst COPY R/ ./R COPY tests/ ./inst/tests -RUN R CMD INSTALL --no-multiarch . +# RUN R CMD INSTALL --no-multiarch . EXPOSE 3838 diff --git a/Dockerfile.postgres b/Dockerfile.postgres index b30d73b..af2c77a 100644 --- a/Dockerfile.postgres +++ b/Dockerfile.postgres @@ -1,15 +1,17 @@ # Start with the official PostgreSQL image based on Debian -FROM postgres:16 +FROM postgres:16-alpine # Run package updates and install necessary packages -RUN apt-get update \ - # Install PostgreSQL development headers and PGXN client - && apt-get install -y \ - postgresql-server-dev-16 \ - pgxnclient \ - make \ - gcc \ +RUN apk --no-cache add \ + python3 \ + py3-pip \ + cmake \ + make \ + gcc \ + g++ \ + clang15 \ + llvm15 \ + postgresql-dev \ + && pip install pgxnclient --break-system-packages \ # Install the 'temporal_tables' extension using PGXN - && pgxn install temporal_tables \ - # Clear apt cache to reduce image size - && rm -rf /var/lib/apt/lists/* + && pgxn install temporal_tables \ No newline at end of file diff --git a/R/db.R b/R/db.R index 37aa7f1..c6c9be8 100644 --- a/R/db.R +++ b/R/db.R @@ -12,7 +12,8 @@ create_db_connection_pool <- purrr::insistently(function() { }, rate = purrr::rate_delay(2, max_times = 5)) -get_similar_studies <- function(name, identifier) { +get_similar_studies <- function(name, identifier, envir = parent.env()) { + db_connection_pool <- get("db_connection_pool", envir = envir) similar <- dplyr::tbl(db_connection_pool, "study") |> dplyr::select(id, name, identifier) |> @@ -22,8 +23,9 @@ get_similar_studies <- function(name, identifier) { } create_study <- function( - name, identifier, method, parameters, arms, strata) { - connection <- pool::poolCheckout(db_connection_pool) + name, identifier, method, parameters, arms, strata, envir = parent.env()) { + db_connection_pool <- get("db_connection_pool", envir = envir) + connection <- pool::localCheckout(db_connection_pool) r <- tryCatch( { diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 41550c4..0c377cc 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -25,7 +25,6 @@ services: - POSTGRES_PASSWORD=postgres networks: - test_net - command: R -e "install.packages('covr'); covr::package_coverage()" - + command: R -e "covr::package_coverage()" networks: test_net: diff --git a/renv.lock b/renv.lock index f303d39..df53110 100644 --- a/renv.lock +++ b/renv.lock @@ -20,6 +20,119 @@ ], "Hash": "3e0051431dff9acfe66c23765e55c556" }, + "DT": { + "Package": "DT", + "Version": "0.31", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "crosstalk", + "htmltools", + "htmlwidgets", + "httpuv", + "jquerylib", + "jsonlite", + "magrittr", + "promises" + ], + "Hash": "77b5189f5272ae2b21e3ac2175ad107c" + }, + "KernSmooth": { + "Package": "KernSmooth", + "Version": "2.23-20", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "stats" + ], + "Hash": "8dcfa99b14c296bc9f1fd64d52fd3ce7" + }, + "MASS": { + "Package": "MASS", + "Version": "7.3-58.2", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats", + "utils" + ], + "Hash": "e02d1a0f6122fd3e634b25b433704344" + }, + "Matrix": { + "Package": "Matrix", + "Version": "1.5-3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "grid", + "lattice", + "methods", + "stats", + "utils" + ], + "Hash": "4006dffe49958d2dd591c17e61e60591" + }, + "R.cache": { + "Package": "R.cache", + "Version": "0.16.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R.methodsS3", + "R.oo", + "R.utils", + "digest", + "utils" + ], + "Hash": "fe539ca3f8efb7410c3ae2cf5fe6c0f8" + }, + "R.methodsS3": { + "Package": "R.methodsS3", + "Version": "1.8.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "utils" + ], + "Hash": "278c286fd6e9e75d0c2e8f731ea445c8" + }, + "R.oo": { + "Package": "R.oo", + "Version": "1.26.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R.methodsS3", + "methods", + "utils" + ], + "Hash": "4fed809e53ddb5407b3da3d0f572e591" + }, + "R.utils": { + "Package": "R.utils", + "Version": "2.12.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R.methodsS3", + "R.oo", + "methods", + "tools", + "utils" + ], + "Hash": "3dc2829b790254bfba21e60965787651" + }, "R6": { "Package": "R6", "Version": "2.5.1", @@ -126,6 +239,18 @@ ], "Hash": "40415719b5a479b87949f3aa0aee737c" }, + "boot": { + "Package": "boot", + "Version": "1.3-28.1", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "stats" + ], + "Hash": "9a052fbcbe97a98ceb18dbfd30ebd96e" + }, "brew": { "Package": "brew", "Version": "1.0-10", @@ -197,6 +322,19 @@ ], "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" }, + "class": { + "Package": "class", + "Version": "7.3-21", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "MASS", + "R", + "stats", + "utils" + ], + "Hash": "8ae0d4328e2eb3a582dfd5391a3663b7" + }, "cli": { "Package": "cli", "Version": "3.6.1", @@ -218,6 +356,37 @@ ], "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" }, + "cluster": { + "Package": "cluster", + "Version": "2.1.4", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "stats", + "utils" + ], + "Hash": "5edbbabab6ce0bf7900a74fd4358628e" + }, + "codetools": { + "Package": "codetools", + "Version": "0.2-19", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "c089a619a7fae175d149d89164f8c7d8" + }, + "collections": { + "Package": "collections", + "Version": "0.3.7", + "Source": "Repository", + "Repository": "RSPM", + "Hash": "90a0eda114ab0bef170ddbf5ef0cd93f" + }, "commonmark": { "Package": "commonmark", "Version": "1.9.0", @@ -225,6 +394,26 @@ "Repository": "RSPM", "Hash": "d691c61bff84bd63c383874d2d0c3307" }, + "covr": { + "Package": "covr", + "Version": "3.6.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "crayon", + "digest", + "httr", + "jsonlite", + "methods", + "rex", + "stats", + "utils", + "withr", + "yaml" + ], + "Hash": "0cbf0435830e767ba9b292b313592362" + }, "cpp11": { "Package": "cpp11", "Version": "0.4.6", @@ -261,6 +450,19 @@ ], "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" }, + "crosstalk": { + "Package": "crosstalk", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R6", + "htmltools", + "jsonlite", + "lazyeval" + ], + "Hash": "ab12c7b080a57475248a30f4db6298c0" + }, "curl": { "Package": "curl", "Version": "5.1.0", @@ -271,6 +473,20 @@ ], "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" }, + "cyclocomp": { + "Package": "cyclocomp", + "Version": "1.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "callr", + "crayon", + "desc", + "remotes", + "withr" + ], + "Hash": "cdc4a473222b0112d4df0bcfbed12d44" + }, "dbplyr": { "Package": "dbplyr", "Version": "2.4.0", @@ -469,6 +685,19 @@ ], "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" }, + "foreign": { + "Package": "foreign", + "Version": "0.8-84", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "methods", + "stats", + "utils" + ], + "Hash": "467ec0ca895d4e61a22cfbac9bccddf8" + }, "fs": { "Package": "fs", "Version": "1.6.3", @@ -694,6 +923,30 @@ ], "Hash": "1ec462871063897135c1bcbe0fc8f07d" }, + "languageserver": { + "Package": "languageserver", + "Version": "0.3.16", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "callr", + "collections", + "fs", + "jsonlite", + "lintr", + "parallel", + "roxygen2", + "stringi", + "styler", + "tools", + "utils", + "xml2", + "xmlparsedata" + ], + "Hash": "f8901f44aedb6d7e7d03b5533986bd97" + }, "later": { "Package": "later", "Version": "1.3.1", @@ -705,6 +958,31 @@ ], "Hash": "40401c9cf2bc2259dfe83311c9384710" }, + "lattice": { + "Package": "lattice", + "Version": "0.20-45", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "grid", + "stats", + "utils" + ], + "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" + }, + "lazyeval": { + "Package": "lazyeval", + "Version": "0.2.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "d908914ae53b04d4c0c0fd72ecc35370" + }, "lifecycle": { "Package": "lifecycle", "Version": "1.0.3", @@ -718,6 +996,27 @@ ], "Hash": "001cecbeac1cff9301bdc3775ee46a86" }, + "lintr": { + "Package": "lintr", + "Version": "3.1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "backports", + "codetools", + "cyclocomp", + "digest", + "glue", + "knitr", + "rex", + "stats", + "utils", + "xml2", + "xmlparsedata" + ], + "Hash": "93e9379f4be8c0bf1862dfc7f720193e" + }, "logger": { "Package": "logger", "Version": "0.2.2", @@ -769,6 +1068,23 @@ ], "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" }, + "mgcv": { + "Package": "mgcv", + "Version": "1.8-42", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "nlme", + "splines", + "stats", + "utils" + ], + "Hash": "3460beba7ccc8946249ba35327ba902a" + }, "mime": { "Package": "mime", "Version": "0.12", @@ -791,6 +1107,32 @@ ], "Hash": "fec5f52652d60615fdb3957b3d74324a" }, + "nlme": { + "Package": "nlme", + "Version": "3.1-162", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "0984ce8da8da9ead8643c5cbbb60f83e" + }, + "nnet": { + "Package": "nnet", + "Version": "7.3-18", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "stats", + "utils" + ], + "Hash": "170da2130d5332bea7d6ede01875ba1d" + }, "openssl": { "Package": "openssl", "Version": "2.1.1", @@ -1108,6 +1450,16 @@ ], "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" }, + "rex": { + "Package": "rex", + "Version": "1.2.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "lazyeval" + ], + "Hash": "ae34cd56890607370665bee5bd17812f" + }, "rlang": { "Package": "rlang", "Version": "1.1.3", @@ -1169,6 +1521,19 @@ ], "Hash": "c25fe7b2d8cba73d1b63c947bf7afdb9" }, + "rpart": { + "Package": "rpart", + "Version": "4.1.19", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "grDevices", + "graphics", + "stats" + ], + "Hash": "b3c892a81783376cc2204af0f5805a80" + }, "rprojroot": { "Package": "rprojroot", "Version": "2.0.3", @@ -1276,6 +1641,19 @@ ], "Hash": "5f5a7629f956619d519205ec475fe647" }, + "spatial": { + "Package": "spatial", + "Version": "7.3-16", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "stats", + "utils" + ], + "Hash": "1f7d528460600aeab2c2fcc5b3f5bab6" + }, "stringi": { "Package": "stringi", "Version": "1.7.12", @@ -1306,6 +1684,41 @@ ], "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" }, + "styler": { + "Package": "styler", + "Version": "1.10.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R.cache", + "cli", + "magrittr", + "purrr", + "rlang", + "rprojroot", + "tools", + "vctrs", + "withr" + ], + "Hash": "d61238fd44fc63c8adf4565efe8eb682" + }, + "survival": { + "Package": "survival", + "Version": "3.5-3", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "Matrix", + "R", + "graphics", + "methods", + "splines", + "stats", + "utils" + ], + "Hash": "aea2b8787db7088ba50ba389848569ee" + }, "swagger": { "Package": "swagger", "Version": "3.33.1", @@ -1591,6 +2004,16 @@ ], "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" }, + "xmlparsedata": { + "Package": "xmlparsedata", + "Version": "1.0.5", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "45e4bf3c46476896e821fc0a408fb4fc" + }, "xopen": { "Package": "xopen", "Version": "1.0.0", From ce5b2bd8d469b0c1e78e46bca1def26e966e5945 Mon Sep 17 00:00:00 2001 From: Ola Date: Wed, 31 Jan 2024 14:02:05 +0000 Subject: [PATCH 141/240] change data to datax --- vignettes/minimization_randomization_comparison.Rmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/minimization_randomization_comparison.Rmd index abb0b0b..a44b1f7 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -81,7 +81,7 @@ The number of iterations, indicates the number of iterations included in the Mon # defined number of patients n <- 105 # defined number of iterations -no_of_iterations <- 20 +no_of_iterations <- 5 ``` ## Defining parameters for Monte-Carlo simulation @@ -474,7 +474,7 @@ block_results <- function(data) { datay <- datarand[datarand$simnr == k, ] - for (i in data$id) { + for (i in datax$id) { matching_rows <- which( datay[2] == datax[3][datax[2] == i] & From f544c5b2763d0a75a2075109586c4b723e52385b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:58:12 +0100 Subject: [PATCH 142/240] coverage in ci --- .github/workflows/test-coverage.yaml | 79 ++++++++++++++++++++++++++++ Dockerfile.postgres | 17 ------ docker-compose.test.yaml | 12 +---- renv/.dockerignore | 7 +++ 4 files changed, 87 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/test-coverage.yaml delete mode 100644 Dockerfile.postgres create mode 100644 renv/.dockerignore diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..c0bae3f --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,79 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: test-coverage + +jobs: + test-coverage: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + POSTGRES_DB: postgres + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + + services: + postgres: + image: ghcr.io/ttscience/postgres-temporal-tables/postgres-temporal-tables:latest + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + + - name: Instal system dependencies + run: | + apt update && apt-get install -y --no-install-recommends \ + libz-dev \ + libsodium-dev \ + libpq-dev libssl-dev postgresql-client \ + libxt-dev + + - uses: r-lib/actions/setup-renv@v2 + + # - uses: r-lib/actions/setup-r-dependencies@v2 + # with: + # extra-packages: any::covr + # needs: coverage + + - name: Test coverage + run: | + covr::codecov( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + shell: Rscript {0} + + - name: Show testthat output + if: always() + run: | + ## -------------------------------------------------------------------- + find ${{ runner.temp }}/package -name 'testthat.Rout*' -exec cat '{}' \; || true + shell: bash + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v3 + with: + name: coverage-test-failures + path: ${{ runner.temp }}/package diff --git a/Dockerfile.postgres b/Dockerfile.postgres deleted file mode 100644 index af2c77a..0000000 --- a/Dockerfile.postgres +++ /dev/null @@ -1,17 +0,0 @@ -# Start with the official PostgreSQL image based on Debian -FROM postgres:16-alpine - -# Run package updates and install necessary packages -RUN apk --no-cache add \ - python3 \ - py3-pip \ - cmake \ - make \ - gcc \ - g++ \ - clang15 \ - llvm15 \ - postgresql-dev \ - && pip install pgxnclient --break-system-packages \ - # Install the 'temporal_tables' extension using PGXN - && pgxn install temporal_tables \ No newline at end of file diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 0c377cc..92389cc 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -1,14 +1,9 @@ version: "3.9" services: postgres: - image: temporal_postgres - build: - context: . - dockerfile: Dockerfile.postgres + image: ghcr.io/ttscience/postgres-temporal-tables/postgres-temporal-tables:latest environment: - POSTGRES_PASSWORD=postgres - networks: - - test_net tests: # image: unbiased build: @@ -17,14 +12,9 @@ services: depends_on: - postgres environment: - - CI=true - POSTGRES_DB=postgres - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - networks: - - test_net command: R -e "covr::package_coverage()" -networks: - test_net: diff --git a/renv/.dockerignore b/renv/.dockerignore new file mode 100644 index 0000000..0ec0cbb --- /dev/null +++ b/renv/.dockerignore @@ -0,0 +1,7 @@ +library/ +local/ +cellar/ +lock/ +python/ +sandbox/ +staging/ From a0cd37b56e362902d350eb52cf08e8b4773715a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:07:35 +0000 Subject: [PATCH 143/240] apt-get instead of apt --- .github/workflows/test-coverage.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index c0bae3f..8bf9d6d 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -42,7 +42,7 @@ jobs: - name: Instal system dependencies run: | - apt update && apt-get install -y --no-install-recommends \ + apt-get update && apt-get install -y --no-install-recommends \ libz-dev \ libsodium-dev \ libpq-dev libssl-dev postgresql-client \ From 63cf54d4bb8d0fa1f0f46151b0b6d65074eb7ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:10:44 +0000 Subject: [PATCH 144/240] branches --- .github/workflows/test-coverage.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 8bf9d6d..392ee66 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main, devel] pull_request: - branches: [main, master] + branches: [main, devel] name: test-coverage From 8673c41e5b53b048acff4f79b8c1d63b13f2bc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:14:37 +0000 Subject: [PATCH 145/240] sudo --- .github/workflows/test-coverage.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 392ee66..39013e1 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -42,11 +42,12 @@ jobs: - name: Instal system dependencies run: | - apt-get update && apt-get install -y --no-install-recommends \ - libz-dev \ - libsodium-dev \ - libpq-dev libssl-dev postgresql-client \ - libxt-dev + sudo apt-get update && \ + sudo apt-get install -y --no-install-recommends \ + libz-dev \ + libsodium-dev \ + libpq-dev libssl-dev postgresql-client \ + libxt-dev - uses: r-lib/actions/setup-renv@v2 From d190df7a0a20da662823502f268291ba4f067e08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:21:53 +0000 Subject: [PATCH 146/240] postgres service port --- .github/workflows/test-coverage.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 39013e1..6299ee7 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -29,6 +29,8 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v2 From 05943a1fb2cfd1824414e56a57764835b9a11d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:43:09 +0000 Subject: [PATCH 147/240] migrate from CI --- .github/workflows/test-coverage.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 6299ee7..c707d4c 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -58,6 +58,15 @@ jobs: # extra-packages: any::covr # needs: coverage + - name: Install migrate + run: | + curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | \ + apt-key add - && \ + echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" \ + > /etc/apt/sources.list.d/migrate.list && \ + apt-get update && \ + apt-get install -y migrate + - name: Test coverage run: | covr::codecov( From 7beb0993421bea4dc7c614f59fcc5216141c61ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 16:51:24 +0000 Subject: [PATCH 148/240] sudo --- .github/workflows/test-coverage.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index c707d4c..a29dc91 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -61,11 +61,11 @@ jobs: - name: Install migrate run: | curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | \ - apt-key add - && \ - echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" \ + sudo apt-key add - && \ + sudo echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" \ > /etc/apt/sources.list.d/migrate.list && \ - apt-get update && \ - apt-get install -y migrate + sudo apt-get update && \ + sudo apt-get install -y migrate - name: Test coverage run: | From 701e24a6ad76baeaa0bb666071eb7ccc6c6231ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 17:02:04 +0000 Subject: [PATCH 149/240] tee instead of shell stdout redirection --- .github/workflows/test-coverage.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index a29dc91..c7866b9 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -62,8 +62,8 @@ jobs: run: | curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | \ sudo apt-key add - && \ - sudo echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" \ - > /etc/apt/sources.list.d/migrate.list && \ + echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" | \ + sudo tee /etc/apt/sources.list.d/migrate.list && \ sudo apt-get update && \ sudo apt-get install -y migrate From 20e6caad2cda67a1526d57b310563dafc9f16e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 17:05:36 +0000 Subject: [PATCH 150/240] remove old workflow --- .github/workflows/R-CMD-check.yaml | 36 ------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/R-CMD-check.yaml diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml deleted file mode 100644 index 08a141c..0000000 --- a/.github/workflows/R-CMD-check.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples -# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help -on: - push: - branches: [main, devel] - pull_request: - branches: [main, devel] - -name: Tests - -jobs: - R-CMD-check: - runs-on: ubuntu-latest - - name: Ubuntu (latest) - - strategy: - fail-fast: false - - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - R_KEEP_PKG_SOURCE: yes - - steps: - - uses: actions/checkout@v3 - - - uses: r-lib/actions/setup-pandoc@v2 - - - name: Build API image - run: docker build -t unbiased --build-arg github_sha=${{ github.sha }} . - - - name: Build custom PostgreSQL image - run: docker build -t temporal_postgres -f Dockerfile.postgres . - - - name: Run tests - run: docker compose -f "docker-compose.test.yaml" up --abort-on-container-exit --exit-code-from tests From 668473c2ba102a544ab88b4b10decf7fe4074d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Wed, 31 Jan 2024 17:27:39 +0000 Subject: [PATCH 151/240] improve db connection settings for tests --- R/db.R | 8 ++++---- tests/testthat/setup-DB.R | 14 -------------- ...{setup-api.R => setup-testing-environment.R} | 17 +++++++++++++++++ tests/testthat/test-DB-0.R | 6 ++++++ tests/testthat/test-DB-study.R | 8 ++++++++ tests/testthat/test-helpers.R | 11 +++++++---- 6 files changed, 42 insertions(+), 22 deletions(-) delete mode 100644 tests/testthat/setup-DB.R rename tests/testthat/{setup-api.R => setup-testing-environment.R} (91%) diff --git a/R/db.R b/R/db.R index c6c9be8..631c862 100644 --- a/R/db.R +++ b/R/db.R @@ -12,8 +12,8 @@ create_db_connection_pool <- purrr::insistently(function() { }, rate = purrr::rate_delay(2, max_times = 5)) -get_similar_studies <- function(name, identifier, envir = parent.env()) { - db_connection_pool <- get("db_connection_pool", envir = envir) +get_similar_studies <- function(name, identifier) { + db_connection_pool <- get("db_connection_pool") similar <- dplyr::tbl(db_connection_pool, "study") |> dplyr::select(id, name, identifier) |> @@ -23,8 +23,8 @@ get_similar_studies <- function(name, identifier, envir = parent.env()) { } create_study <- function( - name, identifier, method, parameters, arms, strata, envir = parent.env()) { - db_connection_pool <- get("db_connection_pool", envir = envir) + name, identifier, method, parameters, arms, strata) { + db_connection_pool <- get("db_connection_pool", envir = .GlobalEnv) connection <- pool::localCheckout(db_connection_pool) r <- tryCatch( diff --git a/tests/testthat/setup-DB.R b/tests/testthat/setup-DB.R deleted file mode 100644 index 45b735a..0000000 --- a/tests/testthat/setup-DB.R +++ /dev/null @@ -1,14 +0,0 @@ -db_pool <- create_db_connection_pool() -conn <- pool::poolCheckout(db_pool) - -assign("conn", conn, envir = .GlobalEnv) -assign("db_pool", db_pool, envir = .GlobalEnv) - -# Close DB connection upon exiting -withr::defer( - { - pool::poolReturn(conn) - pool::poolClose(db_pool) - }, - teardown_env() -) diff --git a/tests/testthat/setup-api.R b/tests/testthat/setup-testing-environment.R similarity index 91% rename from tests/testthat/setup-api.R rename to tests/testthat/setup-testing-environment.R index 6cc9fee..2a8bac7 100644 --- a/tests/testthat/setup-api.R +++ b/tests/testthat/setup-testing-environment.R @@ -30,6 +30,12 @@ run_migrations <- function() { port <- Sys.getenv("POSTGRES_PORT", "5432") db <- Sys.getenv("POSTGRES_DB") + print( + glue::glue( + "Running migrations on database {db} at {host}:{port}" + ) + ) + migrations_path <- glue::glue( "{root_repo_directory}/inst/db/migrations" ) @@ -58,6 +64,15 @@ run_migrations <- function() { system2(command, args) } +setup_test_db_connection_pool <- function() { + # We will create a connection pool to the database + # and store it in the global environment + # so that we can use it in the tests + # without having to pass it around + db_connection_pool <- unbiased:::create_db_connection_pool() + assign("db_connection_pool", db_connection_pool, envir = globalenv()) +} + # We will always run the API on the localhost # and on a random port @@ -203,6 +218,8 @@ withr::defer( # that is used by the testthat package setwd(current_working_dir) +setup_test_db_connection_pool() + # Retry a request until the API starts print("Waiting for the API to start...") request(api_url) |> diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index 95f069c..ffa510d 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -8,6 +8,9 @@ source("./test-helpers.R") # Test values ---- test_that("database contains base tables", { + conn <- pool::localCheckout( + get("db_connection_pool", envir = globalenv()) + ) with_db_fixtures("fixtures/example_study.yml") expect_contains( DBI::dbListTables(conn), @@ -16,6 +19,9 @@ test_that("database contains base tables", { }) test_that("database contains history tables", { + conn <- pool::localCheckout( + get("db_connection_pool", envir = globalenv()) + ) with_db_fixtures("fixtures/example_study.yml") expect_contains( DBI::dbListTables(conn), diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 218188f..a4bf0e3 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -1,6 +1,9 @@ source("./test-helpers.R") +pool <- get("db_connection_pool", envir = globalenv()) + test_that("it is enough to provide a name, an identifier, and a method id", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") expect_no_error({ tbl(conn, "study") |> @@ -19,6 +22,7 @@ test_that("it is enough to provide a name, an identifier, and a method id", { new_study_id <- 1 |> as.integer() test_that("deleting archivizes a study", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") expect_no_error({ tbl(conn, "study") |> @@ -43,6 +47,7 @@ test_that("deleting archivizes a study", { }) test_that("can't push arm with negative ratio", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") expect_error({ tbl(conn, "arm") |> @@ -58,6 +63,7 @@ test_that("can't push arm with negative ratio", { }) test_that("can't push stratum other than factor or numeric", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") expect_error({ tbl(conn, "stratum") |> @@ -73,6 +79,7 @@ test_that("can't push stratum other than factor or numeric", { }) test_that("can't push stratum level outside of defined levels", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") # create a new patient return <- @@ -112,6 +119,7 @@ test_that("can't push stratum level outside of defined levels", { }) test_that("numerical constraints are enforced", { + conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") added_patient_id <- 1 |> as.integer() return <- diff --git a/tests/testthat/test-helpers.R b/tests/testthat/test-helpers.R index df73feb..20eb7c6 100644 --- a/tests/testthat/test-helpers.R +++ b/tests/testthat/test-helpers.R @@ -12,7 +12,8 @@ all_tables <- c( ) with_db_fixtures <- function(test_data_path, env = parent.frame()) { - conn <- get("conn", envir = .GlobalEnv) + pool <- get("db_connection_pool", envir = .GlobalEnv) + conn <- pool::localCheckout(pool) # load test data in yaml format test_data <- yaml::read_yaml(test_data_path) @@ -42,9 +43,11 @@ with_db_fixtures <- function(test_data_path, env = parent.frame()) { } truncate_tables <- function(tables) { + pool <- get("db_connection_pool", envir = .GlobalEnv) + conn <- pool::localCheckout(pool) DBI::dbExecute( "SET client_min_messages TO WARNING;", - conn = get("conn", envir = .GlobalEnv) + conn = conn ) tables |> rev() |> @@ -52,8 +55,8 @@ truncate_tables <- function(tables) { \(table_name) { glue::glue_sql( "TRUNCATE TABLE {`table_name`} RESTART IDENTITY CASCADE;", - .con = get("conn", envir = .GlobalEnv) - ) |> DBI::dbExecute(conn = get("conn", envir = .GlobalEnv)) + .con = conn + ) |> DBI::dbExecute(conn = conn) } ) } \ No newline at end of file From 64a924074e04a38898a85f694d33e4e752baef94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 09:32:37 +0000 Subject: [PATCH 152/240] improve db handling --- .devcontainer/docker-compose.yml | 4 +- tests/testthat/setup-testing-environment.R | 79 +++++++++++++++++----- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index e6c3cb5..a341a70 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -41,9 +41,7 @@ services: - "5454:80" db: - build: - context: .. - dockerfile: Dockerfile.postgres + image: ghcr.io/ttscience/postgres-temporal-tables/postgres-temporal-tables:latest restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index 2a8bac7..66d879e 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -64,13 +64,71 @@ run_migrations <- function() { system2(command, args) } -setup_test_db_connection_pool <- function() { +create_database <- function(db_name) { + # make sure we are not creating the database that we are using for connection + assert( + db_name != Sys.getenv("POSTGRES_DB"), + "Cannot create the database that is used for connection" + ) + print( + glue::glue( + "Creating database {db_name}" + ) + ) + run_psql( + glue::glue( + "CREATE DATABASE {db_name}" + ) + ) +} + +drop_database <- function(db_name) { + # make sure we are not dropping the database that we are using for connection + assert( + db_name != Sys.getenv("POSTGRES_DB"), + "Cannot drop the database that is used for connection" + ) + # first, terminate all connections to the database + print( + glue::glue( + "Terminating all connections to the database {db_name}" + ) + ) + run_psql( + glue::glue( + "SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{db_name}' + AND pid <> pg_backend_pid();" + ) + ) + print( + glue::glue( + "Dropping database {db_name}" + ) + ) + run_psql( + glue::glue( + "DROP DATABASE {db_name}" + ) + ) +} + +setup_test_db_connection_pool <- function(envir = parent.frame()) { # We will create a connection pool to the database # and store it in the global environment # so that we can use it in the tests # without having to pass it around db_connection_pool <- unbiased:::create_db_connection_pool() assign("db_connection_pool", db_connection_pool, envir = globalenv()) + withr::defer( + { + print("Closing database connection pool") + db_connection_pool$close() + assign("db_connection_pool", NULL, envir = globalenv()) + }, + envir = envir + ) } @@ -98,11 +156,7 @@ db_name <- Sys.getenv("POSTGRES_DB") db_name_test <- glue::glue("{db_name}__test") # create the test database using connection with the original database -run_psql( - glue::glue( - "CREATE DATABASE {db_name_test}" - ) -) +create_database(db_name_test) # now that the database is created, we can set the environment variable # to the test database name @@ -122,16 +176,7 @@ withr::defer( "db_name_test should end with __test" ) setwd(root_repo_directory) - print( - glue::glue( - "Dropping test database {db_name_test}" - ) - ) - run_psql( - glue::glue( - "DROP DATABASE {db_name_test}" - ) - ) + drop_database(db_name_test) }, teardown_env() ) @@ -218,7 +263,7 @@ withr::defer( # that is used by the testthat package setwd(current_working_dir) -setup_test_db_connection_pool() +setup_test_db_connection_pool(envir = teardown_env()) # Retry a request until the API starts print("Waiting for the API to start...") From edf1e2b681ac107dac81c235e681a41d48aa6425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 11:24:28 +0000 Subject: [PATCH 153/240] linter --- .github/workflows/lint.yaml | 32 ++++ R/api_create_study.R | 7 +- R/api_randomize.R | 174 +++++++++-------- R/db.R | 6 +- R/randomize-minimisation-pocock.R | 157 ++++++++------- R/randomize-simple.R | 3 +- R/run-api.R | 3 +- inst/plumber/unbiased_api/plumber.R | 29 ++- inst/plumber/unbiased_api/study.R | 13 +- tests/testthat/setup-testing-environment.R | 2 +- tests/testthat/test-DB-study.R | 171 ++++++++++------- tests/testthat/test-E2E-meta-tag.R | 2 +- .../test-E2E-study-minimisation-pocock.R | 68 ++++--- tests/testthat/test-helpers.R | 3 +- .../test-randomize-minimisation-pocock.R | 180 ++++++++++++------ tests/testthat/test-randomize-simple.R | 52 +++-- 16 files changed, 555 insertions(+), 347 deletions(-) create mode 100644 .github/workflows/lint.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..f60d047 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,32 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: lint + +jobs: + lint: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::lintr, local::. + needs: lint + + - name: Lint + run: lintr::lint_package() + shell: Rscript {0} + env: + LINTR_ERROR_ON_LINT: true diff --git a/R/api_create_study.R b/R/api_create_study.R index fbd1e47..64e6a65 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -1,6 +1,5 @@ -api__create_study_minimization_pocock <- function( - identifier, name, method, arms, covariates, p, req, res -) { +api__minimization_pocock <- function( # nolint: cyclocomp_linter. + identifier, name, method, arms, covariates, p, req, res) { validation_errors <- vector() err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) @@ -181,4 +180,4 @@ api__create_study_minimization_pocock <- function( } return(response) -} \ No newline at end of file +} diff --git a/R/api_randomize.R b/R/api_randomize.R index 5840dc9..9916df0 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -1,24 +1,34 @@ +utils::globalVariables(".data") + api__randomize_patient <- function(study_id, current_state, req, res) { collection <- checkmate::makeAssertCollection() - + + db_connection_pool <- get("db_connection_pool") + # Check whether study with study_id exists - checkmate::assert(checkmate::check_subset(x = req$args$study_id, - choices = - dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(id) |> - dplyr::pull()), .var.name = "Study ID", - add = collection) + checkmate::assert( + checkmate::check_subset( + x = req$args$study_id, + choices = dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(.data$id) |> + dplyr::pull() + ), + .var.name = "Study ID", + add = collection + ) # Retrieve study details, especially the ones about randomization method_randomization <- dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::select(method) |> + dplyr::filter(.data$id == study_id) |> + dplyr::select(.data$method) |> dplyr::pull() - checkmate::assert(checkmate::check_scalar(method_randomization, null.ok = FALSE), - .var.name = "Randomization method", - add = collection) + checkmate::assert( + checkmate::check_scalar(method_randomization, null.ok = FALSE), + .var.name = "Randomization method", + add = collection + ) if (length(collection$getMessages()) > 0) { res$status <- 400 @@ -30,94 +40,94 @@ api__randomize_patient <- function(study_id, current_state, req, res) { # Dispatch based on randomization method to parse parameters params <- - switch( - method_randomization, - minimisation_pocock = tryCatch({ - do.call(parse_pocock_parameters, list(db_connection_pool, study_id, current_state)) - }, error = function(e) { - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - }) + switch(method_randomization, + minimisation_pocock = tryCatch( + { + do.call( + parse_pocock_parameters, + list(db_connection_pool, study_id, current_state) + ) + }, + error = function(e) { + res$status <- 400 + res$body <- glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err = e) + } + ) ) arm_name <- - switch( - method_randomization, - # simple = do.call(unbiased:::randomize_simple, params), - minimisation_pocock = tryCatch({ - do.call(unbiased:::randomize_minimisation_pocock, params) - }, error = function(e) { - res$status <- 400 - res$body = glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err=e) - } + switch(method_randomization, + minimisation_pocock = tryCatch( + { + do.call(unbiased:::randomize_minimisation_pocock, params) + }, + error = function(e) { + res$status <- 400 + res$body <- glue::glue("Error message: {conditionMessage(e)}") + logger::log_error("Error: {err}", err = e) + } ) ) arm <- dplyr::tbl(db_connection_pool, "arm") |> - dplyr::filter(study_id == !!study_id & name == arm_name) |> - dplyr::select(arm_id = id, name, ratio) |> + dplyr::filter(study_id == !!study_id & .data$name == arm_name) |> + dplyr::select(arm_id = .data$id, .data$name, .data$ratio) |> dplyr::collect() unbiased:::save_patient(study_id, arm$arm_id) |> - dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = id) |> - as.list() + dplyr::mutate(arm_name = arm$name) |> + dplyr::rename(patient_id = id) |> + as.list() } -parse_pocock_parameters <- function(db_connetion_pool, study_id, current_state){ - parameters <- - dplyr::tbl(db_connetion_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::select(parameters) |> - dplyr::pull() +parse_pocock_parameters <- + function(db_connetion_pool, study_id, current_state) { + parameters <- + dplyr::tbl(db_connetion_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::select(parameters) |> + dplyr::pull() - parameters <- jsonlite::fromJSON(parameters) + parameters <- jsonlite::fromJSON(parameters) - if (!checkmate::test_list(parameters, null.ok = FALSE)){ - message <- checkmate::test_list(parameters, null.ok = FALSE) - res$status <- 400 - res$body <- - c( - response, + if (!checkmate::test_list(parameters, null.ok = FALSE)) { + message <- checkmate::test_list(parameters, null.ok = FALSE) + res$status <- 400 + res$body <- list( - error = glue::glue("Parse validation failed. 'Parameters' must be a list: {message}") + error = glue::glue( + "Parse validation failed. 'Parameters' must be a list: {message}" + ) ) - ) - return(res) - } - # do testowania - # parameters <- jsonlite::fromJSON('{"method": "var", "p": 0.85, "weights": {"gender": 1, "age_group" : 2, "height" : 1}}') + return(res) + } - ratio_arms <- - dplyr::tbl(db_connetion_pool, "arm") |> - dplyr::filter(study_id == !!study_id) |> - dplyr::select(name, ratio) |> - dplyr::collect() + ratio_arms <- + dplyr::tbl(db_connetion_pool, "arm") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::select(.data$name, .data$ratio) |> + dplyr::collect() - params <- list( - arms = ratio_arms$name, - current_state = tibble::as_tibble(current_state), - ratio = setNames(ratio_arms$ratio, ratio_arms$name), - method = parameters$method, - p = parameters$p, - weights = parameters$weights |> unlist() - ) + params <- list( + arms = ratio_arms$name, + current_state = tibble::as_tibble(current_state), + ratio = setNames(ratio_arms$ratio, ratio_arms$name), + method = parameters$method, + p = parameters$p, + weights = parameters$weights |> unlist() + ) - if (!checkmate::test_list(params, null.ok = FALSE)){ - message <- checkmate::test_list(params, null.ok = FALSE) - res$status <- 400 - res$body <- - c( - response, - list( - error = glue::glue("Parse validation failed. Input parameters must be a list: {message}") - ) - ) - return(res) - } + if (!checkmate::test_list(params, null.ok = FALSE)) { + message <- checkmate::test_list(params, null.ok = FALSE) + res$status <- 400 + res$body <- + list(error = glue::glue( + "Parse validation failed. Input parameters must be a list: {message}" + )) + return(res) + } - return(params) -} + return(params) + } diff --git a/R/db.R b/R/db.R index 631c862..d1ece84 100644 --- a/R/db.R +++ b/R/db.R @@ -50,7 +50,7 @@ create_study <- function( study$parameters <- jsonlite::fromJSON(study$parameters) arm_records <- arms |> - purrr::imap(\(x, name) list(name=name, ratio=x)) |> + purrr::imap(\(x, name) list(name = name, ratio = x)) |> purrr::map(tibble::as_tibble) |> purrr::list_c() arm_records$study_id <- study$id @@ -116,7 +116,7 @@ create_study <- function( list(study = study) }, error = function(cond) { - logger::log_error("Error creating study: {cond}", cond=cond) + logger::log_error("Error creating study: {cond}", cond = cond) DBI::dbRollback(connection) list(error = conditionMessage(cond)) } @@ -125,7 +125,7 @@ create_study <- function( r } -save_patient <- function(study_id, arm_id){ +save_patient <- function(study_id, arm_id) { randomized_patient <- DBI::dbGetQuery( db_connection_pool, "INSERT INTO patient (arm_id, study_id) diff --git a/R/randomize-minimisation-pocock.R b/R/randomize-minimisation-pocock.R index c9537e0..b2a23c6 100644 --- a/R/randomize-minimisation-pocock.R +++ b/R/randomize-minimisation-pocock.R @@ -1,26 +1,27 @@ #' Compare rows of two dataframes #' -#' Takes dataframe B (presumably with one row / patient) and compares it to all -#' rows of A (presumably already randomized patietns) +#' Takes dataframe all_patients (presumably with one row / patient) and +#' compares it to all rows of new_patients (presumably already randomized +#' patients) #' -#' @param A data.frame with all patients -#' @param B data.frame with new patient +#' @param all_patients data.frame with all patients +#' @param new_patients data.frame with new patient #' -#' @return data.frame with columns as in A and B, filled with TRUE if there is -#' match in covariate and FALSE if not -compare_rows <- function(A, B) { +#' @return data.frame with columns as in all_patients and new_patients, +#' filled with TRUE if there is match in covariate and FALSE if not +compare_rows <- function(all_patients, new_patients) { # Find common column names - common_cols <- intersect(names(A), names(B)) + common_cols <- intersect(names(all_patients), names(new_patients)) # Compare each common column of A with B comparisons <- lapply(common_cols, function(col) { - A[[col]] == B[[col]] + all_patients[[col]] == new_patients[[col]] }) # Combine the comparisons into a new dataframe - C <- data.frame(comparisons) - names(C) <- common_cols - tibble::as_tibble(C) + comparison_df <- data.frame(comparisons) + names(comparison_df) <- common_cols + tibble::as_tibble(comparison_df) } @@ -31,15 +32,16 @@ compare_rows <- function(A, B) { #' The `randomize_dynamic` function implements the dynamic randomization #' algorithm using the minimization method proposed by Pocock (Pocock and Simon, #' 1975). It requires defining basic study parameters: the number of arms (K), -#' number of covariates (C), patient allocation ratios (\(a_{k}\)) (where k = 1,2,…., K), -#' weights for the covariates (\(w_{i}\)) (where i = 1,2,…., C), and the maximum probability (p) -#' of assigning a patient to the group with the smallest total unbalance multiplied by -#' the respective weights (\(G_{k}\)). As the total unbalance for the first patient is the same -#' regardless of the assigned arm, this patient is randomly allocated to a given -#' arm. Subsequent patients are randomized based on the calculation of the -#' unbalance depending on the selected method: "range", "var" (variance), or -#' "sd" (standard deviation). In the case of two arms, the "range" method is -#' equivalent to the "sd" method. +#' number of covariates (C), patient allocation ratios (\(a_{k}\)) +#' (where k = 1,2,…., K), weights for the covariates (\(w_{i}\)) +#' (where i = 1,2,…., C), and the maximum probability (p) of assigning a patient +#' to the group with the smallest total unbalance multiplied by +#' the respective weights (\(G_{k}\)). As the total unbalance for the first +#' patient is the same regardless of the assigned arm, this patient is randomly +#' allocated to a given arm. Subsequent patients are randomized based on the +#' calculation of the unbalance depending on the selected method: "range", +#' "var" (variance), or "sd" (standard deviation). In the case of two arms, +#' the "range" method is equivalent to the "sd" method. #' #' Initially, the algorithm creates a matrix of results comparing a newly #' randomized patient with the current balance of patients based on the defined @@ -53,12 +55,16 @@ compare_rows <- function(A, B) { #' Based on the number of defined arms, the minimum value of (\(G_{k}\)) #' (defined as the weighted sum of the level-based imbalance) selects the arm to #' which the patient will be assigned with a predefined probability (p). The -#' probability that a patient will be assigned to any other arm will then be equal (1-p)/(K-1) +#' probability that a patient will be assigned to any other arm will then be +#' equal (1-p)/(K-1) #' for each of the remaining arms. -#' @references Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. -#' @references Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. (https://CRAN.R-project.org/package=Minirand) -#' @note This function's implementation is a refactored adaptation of the codebase from the 'Minirand' package. +#' @references Pocock, S. J., & Simon, R. (1975). Minimization: A new method +#' of assigning patients to treatment and control groups in clinical trials. +#' @references Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. +#' (https://CRAN.R-project.org/package=Minirand) +#' @note This function's implementation is a refactored adaptation +#' of the codebase from the 'Minirand' package. #' #' @inheritParams randomize_simple #' @@ -81,33 +87,39 @@ compare_rows <- function(A, B) { #' n_at_the_moment <- 10 #' arms <- c("control", "active low", "active high") #' sex <- sample(c("F", "M"), -#' n_at_the_moment + 1, -#' replace = TRUE, -#' prob = c(0.4, 0.6) +#' n_at_the_moment + 1, +#' replace = TRUE, +#' prob = c(0.4, 0.6) #' ) #' diabetes <- #' sample(c("diabetes", "no diabetes"), -#' n_at_the_moment + 1, -#' replace = TRUE, -#' prob = c(0.2, 0.8) +#' n_at_the_moment + 1, +#' replace = TRUE, +#' prob = c(0.2, 0.8) #' ) #' arm <- #' sample(arms, -#' n_at_the_moment, -#' replace = TRUE, -#' prob = c(0.4, 0.4, 0.2) +#' n_at_the_moment, +#' replace = TRUE, +#' prob = c(0.4, 0.4, 0.2) #' ) |> #' c("") #' covar_df <- tibble::tibble(sex, diabetes, arm) #' covar_df #' #' randomize_minimisation_pocock(arms = arms, current_state = covar_df) -#' randomize_minimisation_pocock(arms = arms, current_state = covar_df, -#' ratio = c("control" = 1, -#' "active low" = 2, -#' "active high" = 2), -#' weights = c("sex" = 0.5, -#' "diabetes" = 1)) +#' randomize_minimisation_pocock( +#' arms = arms, current_state = covar_df, +#' ratio = c( +#' "control" = 1, +#' "active low" = 2, +#' "active high" = 2 +#' ), +#' weights = c( +#' "sex" = 0.5, +#' "diabetes" = 1 +#' ) +#' ) #' #' @export randomize_minimisation_pocock <- @@ -117,17 +129,23 @@ randomize_minimisation_pocock <- ratio, method = "var", p = 0.85) { - # Assertions checkmate::assert_character( arms, min.len = 2, min.chars = 1, - unique = TRUE) + unique = TRUE + ) + + supported_methods <- list( + "range" = custom_range, + "var" = var, + "sd" = sd + ) checkmate::assert_choice( method, - choices = c("range", "var", "sd") + choices = names(supported_methods), ) checkmate::assert_tibble( current_state, @@ -142,7 +160,8 @@ randomize_minimisation_pocock <- ) checkmate::assert_character( current_state$arm[nrow(current_state)], - max.chars = 0, .var.name = "Last value of 'arm'") + max.chars = 0, .var.name = "Last value of 'arm'" + ) n_covariates <- (ncol(current_state) - 1) @@ -160,8 +179,9 @@ randomize_minimisation_pocock <- names(ratio) <- arms } if (rlang::is_missing(weights)) { - weights <- rep(1/n_covariates, n_covariates) - names(weights) <- colnames(current_state)[colnames(current_state) != "arm"] + weights <- rep(1 / n_covariates, n_covariates) + names(weights) <- + colnames(current_state)[colnames(current_state) != "arm"] } checkmate::assert_numeric( @@ -197,11 +217,10 @@ randomize_minimisation_pocock <- lower = 0, upper = 1, null.ok = FALSE - ) + ) # Computations n_at_the_moment <- nrow(current_state) - 1 - covariate_names <- names(current_state)[names(current_state) != "arm"] if (n_at_the_moment == 0) { return(randomize_simple(arms, ratio)) @@ -218,29 +237,32 @@ randomize_minimisation_pocock <- dplyr::bind_rows(.id = "arm") |> # make sure that every arm has a metric, even if not present in data yet tidyr::complete(arm = arms) |> - dplyr::mutate(dplyr::across(dplyr::where(is.numeric), - ~ tidyr::replace_na(.x, 0))) - - # Define a custom range function - range <- function(x) { - max(x, na.rm = TRUE) - min(x, na.rm = TRUE) - } + dplyr::mutate(dplyr::across( + dplyr::where(is.numeric), + ~ tidyr::replace_na(.x, 0) + )) imbalance <- sapply(arms, function(x) { arms_similarity |> # compute scenario where each arm (x) gets new subject - dplyr::mutate(dplyr::across(dplyr::where(is.numeric), - ~ dplyr::if_else(arm == x, .x + 1, .x) * - ratio[arm])) |> + dplyr::mutate(dplyr::across( + dplyr::where(is.numeric), + ~ dplyr::if_else(arm == x, .x + 1, .x) * + ratio[arm] + )) |> # compute dispersion across each covariate - dplyr::summarise(dplyr::across(dplyr::where(is.numeric), - ~ get(method)(.x))) |> + dplyr::summarise(dplyr::across( + dplyr::where(is.numeric), + ~ supported_methods[[method]](.x) + )) |> # multiply each covariate dispersion by covariate weight - dplyr::mutate(dplyr::across(dplyr::everything(), - ~ . * weights[dplyr::cur_column()])) |> + dplyr::mutate(dplyr::across( + dplyr::everything(), + ~ . * weights[dplyr::cur_column()] + )) |> # sum all covariate outcomes dplyr::summarize(total = sum(dplyr::c_across(dplyr::everything()))) |> - dplyr::pull(total) + dplyr::pull("total") }) high_prob_arms <- names(which(imbalance == min(imbalance))) @@ -255,7 +277,8 @@ randomize_minimisation_pocock <- prob = c( rep( p / length(high_prob_arms), - length(high_prob_arms)), + length(high_prob_arms) + ), rep( (1 - p) / length(low_prob_arms), length(low_prob_arms) @@ -263,3 +286,9 @@ randomize_minimisation_pocock <- ) ) } + + +# Define a custom range function +custom_range <- function(x) { + max(x, na.rm = TRUE) - min(x, na.rm = TRUE) +} diff --git a/R/randomize-simple.R b/R/randomize-simple.R index 441565b..a8b558a 100644 --- a/R/randomize-simple.R +++ b/R/randomize-simple.R @@ -28,7 +28,8 @@ randomize_simple <- function(arms, ratio) { arms, any.missing = FALSE, unique = TRUE, - min.chars = 1) + min.chars = 1 + ) checkmate::assert_integerish( ratio, diff --git a/R/run-api.R b/R/run-api.R index e5a80d9..9030be7 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -37,5 +37,4 @@ run_unbiased <- function() { plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> plumber::pr_run(host = host, port = port) } - -} \ No newline at end of file +} diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 5eca066..3f2b07d 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -1,10 +1,20 @@ #* @apiTitle Unbiased -#* @apiDescription This API provides a diverse range of randomization algorithms specifically designed for use in clinical trials. It supports dynamic strategies such as the minimization method, as well as simpler approaches including standard and block randomization. The main goal of this API is to ensure seamless integration with electronic Case Report Form (eCRF) systems, facilitating efficient patient allocation management in clinical trials. -#* @apiContact list(name = "GitHub", url = "https://ttscience.github.io/unbiased/") -#* @apiLicense list(name = "MIT", url = "https://github.com/ttscience/unbiased/LICENSE.md") +#* @apiDescription This API provides a diverse range of randomization +#* algorithms specifically designed for use in clinical trials. It supports +#* dynamic strategies such as the minimization method, as well as simpler +#* approaches including standard and block randomization. The main goal of +#* this API is to ensure seamless integration with electronic Case Report +#* Form (eCRF) systems, facilitating efficient patient allocation management +#* in clinical trials. +#* @apiContact list(name = "GitHub", +#* url = "https://ttscience.github.io/unbiased/") +#* @apiLicense list(name = "MIT", +#* url = "https://github.com/ttscience/unbiased/LICENSE.md") #* @apiVersion 0.0.0.9003 -#* @apiTag initialize Endpoints that initialize study with chosen randomization method and parameters. -#* @apiTag randomize Endpoints that randomize individual patients after the study was created. +#* @apiTag initialize Endpoints that initialize study with chosen +#* randomization method and parameters. +#* @apiTag randomize Endpoints that randomize individual patients after the +#* study was created. #* @apiTag other Other endpoints (helpers etc.). #* #* @plumber @@ -67,9 +77,11 @@ function(api) { paths$`/study/{study_id}/patient`$ post$requestBody$content$`application/json`$ schema$properties$current_state$example <- - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", "")) + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "") + ) spec }) } @@ -93,4 +105,3 @@ function(req) { plumber::forward() } - diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index d42c5ec..f613b4e 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -1,14 +1,17 @@ #* Initialize a study with Pocock's minimisation randomization #* -#* Set up a new study for randomization defining it's parameters +#* Set up a new study for randomization defining its parameters #* #* #* @param identifier:object Study code, at most 12 characters. #* @param name:object Full study name. -#* @param method:object Function used to compute within-arm variability, must be one of: sd, var, range -#* @param p:object Proportion of randomness (0, 1) in the randomization vs determinism (e.g. 0.85 equals 85% deterministic) +#* @param method:object Function used to compute within-arm variability, +#* must be one of: sd, var, range +#* @param p:object Proportion of randomness (0, 1) in the randomization vs +#* determinism (e.g. 0.85 equals 85% deterministic) #* @param arms:object Arm names (character) with their ratios (integer). -#* @param covariates:object Covariate names (character), allowed levels (character) and covariate weights (double). +#* @param covariates:object Covariate names (character), allowed levels +#* (character) and covariate weights (double). #* #* @tag initialize #* @@ -17,7 +20,7 @@ #* function(identifier, name, method, arms, covariates, p, req, res) { return( - unbiased:::api__create_study_minimization_pocock( + unbiased:::api__minimization_pocock( identifier, name, method, arms, covariates, p, req, res ) ) diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index 66d879e..1c3e7db 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -276,4 +276,4 @@ request(api_url) |> backoff = \(x) 0.3 ) |> req_perform() -print("API started, running tests...") \ No newline at end of file +print("API started, running tests...") diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index a4bf0e3..ca474cb 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -49,33 +49,39 @@ test_that("deleting archivizes a study", { test_that("can't push arm with negative ratio", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_error({ - tbl(conn, "arm") |> - rows_append( - tibble( - study_id = 1, - name = "Exception-throwing arm", - ratio = -1 - ), - copy = TRUE, in_place = TRUE - ) - }, regexp = "violates check constraint") + expect_error( + { + tbl(conn, "arm") |> + rows_append( + tibble( + study_id = 1, + name = "Exception-throwing arm", + ratio = -1 + ), + copy = TRUE, in_place = TRUE + ) + }, + regexp = "violates check constraint" + ) }) test_that("can't push stratum other than factor or numeric", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_error({ - tbl(conn, "stratum") |> - rows_append( - tibble( - study_id = 1, - name = "failing stratum", - value_type = "array" - ), - copy = TRUE, in_place = TRUE - ) - }, regexp = "violates check constraint") + expect_error( + { + tbl(conn, "stratum") |> + rows_append( + tibble( + study_id = 1, + name = "failing stratum", + value_type = "array" + ), + copy = TRUE, in_place = TRUE + ) + }, + regexp = "violates check constraint" + ) }) test_that("can't push stratum level outside of defined levels", { @@ -84,35 +90,44 @@ test_that("can't push stratum level outside of defined levels", { # create a new patient return <- expect_no_error({ - tbl(conn, "patient") |> - rows_append( - tibble(study_id = 1, - arm_id = 1, - used = TRUE), - copy = TRUE, in_place = TRUE, returning = id - ) |> - dbplyr::get_returned_rows() - }) + tbl(conn, "patient") |> + rows_append( + tibble( + study_id = 1, + arm_id = 1, + used = TRUE + ), + copy = TRUE, in_place = TRUE, returning = id + ) |> + dbplyr::get_returned_rows() + }) added_patient_id <- return$id - expect_error({ - tbl(conn, "patient_stratum") |> - rows_append( - tibble(patient_id = added_patient_id, - stratum_id = 1, - fct_value = "Female"), - copy = TRUE, in_place = TRUE - ) - }, regexp = "Factor value not specified as allowed") + expect_error( + { + tbl(conn, "patient_stratum") |> + rows_append( + tibble( + patient_id = added_patient_id, + stratum_id = 1, + fct_value = "Female" + ), + copy = TRUE, in_place = TRUE + ) + }, + regexp = "Factor value not specified as allowed" + ) # add legal value expect_no_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_patient_id, - stratum_id = 1, - fct_value = "F"), + tibble( + patient_id = added_patient_id, + stratum_id = 1, + fct_value = "F" + ), copy = TRUE, in_place = TRUE ) }) @@ -126,9 +141,11 @@ test_that("numerical constraints are enforced", { expect_no_error({ tbl(conn, "stratum") |> rows_append( - tibble(study_id = 1, - name = "age", - value_type = "numeric"), + tibble( + study_id = 1, + name = "age", + value_type = "numeric" + ), copy = TRUE, in_place = TRUE, returning = id ) |> dbplyr::get_returned_rows() @@ -139,43 +156,57 @@ test_that("numerical constraints are enforced", { expect_no_error({ tbl(conn, "numeric_constraint") |> rows_append( - tibble(stratum_id = added_stratum_id, - min_value = 18, - max_value = 64), + tibble( + stratum_id = added_stratum_id, + min_value = 18, + max_value = 64 + ), copy = TRUE, in_place = TRUE ) }) # and you can't add an illegal value - expect_error({ - tbl(conn, "patient_stratum") |> - rows_append( - tibble(patient_id = added_patient_id, - stratum_id = added_stratum_id, - num_value = 16), - copy = TRUE, in_place = TRUE - ) - }, regexp = "New value is lower than minimum") + expect_error( + { + tbl(conn, "patient_stratum") |> + rows_append( + tibble( + patient_id = added_patient_id, + stratum_id = added_stratum_id, + num_value = 16 + ), + copy = TRUE, in_place = TRUE + ) + }, + regexp = "New value is lower than minimum" + ) # you can add valid value expect_no_error({ tbl(conn, "patient_stratum") |> rows_append( - tibble(patient_id = added_patient_id, - stratum_id = added_stratum_id, - num_value = 23), + tibble( + patient_id = added_patient_id, + stratum_id = added_stratum_id, + num_value = 23 + ), copy = TRUE, in_place = TRUE ) }) # but you cannot add two values for one patient one stratum - expect_error({ - tbl(conn, "patient_stratum") |> - rows_append( - tibble(patient_id = added_patient_id, - stratum_id = added_stratum_id, - num_value = 24), - copy = TRUE, in_place = TRUE - ) - }, regexp = "duplicate key value violates unique constraint") + expect_error( + { + tbl(conn, "patient_stratum") |> + rows_append( + tibble( + patient_id = added_patient_id, + stratum_id = added_stratum_id, + num_value = 24 + ), + copy = TRUE, in_place = TRUE + ) + }, + regexp = "duplicate key value violates unique constraint" + ) }) diff --git a/tests/testthat/test-E2E-meta-tag.R b/tests/testthat/test-E2E-meta-tag.R index 80305af..eb8fee2 100644 --- a/tests/testthat/test-E2E-meta-tag.R +++ b/tests/testthat/test-E2E-meta-tag.R @@ -4,6 +4,6 @@ test_that("meta tag endpoint returns the SHA", { req_method("GET") |> req_perform() |> resp_body_json() - + expect_string(response, n.chars = 40, pattern = "^[0-9a-f]{40}$") }) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 9528eaa..c366092 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -10,7 +10,8 @@ test_that("endpoint returns the study id, can randomize 2 patients", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1), + "active" = 1 + ), covariates = list( sex = list( weight = 1, @@ -20,7 +21,8 @@ test_that("endpoint returns the study id, can randomize 2 patients", { weight = 1, levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) - )) + ) + ) ) |> req_perform() response_body <- @@ -34,10 +36,14 @@ test_that("endpoint returns the study id, can randomize 2 patients", { req_url_path("study", response_body$study$id, "patient") |> req_method("POST") |> req_body_json( - data = list(current_state = - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", ""))) + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "") + ) + ) ) |> req_perform() response_patient_body <- @@ -48,26 +54,42 @@ test_that("endpoint returns the study id, can randomize 2 patients", { expect_number(response_patient_body$patient_id, lower = 1) # Endpoint Response Structure Test - checkmate::expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) - checkmate::expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) + checkmate::expect_names( + names(response_patient_body), + identical.to = c("patient_id", "arm_id", "arm_name") + ) + checkmate::expect_list( + response_patient_body, + any.missing = TRUE, + null.ok = FALSE, + len = 3, type = c("numeric", "numeric", "character") + ) # Incorrect Study ID response_study <- - tryCatch({ - request(api_url) |> - req_url_path("study", response_body$study$id + 1, "patient") |> - req_method("POST") |> - req_body_json( - data = list(current_state = - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", ""))) + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id + 1, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "") + ) + ) ) |> - req_perform() - }, error = function(e) e) - - checkmate::expect_set_equal(response_study$status, 400, label = "HTTP status code") - - }) + req_perform() + }, + error = function(e) e + ) + checkmate::expect_set_equal( + response_study$status, 400, + label = "HTTP status code" + ) +}) diff --git a/tests/testthat/test-helpers.R b/tests/testthat/test-helpers.R index 20eb7c6..271e501 100644 --- a/tests/testthat/test-helpers.R +++ b/tests/testthat/test-helpers.R @@ -1,4 +1,3 @@ - versioned_tables <- c( "study", "arm", "stratum", "factor_constraint", "numeric_constraint", "patient", "patient_stratum" @@ -59,4 +58,4 @@ truncate_tables <- function(tables) { ) |> DBI::dbExecute(conn = conn) } ) -} \ No newline at end of file +} diff --git a/tests/testthat/test-randomize-minimisation-pocock.R b/tests/testthat/test-randomize-minimisation-pocock.R index c461fc6..7d98125 100644 --- a/tests/testthat/test-randomize-minimisation-pocock.R +++ b/tests/testthat/test-randomize-minimisation-pocock.R @@ -2,103 +2,165 @@ set.seed(seed = "345345") n_at_the_moment <- 10 arms <- c("control", "active low", "active high") sex <- sample(c("F", "M"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.4, 0.6) + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.4, 0.6) ) diabetes <- sample(c("diabetes", "no diabetes"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.2, 0.8) + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.2, 0.8) ) arm <- sample(arms, - n_at_the_moment, - replace = TRUE, - prob = c(0.4, 0.4, 0.2) + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.4, 0.2) ) |> c("") covar_df <- tibble::tibble(sex, diabetes, arm) test_that("You can call function and it returns arm", { expect_subset( - randomize_minimisation_pocock(arms = arms, current_state = covar_df), choices = arms + randomize_minimisation_pocock(arms = arms, current_state = covar_df), + choices = arms ) }) test_that("Assertions work", { - expect_error(randomize_minimisation_pocock(arms = c(1, 2), current_state = covar_df), - regexp = "Must be of type 'character'") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - method = "nonexistent"), - regexp = "Must be element of set .'range','var','sd'., but is 'nonexistent'") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = "5 patietns OK"), - regexp = "Assertion on 'current_state' failed: Must be a tibble, not character") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df[, 1:2]), - regexp = "Names must include the elements .'arm'.") + expect_error( + randomize_minimisation_pocock( + arms = c(1, 2), current_state = covar_df + ), + regexp = "Must be of type 'character'" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + method = "nonexistent" + ), + regexp = "Must be element of set .'range','var','sd'., but is 'nonexistent'" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, + current_state = "5 patietns OK" + ), + regexp = + "Assertion on 'current_state' failed: Must be a tibble, not character" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, + current_state = covar_df[, 1:2] + ), + regexp = "Names must include the elements .'arm'." + ) # Last subject already randomized - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df[1:3,]), - regexp = "must have at most 0 characters") - expect_error(randomize_minimisation_pocock(arms = c("foo", "bar"), - current_state = covar_df), - regexp = "Must be a subset of .'foo','bar',''.") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - weights = c("sex" = -1, "diabetes" = 2)), - regexp = "Element 1 is not >= 0") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - weights = c("wrong" = 1, "diabetes" = 2)), - regexp = "is missing elements .'sex'.") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - ratio = c("control" = 1.5, - "active low" = 2, - "active high" = 1)), - regexp = "element 1 is not close to an integer") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - ratio = c("control" = 1L, - "active high" = 1L)), - regexp = "Must have length 3, but has length 2") - expect_error(randomize_minimisation_pocock(arms = arms, current_state = covar_df, - p = 12), - regexp = "Assertion on 'p' failed: Element 1 is not <= 1") + expect_error( + randomize_minimisation_pocock(arms = arms, current_state = covar_df[1:3, ]), + regexp = "must have at most 0 characters" + ) + expect_error( + randomize_minimisation_pocock( + arms = c("foo", "bar"), + current_state = covar_df + ), + regexp = "Must be a subset of .'foo','bar',''." + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + weights = c("sex" = -1, "diabetes" = 2) + ), + regexp = "Element 1 is not >= 0" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + weights = c("wrong" = 1, "diabetes" = 2) + ), + regexp = "is missing elements .'sex'." + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + ratio = c( + "control" = 1.5, + "active low" = 2, + "active high" = 1 + ) + ), + regexp = "element 1 is not close to an integer" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + ratio = c( + "control" = 1L, + "active high" = 1L + ) + ), + regexp = "Must have length 3, but has length 2" + ) + expect_error( + randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + p = 12 + ), + regexp = "Assertion on 'p' failed: Element 1 is not <= 1" + ) }) test_that("Function randomizes first patient randomly", { randomized <- sapply(1:100, function(x) { - randomize_minimisation_pocock(arms = arms, - current_state = covar_df[nrow(covar_df), ]) + randomize_minimisation_pocock( + arms = arms, + current_state = covar_df[nrow(covar_df), ] + ) }) - test <- prop.test(x = sum(randomized == "control"), - n = length(randomized), - p = 1/3, - conf.level = 0.95, - correct = FALSE) + test <- prop.test( + x = sum(randomized == "control"), + n = length(randomized), + p = 1 / 3, + conf.level = 0.95, + correct = FALSE + ) expect_gt(test$p.value, 0.05) }) test_that("Function randomizes second patient deterministically", { arms <- c("A", "B") - situation <- tibble::tibble(sex = c("F", "F"), - arm = c("A", "")) + situation <- tibble::tibble( + sex = c("F", "F"), + arm = c("A", "") + ) randomized <- - randomize_minimisation_pocock(arms = arms, - current_state = situation, - p = 1) + randomize_minimisation_pocock( + arms = arms, + current_state = situation, + p = 1 + ) expect_equal(randomized, "B") }) test_that("Setting proportion of randomness works", { arms <- c("A", "B") - situation <- tibble::tibble(sex = c("F", "F"), - arm = c("A", "")) + situation <- tibble::tibble( + sex = c("F", "F"), + arm = c("A", "") + ) randomized <- sapply(1:100, function(x) { - randomize_minimisation_pocock(arms = arms, - current_state = situation, - p = 0.60) + randomize_minimisation_pocock( + arms = arms, + current_state = situation, + p = 0.60 + ) }) # 60% to minimization arm (B) 40% to other arm (in this case A) diff --git a/tests/testthat/test-randomize-simple.R b/tests/testthat/test-randomize-simple.R index 51f2486..c8d3819 100644 --- a/tests/testthat/test-randomize-simple.R +++ b/tests/testthat/test-randomize-simple.R @@ -1,7 +1,9 @@ test_that("returns a single string", { expect_vector( - randomize_simple(c("active", "placebo"), - c("active" = 2L, "placebo" = 1L)), + randomize_simple( + c("active", "placebo"), + c("active" = 2L, "placebo" = 1L) + ), ptype = character(), size = 1 ) @@ -28,8 +30,10 @@ test_that("incorrect parameters raise an exception", { # Incorrect ratio type expect_error(randomize_simple(c("roof", "basement"), c("high", "low"))) # Lengths not matching - expect_error(randomize_simple(c("Paris", "Barcelona"), - c("Paris" = 1L, "Barcelona" = 2L, "Warsaw" = 1L))) + expect_error(randomize_simple( + c("Paris", "Barcelona"), + c("Paris" = 1L, "Barcelona" = 2L, "Warsaw" = 1L) + )) # Missing value expect_error(randomize_simple(c("yen", NA))) # Empty arm name @@ -41,26 +45,32 @@ test_that("incorrect parameters raise an exception", { test_that("proportions are kept (allocation 1:1)", { randomizations <- sapply(1:1000, function(x) randomize_simple(c("armA", "armB"))) - x <- prop.test(x = sum(randomizations == "armA"), - n = length(randomizations), - p = 0.5, - conf.level = 0.95, - correct = FALSE) + x <- prop.test( + x = sum(randomizations == "armA"), + n = length(randomizations), + p = 0.5, + conf.level = 0.95, + correct = FALSE + ) # precision 0.01 expect_gt(x$p.value, 0.01) }) -test_that("proportions are kept (allocation 2:1), even if ratio is in reverse", { - function_result <- sapply(1:1000, function(x) { - randomize_simple(c("armA", "armB"), c("armB" = 1L,"armA" = 2L)) - } +test_that( + "proportions are kept (allocation 2:1), even if ratio is in reverse", + { + function_result <- sapply(1:1000, function(x) { + randomize_simple(c("armA", "armB"), c("armB" = 1L, "armA" = 2L)) + }) + x <- prop.test( + x = sum(function_result == "armA"), + n = length(function_result), + p = 2 / 3, + conf.level = 0.95, + correct = FALSE ) - x <- prop.test(x = sum(function_result == "armA"), - n = length(function_result), - p = 2/3, - conf.level = 0.95, - correct = FALSE) - # precision 0.01 - expect_gt(x$p.value, 0.01) -}) + # precision 0.01 + expect_gt(x$p.value, 0.01) + } +) From d1633271ca2538e7b5f587b6fffba09bf49738e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 11:27:04 +0000 Subject: [PATCH 154/240] branches for linter --- .github/workflows/lint.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index f60d047..5d4cb21 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -2,9 +2,9 @@ # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help on: push: - branches: [main, master] + branches: [main, devel] pull_request: - branches: [main, master] + branches: [main, devel] name: lint From 88b63dcbd705a2e6a89b55e3e510c8fcd06f58a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 11:59:26 +0000 Subject: [PATCH 155/240] fix linter warning in db.R --- R/db.R | 1 + 1 file changed, 1 insertion(+) diff --git a/R/db.R b/R/db.R index d1ece84..8dd1c39 100644 --- a/R/db.R +++ b/R/db.R @@ -126,6 +126,7 @@ create_study <- function( } save_patient <- function(study_id, arm_id) { + db_connection_pool <- get("db_connection_pool") randomized_patient <- DBI::dbGetQuery( db_connection_pool, "INSERT INTO patient (arm_id, study_id) From 8065925989d27dfc7a74646011060af4800f7120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 12:26:25 +0000 Subject: [PATCH 156/240] linter --- R/api_create_study.R | 24 ++++++++++++------------ R/api_randomize.R | 4 +--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/R/api_create_study.R b/R/api_create_study.R index 64e6a65..a6a0157 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -4,14 +4,14 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) if (err != TRUE) { - validation_errors <- append_error( + validation_errors <- unbiased:::append_error( validation_errors, "name", err ) } err <- checkmate::check_character(identifier, min.chars = 1, max.chars = 12) if (err != TRUE) { - validation_errors <- append_error( + validation_errors <- unbiased:::append_error( validation_errors, "identifier", err @@ -20,7 +20,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. err <- checkmate::check_choice(method, choices = c("range", "var", "sd")) if (err != TRUE) { - validation_errors <- append_error( + validation_errors <- unbiased:::append_error( validation_errors, "method", err @@ -36,7 +36,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. names = "unique" ) if (err != TRUE) { - validation_errors <- append_error( + validation_errors <- unbiased:::append_error( validation_errors, "arms", err @@ -53,7 +53,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. ) if (err != TRUE) { validation_errors <- - append_error(validation_errors, "covariates", err) + unbiased:::append_error(validation_errors, "covariates", err) } response <- list() @@ -67,7 +67,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. ) if (err != TRUE) { validation_errors <- - append_error( + unbiased:::append_error( validation_errors, glue::glue("covariates[{c_name}]"), err @@ -79,7 +79,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. ) if (err != TRUE) { validation_errors <- - append_error( + unbiased:::append_error( validation_errors, glue::glue("covariates[{c_name}]"), err @@ -95,7 +95,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. ) if (err != TRUE) { validation_errors <- - append_error( + unbiased:::append_error( validation_errors, glue::glue("covariates[{c_name}][weight]"), err @@ -109,7 +109,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. ) if (err != TRUE) { validation_errors <- - append_error( + unbiased:::append_error( validation_errors, glue::glue("covariates[{c_name}][levels]"), err @@ -122,7 +122,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. err <- checkmate::check_numeric(p, lower = 0, upper = 1, len = 1) if (err != TRUE) { validation_errors <- - append_error( + unbiased:::append_error( validation_errors, "p", err @@ -137,7 +137,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. )) } - similar_studies <- get_similar_studies(name, identifier) + similar_studies <- unbiased:::get_similar_studies(name, identifier) strata <- purrr::imap(covariates, function(covariate, name) { list( @@ -149,7 +149,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. weights <- lapply(covariates, function(covariate) covariate$weight) # Write study to DB ------------------------------------------------------- - r <- create_study( + r <- unbiased:::create_study( name = name, identifier = identifier, method = "minimisation_pocock", diff --git a/R/api_randomize.R b/R/api_randomize.R index 9916df0..faa7a77 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -1,5 +1,3 @@ -utils::globalVariables(".data") - api__randomize_patient <- function(study_id, current_state, req, res) { collection <- checkmate::makeAssertCollection() @@ -77,7 +75,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { unbiased:::save_patient(study_id, arm$arm_id) |> dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = id) |> + dplyr::rename(patient_id = .data$id) |> as.list() } From dda17802bdd48e9d519ce270e6a39b715e2d6835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 12:39:37 +0000 Subject: [PATCH 157/240] try roxygen2 workflow --- .github/workflows/document.yaml | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/document.yaml diff --git a/.github/workflows/document.yaml b/.github/workflows/document.yaml new file mode 100644 index 0000000..8ac7bab --- /dev/null +++ b/.github/workflows/document.yaml @@ -0,0 +1,42 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + paths: ["R/**"] + +name: Document + +jobs: + document: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup R + uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - name: Install dependencies + uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::roxygen2 + needs: roxygen2 + + - name: Document + run: roxygen2::roxygenise() + shell: Rscript {0} + + - name: Commit and push changes + run: | + git config --local user.name "$GITHUB_ACTOR" + git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" + git add man/\* NAMESPACE DESCRIPTION + git commit -m "Update documentation" || echo "No changes to commit" + git pull --ff-only + git push origin From 33670622425a0202130ed4010a1bf470d4ac143e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 12:43:52 +0000 Subject: [PATCH 158/240] add docs for create_db_connection_pool --- R/db.R | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/R/db.R b/R/db.R index 8dd1c39..8bca246 100644 --- a/R/db.R +++ b/R/db.R @@ -1,5 +1,19 @@ #' Defines methods for interacting with the study in the database +#' Create a database connection pool +#' +#' This function creates a connection pool to a PostgreSQL database. It uses +#' environment variables to get the necessary connection parameters. If the +#' connection fails, it will retry up to 5 times with a delay of 2 seconds +#' between each attempt. +#' +#' @return A pool object representing the connection pool to the database. +#' @export +#' +#' @examples +#' \dontrun{ +#' pool <- create_db_connection_pool() +#' } create_db_connection_pool <- purrr::insistently(function() { pool::dbPool( RPostgres::Postgres(), From 263c8e139858fb4a948489b3209f99981ea18182 Mon Sep 17 00:00:00 2001 From: lwalejko Date: Thu, 1 Feb 2024 12:47:56 +0000 Subject: [PATCH 159/240] Update documentation --- DESCRIPTION | 2 +- NAMESPACE | 1 + man/compare_rows.Rd | 15 ++++--- man/create_db_connection_pool.Rd | 23 ++++++++++ man/get_similar_studies.Rd | 11 ----- man/list_studies.Rd | 14 ------ man/randomize_minimisation_pocock.Rd | 67 ++++++++++++++++------------ man/read_study_details.Rd | 19 -------- man/run_unbiased.Rd | 4 +- man/study_exists.Rd | 18 -------- 10 files changed, 74 insertions(+), 100 deletions(-) create mode 100644 man/create_db_connection_pool.Rd delete mode 100644 man/get_similar_studies.Rd delete mode 100644 man/list_studies.Rd delete mode 100644 man/read_study_details.Rd delete mode 100644 man/study_exists.Rd diff --git a/DESCRIPTION b/DESCRIPTION index c2b6c56..e1ea907 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -47,5 +47,5 @@ RdMacros: mathjaxr Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.2.3 +RoxygenNote: 7.3.1 URL: https://ttscience.github.io/unbiased/ diff --git a/NAMESPACE b/NAMESPACE index 3b254dd..18be837 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(create_db_connection_pool) export(randomize_minimisation_pocock) export(randomize_simple) export(run_unbiased) diff --git a/man/compare_rows.Rd b/man/compare_rows.Rd index ed3a414..da314a0 100644 --- a/man/compare_rows.Rd +++ b/man/compare_rows.Rd @@ -4,18 +4,19 @@ \alias{compare_rows} \title{Compare rows of two dataframes} \usage{ -compare_rows(A, B) +compare_rows(all_patients, new_patients) } \arguments{ -\item{A}{data.frame with all patients} +\item{all_patients}{data.frame with all patients} -\item{B}{data.frame with new patient} +\item{new_patients}{data.frame with new patient} } \value{ -data.frame with columns as in A and B, filled with TRUE if there is -match in covariate and FALSE if not +data.frame with columns as in all_patients and new_patients, +filled with TRUE if there is match in covariate and FALSE if not } \description{ -Takes dataframe B (presumably with one row / patient) and compares it to all -rows of A (presumably already randomized patietns) +Takes dataframe all_patients (presumably with one row / patient) and +compares it to all rows of new_patients (presumably already randomized +patients) } diff --git a/man/create_db_connection_pool.Rd b/man/create_db_connection_pool.Rd new file mode 100644 index 0000000..9a76532 --- /dev/null +++ b/man/create_db_connection_pool.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/db.R +\name{create_db_connection_pool} +\alias{create_db_connection_pool} +\title{Defines methods for interacting with the study in the database +Create a database connection pool} +\usage{ +create_db_connection_pool(...) +} +\value{ +A pool object representing the connection pool to the database. +} +\description{ +This function creates a connection pool to a PostgreSQL database. It uses +environment variables to get the necessary connection parameters. If the +connection fails, it will retry up to 5 times with a delay of 2 seconds +between each attempt. +} +\examples{ +\dontrun{ +pool <- create_db_connection_pool() +} +} diff --git a/man/get_similar_studies.Rd b/man/get_similar_studies.Rd deleted file mode 100644 index f56af93..0000000 --- a/man/get_similar_studies.Rd +++ /dev/null @@ -1,11 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/study-repository.R -\name{get_similar_studies} -\alias{get_similar_studies} -\title{Defines methods for interacting with the study in the database} -\usage{ -get_similar_studies(name, identifier) -} -\description{ -Defines methods for interacting with the study in the database -} diff --git a/man/list_studies.Rd b/man/list_studies.Rd deleted file mode 100644 index c11cbb3..0000000 --- a/man/list_studies.Rd +++ /dev/null @@ -1,14 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/study-list.R -\name{list_studies} -\alias{list_studies} -\title{List available studies} -\usage{ -list_studies() -} -\value{ -A tibble with basic study info, including ID. -} -\description{ -Queries the DB for the basic information about existing studies. -} diff --git a/man/randomize_minimisation_pocock.Rd b/man/randomize_minimisation_pocock.Rd index f2e2341..d71f807 100644 --- a/man/randomize_minimisation_pocock.Rd +++ b/man/randomize_minimisation_pocock.Rd @@ -46,15 +46,16 @@ name of the arm assigned to the patient The \code{randomize_dynamic} function implements the dynamic randomization algorithm using the minimization method proposed by Pocock (Pocock and Simon, 1975). It requires defining basic study parameters: the number of arms (K), -number of covariates (C), patient allocation ratios (\(a_{k}\)) (where k = 1,2,…., K), -weights for the covariates (\(w_{i}\)) (where i = 1,2,…., C), and the maximum probability (p) -of assigning a patient to the group with the smallest total unbalance multiplied by -the respective weights (\(G_{k}\)). As the total unbalance for the first patient is the same -regardless of the assigned arm, this patient is randomly allocated to a given -arm. Subsequent patients are randomized based on the calculation of the -unbalance depending on the selected method: "range", "var" (variance), or -"sd" (standard deviation). In the case of two arms, the "range" method is -equivalent to the "sd" method. +number of covariates (C), patient allocation ratios (\(a_{k}\)) +(where k = 1,2,…., K), weights for the covariates (\(w_{i}\)) +(where i = 1,2,…., C), and the maximum probability (p) of assigning a patient +to the group with the smallest total unbalance multiplied by +the respective weights (\(G_{k}\)). As the total unbalance for the first +patient is the same regardless of the assigned arm, this patient is randomly +allocated to a given arm. Subsequent patients are randomized based on the +calculation of the unbalance depending on the selected method: "range", +"var" (variance), or "sd" (standard deviation). In the case of two arms, +the "range" method is equivalent to the "sd" method. } \details{ Initially, the algorithm creates a matrix of results comparing a newly @@ -69,47 +70,57 @@ of three methods (“sd”, “range”, “var”). Based on the number of defined arms, the minimum value of (\(G_{k}\)) (defined as the weighted sum of the level-based imbalance) selects the arm to which the patient will be assigned with a predefined probability (p). The -probability that a patient will be assigned to any other arm will then be equal (1-p)/(K-1) +probability that a patient will be assigned to any other arm will then be +equal (1-p)/(K-1) for each of the remaining arms. } \note{ -This function's implementation is a refactored adaptation of the codebase from the 'Minirand' package. +This function's implementation is a refactored adaptation +of the codebase from the 'Minirand' package. } \examples{ n_at_the_moment <- 10 arms <- c("control", "active low", "active high") sex <- sample(c("F", "M"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.4, 0.6) + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.4, 0.6) ) diabetes <- sample(c("diabetes", "no diabetes"), - n_at_the_moment + 1, - replace = TRUE, - prob = c(0.2, 0.8) + n_at_the_moment + 1, + replace = TRUE, + prob = c(0.2, 0.8) ) arm <- sample(arms, - n_at_the_moment, - replace = TRUE, - prob = c(0.4, 0.4, 0.2) + n_at_the_moment, + replace = TRUE, + prob = c(0.4, 0.4, 0.2) ) |> c("") covar_df <- tibble::tibble(sex, diabetes, arm) covar_df randomize_minimisation_pocock(arms = arms, current_state = covar_df) -randomize_minimisation_pocock(arms = arms, current_state = covar_df, - ratio = c("control" = 1, - "active low" = 2, - "active high" = 2), - weights = c("sex" = 0.5, - "diabetes" = 1)) +randomize_minimisation_pocock( + arms = arms, current_state = covar_df, + ratio = c( + "control" = 1, + "active low" = 2, + "active high" = 2 + ), + weights = c( + "sex" = 0.5, + "diabetes" = 1 + ) +) } \references{ -Pocock, S. J., & Simon, R. (1975). Minimization: A new method of assigning patients to treatment and control groups in clinical trials. +Pocock, S. J., & Simon, R. (1975). Minimization: A new method +of assigning patients to treatment and control groups in clinical trials. -Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. (https://CRAN.R-project.org/package=Minirand) +Minirand Package: Man Jin, Adam Polis, Jonathan Hartzel. +(https://CRAN.R-project.org/package=Minirand) } diff --git a/man/read_study_details.Rd b/man/read_study_details.Rd deleted file mode 100644 index 1659dac..0000000 --- a/man/read_study_details.Rd +++ /dev/null @@ -1,19 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/study-details.R -\name{read_study_details} -\alias{read_study_details} -\title{Read study details} -\usage{ -read_study_details(study_id) -} -\arguments{ -\item{study_id}{\code{integer(1)}\cr -ID of the study.} -} -\value{ -A tibble with study details, containing potentially complex columns, -like \code{arms}. -} -\description{ -Queries the DB for the study parameters, including declared arms and strata. -} diff --git a/man/run_unbiased.Rd b/man/run_unbiased.Rd index b6a1478..a7f87f4 100644 --- a/man/run_unbiased.Rd +++ b/man/run_unbiased.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/run_api.R +% Please edit documentation in R/run-api.R \name{run_unbiased} \alias{run_unbiased} \title{Run API} \usage{ -run_unbiased(host = "0.0.0.0", port = 3838, ...) +run_unbiased() } \arguments{ \item{host}{\code{character(1)}\cr diff --git a/man/study_exists.Rd b/man/study_exists.Rd deleted file mode 100644 index 66196fc..0000000 --- a/man/study_exists.Rd +++ /dev/null @@ -1,18 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/study-list.R -\name{study_exists} -\alias{study_exists} -\title{Validate study existence} -\usage{ -study_exists(study_id) -} -\arguments{ -\item{study_id}{\code{integer(1)}\cr -ID of the study.} -} -\value{ -\code{TRUE} or \code{FALSE}, depending whether given ID exists in the DB. -} -\description{ -Checks the database for the existence of given ID. -} From 888781978f9b75f336ddcee43889a16e8bbae58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 13:25:58 +0000 Subject: [PATCH 160/240] readme --- Dockerfile | 5 +- README.md | 48 +++++++++++++++++++ ...mpose.test.yaml => docker-compose.test.yml | 1 - run_tests_with_coverage.sh | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) rename docker-compose.test.yaml => docker-compose.test.yml (95%) diff --git a/Dockerfile b/Dockerfile index 4afd66f..b12a3b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,10 @@ RUN apt update && apt-get install -y --no-install-recommends \ # sodium libsodium-dev \ # RPostgres - libpq-dev libssl-dev postgresql-client + libpq-dev libssl-dev postgresql-client \ + # R_X11 + libxt-dev + ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE diff --git a/README.md b/README.md index 1dc73f6..be60e09 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,50 @@ # unbiased API for clinical trial randomization + +## Configuration + +The Unbiased API server can be configured using environment variables. The following environment variables need to be set for the server to start: + +- `POSTGRES_DB`: The name of the PostgreSQL database to connect to. +- `POSTGRES_HOST`: The host of the PostgreSQL database. This could be a hostname, such as `localhost` or `database.example.com`, or an IP address. +- `POSTGRES_PORT`: The port on which the PostgreSQL database is listening. Defaults to `5432` if not provided. +- `POSTGRES_USER`: The username for authentication with the PostgreSQL database. +- `POSTGRES_PASSWORD`: The password for authentication with the PostgreSQL database. +- `UNBIASED_HOST`: The host on which the API will run. Defaults to `0.0.0.0` if not provided. +- `UNBIASED_PORT`: The port on which the API will listen. Defaults to `3838` if not provided. + +## Running Tests + +Unbiased provides an extensive collection of tests to ensure correct functionality. + +### Executing Tests from an R Interactive Session + +To execute tests using an interactive R session, run the following commands: + +```R +devtools::load_all() +testthat::test_package("unbiased") +``` + +Ensure that the necessary database connection environment variables are set before running these tests. You can set environment variables using methods such as `Sys.setenv`. + +Running these tests will start the Unbiased API on a random port. + +### Executing Tests from the Command Line + +Use the helper script `run_tests.sh` to execute tests from the command line. Remember to set the database connection environment variables before running the tests. + +### Running Tests with Docker Compose + +Docker Compose can be used to build the Unbiased Docker image and execute all tests. This can be done using the provided `docker-compose.test.yml` file. This method ensures a consistent testing environment and simplifies the setup process. + +```bash +docker compose -f docker-compose.test.yml build +docker compose -f docker-compose.test.yml run tests +``` + +### Executing with Coverage + +For test coverage, use the `covr::report()` method. This will run all tests and provide a coverage report in HTML format. For a simpler code coverage report, use the `covr::package_coverage()` method. + +You can also use the `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. diff --git a/docker-compose.test.yaml b/docker-compose.test.yml similarity index 95% rename from docker-compose.test.yaml rename to docker-compose.test.yml index 92389cc..d567e60 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yml @@ -5,7 +5,6 @@ services: environment: - POSTGRES_PASSWORD=postgres tests: - # image: unbiased build: context: . dockerfile: Dockerfile diff --git a/run_tests_with_coverage.sh b/run_tests_with_coverage.sh index 4a36b50..f2d272d 100644 --- a/run_tests_with_coverage.sh +++ b/run_tests_with_coverage.sh @@ -4,4 +4,4 @@ set -e echo "Running tests" -R --quiet --no-save -e "devtools::load_all(); covr::package_coverage('.')" +R --quiet --no-save -e "devtools::load_all(); covr::package_coverage()" From a9d3de48a2b9338a4034e8640fa7e88e0049b2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 1 Feb 2024 15:43:37 +0000 Subject: [PATCH 161/240] deps and docker update --- .github/workflows/test-coverage.yaml | 20 +- Dockerfile | 13 +- R/api_randomize.R | 10 +- README.md | 13 +- docker-compose.test.yml | 2 +- renv.lock | 423 --------------------- tests/testthat/setup-testing-environment.R | 15 +- 7 files changed, 33 insertions(+), 463 deletions(-) diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index c7866b9..9efc03f 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -40,23 +40,11 @@ jobs: - uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true - - - - name: Instal system dependencies - run: | - sudo apt-get update && \ - sudo apt-get install -y --no-install-recommends \ - libz-dev \ - libsodium-dev \ - libpq-dev libssl-dev postgresql-client \ - libxt-dev - - - uses: r-lib/actions/setup-renv@v2 - # - uses: r-lib/actions/setup-r-dependencies@v2 - # with: - # extra-packages: any::covr - # needs: coverage + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::covr + needs: coverage - name: Install migrate run: | diff --git a/Dockerfile b/Dockerfile index b12a3b3..1407623 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,13 @@ RUN apt update && apt-get install -y --no-install-recommends \ libsodium-dev \ # RPostgres libpq-dev libssl-dev postgresql-client \ - # R_X11 - libxt-dev + curl gnupg2 +# Install database migration tool +RUN curl -L https://packagecloud.io/golang-migrate/migrate/gpgkey | apt-key add - && \ + echo "deb https://packagecloud.io/golang-migrate/migrate/ubuntu/ focal main" > /etc/apt/sources.list.d/migrate.list && \ + apt-get update && \ + apt-get install -y migrate ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE @@ -29,12 +33,11 @@ COPY inst/ ./inst COPY R/ ./R COPY tests/ ./inst/tests -# RUN R CMD INSTALL --no-multiarch . +RUN R CMD INSTALL --no-multiarch . EXPOSE 3838 ARG github_sha ENV GITHUB_SHA=${github_sha} -CMD ["R", "-e", "unbiased::run_unbiased()"] - +CMD ["R", "-e", "unbiased::run_unbiased()"] \ No newline at end of file diff --git a/R/api_randomize.R b/R/api_randomize.R index faa7a77..9cb6e31 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -8,7 +8,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { checkmate::check_subset( x = req$args$study_id, choices = dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(.data$id) |> + dplyr::select("id") |> dplyr::pull() ), .var.name = "Study ID", @@ -19,7 +19,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { method_randomization <- dplyr::tbl(db_connection_pool, "study") |> dplyr::filter(.data$id == study_id) |> - dplyr::select(.data$method) |> + dplyr::select("method") |> dplyr::pull() checkmate::assert( @@ -70,12 +70,12 @@ api__randomize_patient <- function(study_id, current_state, req, res) { arm <- dplyr::tbl(db_connection_pool, "arm") |> dplyr::filter(study_id == !!study_id & .data$name == arm_name) |> - dplyr::select(arm_id = .data$id, .data$name, .data$ratio) |> + dplyr::select(arm_id = "id", "name", "ratio") |> dplyr::collect() unbiased:::save_patient(study_id, arm$arm_id) |> dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = .data$id) |> + dplyr::rename(patient_id = "id") |> as.list() } @@ -105,7 +105,7 @@ parse_pocock_parameters <- ratio_arms <- dplyr::tbl(db_connetion_pool, "arm") |> dplyr::filter(study_id == !!study_id) |> - dplyr::select(.data$name, .data$ratio) |> + dplyr::select("name", "ratio") |> dplyr::collect() params <- list( diff --git a/README.md b/README.md index be60e09..5eadab3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ devtools::load_all() testthat::test_package("unbiased") ``` +Make sure that `devtools` package is installed in your environment. + Ensure that the necessary database connection environment variables are set before running these tests. You can set environment variables using methods such as `Sys.setenv`. Running these tests will start the Unbiased API on a random port. @@ -43,8 +45,13 @@ docker compose -f docker-compose.test.yml build docker compose -f docker-compose.test.yml run tests ``` -### Executing with Coverage +### Code Coverage + +Unbiased supports code coverage analysis through the `covr` package. This allows you to measure the effectiveness of your tests by showing which parts of your R code in the `R` directory are actually being tested. + +To calculate code coverage, you will need to install the `covr` package. Once installed, you can use the following methods: -For test coverage, use the `covr::report()` method. This will run all tests and provide a coverage report in HTML format. For a simpler code coverage report, use the `covr::package_coverage()` method. +- `covr::report()`: This method runs all tests and generates a detailed coverage report in HTML format. +- `covr::package_coverage()`: This method provides a simpler, text-based code coverage report. -You can also use the `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. +Alternatively, you can use the provided `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d567e60..a44b094 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -16,4 +16,4 @@ services: - POSTGRES_PORT=5432 - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - command: R -e "covr::package_coverage()" + command: R -e "testthat::test_package('unbiased')" diff --git a/renv.lock b/renv.lock index df53110..f303d39 100644 --- a/renv.lock +++ b/renv.lock @@ -20,119 +20,6 @@ ], "Hash": "3e0051431dff9acfe66c23765e55c556" }, - "DT": { - "Package": "DT", - "Version": "0.31", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "crosstalk", - "htmltools", - "htmlwidgets", - "httpuv", - "jquerylib", - "jsonlite", - "magrittr", - "promises" - ], - "Hash": "77b5189f5272ae2b21e3ac2175ad107c" - }, - "KernSmooth": { - "Package": "KernSmooth", - "Version": "2.23-20", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "stats" - ], - "Hash": "8dcfa99b14c296bc9f1fd64d52fd3ce7" - }, - "MASS": { - "Package": "MASS", - "Version": "7.3-58.2", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "methods", - "stats", - "utils" - ], - "Hash": "e02d1a0f6122fd3e634b25b433704344" - }, - "Matrix": { - "Package": "Matrix", - "Version": "1.5-3", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "grid", - "lattice", - "methods", - "stats", - "utils" - ], - "Hash": "4006dffe49958d2dd591c17e61e60591" - }, - "R.cache": { - "Package": "R.cache", - "Version": "0.16.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R.methodsS3", - "R.oo", - "R.utils", - "digest", - "utils" - ], - "Hash": "fe539ca3f8efb7410c3ae2cf5fe6c0f8" - }, - "R.methodsS3": { - "Package": "R.methodsS3", - "Version": "1.8.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "utils" - ], - "Hash": "278c286fd6e9e75d0c2e8f731ea445c8" - }, - "R.oo": { - "Package": "R.oo", - "Version": "1.26.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R.methodsS3", - "methods", - "utils" - ], - "Hash": "4fed809e53ddb5407b3da3d0f572e591" - }, - "R.utils": { - "Package": "R.utils", - "Version": "2.12.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R.methodsS3", - "R.oo", - "methods", - "tools", - "utils" - ], - "Hash": "3dc2829b790254bfba21e60965787651" - }, "R6": { "Package": "R6", "Version": "2.5.1", @@ -239,18 +126,6 @@ ], "Hash": "40415719b5a479b87949f3aa0aee737c" }, - "boot": { - "Package": "boot", - "Version": "1.3-28.1", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "stats" - ], - "Hash": "9a052fbcbe97a98ceb18dbfd30ebd96e" - }, "brew": { "Package": "brew", "Version": "1.0-10", @@ -322,19 +197,6 @@ ], "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" }, - "class": { - "Package": "class", - "Version": "7.3-21", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "MASS", - "R", - "stats", - "utils" - ], - "Hash": "8ae0d4328e2eb3a582dfd5391a3663b7" - }, "cli": { "Package": "cli", "Version": "3.6.1", @@ -356,37 +218,6 @@ ], "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" }, - "cluster": { - "Package": "cluster", - "Version": "2.1.4", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "stats", - "utils" - ], - "Hash": "5edbbabab6ce0bf7900a74fd4358628e" - }, - "codetools": { - "Package": "codetools", - "Version": "0.2-19", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R" - ], - "Hash": "c089a619a7fae175d149d89164f8c7d8" - }, - "collections": { - "Package": "collections", - "Version": "0.3.7", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "90a0eda114ab0bef170ddbf5ef0cd93f" - }, "commonmark": { "Package": "commonmark", "Version": "1.9.0", @@ -394,26 +225,6 @@ "Repository": "RSPM", "Hash": "d691c61bff84bd63c383874d2d0c3307" }, - "covr": { - "Package": "covr", - "Version": "3.6.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "crayon", - "digest", - "httr", - "jsonlite", - "methods", - "rex", - "stats", - "utils", - "withr", - "yaml" - ], - "Hash": "0cbf0435830e767ba9b292b313592362" - }, "cpp11": { "Package": "cpp11", "Version": "0.4.6", @@ -450,19 +261,6 @@ ], "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" }, - "crosstalk": { - "Package": "crosstalk", - "Version": "1.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R6", - "htmltools", - "jsonlite", - "lazyeval" - ], - "Hash": "ab12c7b080a57475248a30f4db6298c0" - }, "curl": { "Package": "curl", "Version": "5.1.0", @@ -473,20 +271,6 @@ ], "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" }, - "cyclocomp": { - "Package": "cyclocomp", - "Version": "1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "callr", - "crayon", - "desc", - "remotes", - "withr" - ], - "Hash": "cdc4a473222b0112d4df0bcfbed12d44" - }, "dbplyr": { "Package": "dbplyr", "Version": "2.4.0", @@ -685,19 +469,6 @@ ], "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" }, - "foreign": { - "Package": "foreign", - "Version": "0.8-84", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "methods", - "stats", - "utils" - ], - "Hash": "467ec0ca895d4e61a22cfbac9bccddf8" - }, "fs": { "Package": "fs", "Version": "1.6.3", @@ -923,30 +694,6 @@ ], "Hash": "1ec462871063897135c1bcbe0fc8f07d" }, - "languageserver": { - "Package": "languageserver", - "Version": "0.3.16", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "callr", - "collections", - "fs", - "jsonlite", - "lintr", - "parallel", - "roxygen2", - "stringi", - "styler", - "tools", - "utils", - "xml2", - "xmlparsedata" - ], - "Hash": "f8901f44aedb6d7e7d03b5533986bd97" - }, "later": { "Package": "later", "Version": "1.3.1", @@ -958,31 +705,6 @@ ], "Hash": "40401c9cf2bc2259dfe83311c9384710" }, - "lattice": { - "Package": "lattice", - "Version": "0.20-45", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "grid", - "stats", - "utils" - ], - "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" - }, - "lazyeval": { - "Package": "lazyeval", - "Version": "0.2.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "d908914ae53b04d4c0c0fd72ecc35370" - }, "lifecycle": { "Package": "lifecycle", "Version": "1.0.3", @@ -996,27 +718,6 @@ ], "Hash": "001cecbeac1cff9301bdc3775ee46a86" }, - "lintr": { - "Package": "lintr", - "Version": "3.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "backports", - "codetools", - "cyclocomp", - "digest", - "glue", - "knitr", - "rex", - "stats", - "utils", - "xml2", - "xmlparsedata" - ], - "Hash": "93e9379f4be8c0bf1862dfc7f720193e" - }, "logger": { "Package": "logger", "Version": "0.2.2", @@ -1068,23 +769,6 @@ ], "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" }, - "mgcv": { - "Package": "mgcv", - "Version": "1.8-42", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "Matrix", - "R", - "graphics", - "methods", - "nlme", - "splines", - "stats", - "utils" - ], - "Hash": "3460beba7ccc8946249ba35327ba902a" - }, "mime": { "Package": "mime", "Version": "0.12", @@ -1107,32 +791,6 @@ ], "Hash": "fec5f52652d60615fdb3957b3d74324a" }, - "nlme": { - "Package": "nlme", - "Version": "3.1-162", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "lattice", - "stats", - "utils" - ], - "Hash": "0984ce8da8da9ead8643c5cbbb60f83e" - }, - "nnet": { - "Package": "nnet", - "Version": "7.3-18", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "stats", - "utils" - ], - "Hash": "170da2130d5332bea7d6ede01875ba1d" - }, "openssl": { "Package": "openssl", "Version": "2.1.1", @@ -1450,16 +1108,6 @@ ], "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" }, - "rex": { - "Package": "rex", - "Version": "1.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "lazyeval" - ], - "Hash": "ae34cd56890607370665bee5bd17812f" - }, "rlang": { "Package": "rlang", "Version": "1.1.3", @@ -1521,19 +1169,6 @@ ], "Hash": "c25fe7b2d8cba73d1b63c947bf7afdb9" }, - "rpart": { - "Package": "rpart", - "Version": "4.1.19", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "stats" - ], - "Hash": "b3c892a81783376cc2204af0f5805a80" - }, "rprojroot": { "Package": "rprojroot", "Version": "2.0.3", @@ -1641,19 +1276,6 @@ ], "Hash": "5f5a7629f956619d519205ec475fe647" }, - "spatial": { - "Package": "spatial", - "Version": "7.3-16", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "stats", - "utils" - ], - "Hash": "1f7d528460600aeab2c2fcc5b3f5bab6" - }, "stringi": { "Package": "stringi", "Version": "1.7.12", @@ -1684,41 +1306,6 @@ ], "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" }, - "styler": { - "Package": "styler", - "Version": "1.10.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R.cache", - "cli", - "magrittr", - "purrr", - "rlang", - "rprojroot", - "tools", - "vctrs", - "withr" - ], - "Hash": "d61238fd44fc63c8adf4565efe8eb682" - }, - "survival": { - "Package": "survival", - "Version": "3.5-3", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "Matrix", - "R", - "graphics", - "methods", - "splines", - "stats", - "utils" - ], - "Hash": "aea2b8787db7088ba50ba389848569ee" - }, "swagger": { "Package": "swagger", "Version": "3.33.1", @@ -2004,16 +1591,6 @@ ], "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" }, - "xmlparsedata": { - "Package": "xmlparsedata", - "Version": "1.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "45e4bf3c46476896e821fc0a408fb4fc" - }, "xopen": { "Package": "xopen", "Version": "1.0.0", diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index 1c3e7db..c09cd7b 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -210,6 +210,9 @@ github_sha <- Sys.getenv( "GITHUB_SHA", "6e21b5b689cc9737ba0d24147ed4b634c7146a28" ) +if (github_sha == "") { + github_sha <- "6e21b5b689cc9737ba0d24147ed4b634c7146a28" +} withr::local_envvar( list( GITHUB_SHA = github_sha @@ -236,19 +239,11 @@ withr::defer( { print("Server STDOUT:") while (length(lines <- plumber_process$read_output_lines())) { - print( - lines |> - paste(collapse = "\n") |> - stringr::str_squish() - ) + writeLines(lines) } print("Server STDERR:") while (length(lines <- plumber_process$read_error_lines())) { - message( - lines |> - paste(collapse = "\n") |> - stringr::str_squish() - ) + writeLines(lines) } print("Sending SIGINT to plumber process") plumber_process$interrupt() From b8f8b674c07fb933581b57a2a90a94a15fb8f703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 2 Feb 2024 09:21:34 +0000 Subject: [PATCH 162/240] move custom range back to the randomize_minimisation_pocock method --- R/randomize-minimisation-pocock.R | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/R/randomize-minimisation-pocock.R b/R/randomize-minimisation-pocock.R index b2a23c6..1f01816 100644 --- a/R/randomize-minimisation-pocock.R +++ b/R/randomize-minimisation-pocock.R @@ -137,6 +137,11 @@ randomize_minimisation_pocock <- unique = TRUE ) + # Define a custom range function + custom_range <- function(x) { + max(x, na.rm = TRUE) - min(x, na.rm = TRUE) + } + supported_methods <- list( "range" = custom_range, "var" = var, @@ -286,9 +291,3 @@ randomize_minimisation_pocock <- ) ) } - - -# Define a custom range function -custom_range <- function(x) { - max(x, na.rm = TRUE) - min(x, na.rm = TRUE) -} From 9a8322365286574b3faaa47bb4835f0de5bca491 Mon Sep 17 00:00:00 2001 From: jagoda Date: Mon, 5 Feb 2024 12:07:56 +0000 Subject: [PATCH 163/240] optimized code + Rds data from 1000 simulations --- vignettes/.gitignore | 2 +- vignettes/1000_sim_data.Rds | Bin 0 -> 343722 bytes vignettes/helpers/functions.R | 127 +++++ vignettes/helpers/run_parallel.R | 76 +++ .../minimization_randomization_comparison.Rmd | 433 ++++++++---------- 5 files changed, 386 insertions(+), 252 deletions(-) create mode 100644 vignettes/1000_sim_data.Rds create mode 100644 vignettes/helpers/functions.R create mode 100644 vignettes/helpers/run_parallel.R diff --git a/vignettes/.gitignore b/vignettes/.gitignore index 097b241..3432c3f 100644 --- a/vignettes/.gitignore +++ b/vignettes/.gitignore @@ -1,2 +1,2 @@ *.html -*.R + diff --git a/vignettes/1000_sim_data.Rds b/vignettes/1000_sim_data.Rds new file mode 100644 index 0000000000000000000000000000000000000000..2dcc0a590a960909d494e411af5b84cc3bc070fa GIT binary patch literal 343722 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

0fH_-QSVmhM39iqgN3mucBvXLYVp64^m# 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 literal 0 HcmV?d00001 diff --git a/vignettes/helpers/functions.R b/vignettes/helpers/functions.R new file mode 100644 index 0000000..dc9f902 --- /dev/null +++ b/vignettes/helpers/functions.R @@ -0,0 +1,127 @@ +# functions + +simulate_data_monte_carlo <- + function(def, n) { + + data <- + genData(n, def)|> + mutate( + sex = as.character(sex), + age = as.character(age), + diabetes_type = as.character(diabetes_type), + hba1c = as.character(hba1c), + tpo2 = as.character(tpo2), + wound_size = as.character(wound_size) + ) |> + tibble::as_tibble() |> + tibble::add_column(arm = "") + + return(data) + } + +minimize_results <- + function(current_data, arms, weights) { + + for (n in 1:nrow(current_data)) + { + + current_state <- current_data[1:n, 2:ncol(current_data)] + + current_data$arm[n] <- + randomize_minimisation_pocock( + arms = arms, + current_state = current_state, + weights = weights + ) + + } + + return(current_data$arm) + } + +simple_results <- + function(current_data, arms, ratio) { + + for (n in 1:nrow(current_data)) + { + current_data$arm[n] <- + randomize_simple(arms, ratio) + + } + + return(current_data$arm) + } + +# Function to generate a randomisation list +block_rand <- + function(N, block, n_groups, strata, arms = LETTERS[1:n_groups]) { + strata_grid = expand.grid(strata) + + strata_n = nrow(strata_grid) + + ratio = rep(1, n_groups) + + genSeq_list <- lapply(seq_len(strata_n), function(i) { + rand <- rpbrPar( + N = N, + rb = block, + K = n_groups, + ratio = ratio, + groups = arms, + filledBlock = FALSE + ) + getRandList(genSeq(rand))[1,] + }) + df_list = tibble::tibble() + for (i in seq_len(strata_n)) { + local_df <- strata_grid |> + dplyr::slice(i) |> + dplyr::mutate(count = N) |> + tidyr::uncount(count) |> + tibble::add_column(rand_arm = genSeq_list[[i]]) + df_list <- rbind(local_df, df_list) + } + return(df_list) + } + +# Generate a research arm for patients in each iteration +block_results <- function(current_data) { + + simulation_result <- + block_rand( + N = n, + block = c(3, 6, 9), + n_groups = 3, + strata = + list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ), + arms = c("armA", "armB", "armC") + ) + + for (n in 1:nrow(current_data)) + { + + #"-1" is for "arm" column + current_state <- current_data[n, 2:(ncol(current_data)-1)] + + matching_rows <- which(apply(simulation_result[,-ncol(simulation_result)], 1, function(row) all(row == current_state))) + + if (length(matching_rows) > 0) { + + current_data$arm[n] <- + simulation_result[matching_rows[1],"rand_arm"] + + # Delete row from randomization list + simulation_result <- simulation_result[-matching_rows[1], , drop = FALSE] + } + } + + return(current_data$arm) + +} diff --git a/vignettes/helpers/run_parallel.R b/vignettes/helpers/run_parallel.R new file mode 100644 index 0000000..2fda449 --- /dev/null +++ b/vignettes/helpers/run_parallel.R @@ -0,0 +1,76 @@ +# set cluster +library(parallel) +# Start parallel cluster +cl <- makeForkCluster(no_of_cores) + +results <- + parLapply(cl, 1:no_of_iterations, function(i) { + # lapply(1:no_of_iterations, funĆction(i) { + set.seed(i) + + data <- simulate_data_monte_carlo(def, n) + + # eqal weights - 1/6 + minimize_equal_weights <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC") + ) + + # double weights where the covariant is of high clinical significance + minimize_unequal_weights <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 2, + "tpo2" = 2, + "age" = 1, + "wound_size" = 2 + ) + ) + + # triple weights where the covariant is of high clinical significance + minimize_unequal_weights_triple <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 3, + "tpo2" = 3, + "age" = 1, + "wound_size" = 3 + ) + ) + + simple_data <- + simple_results( + current_data = data, + arms = c("armA", "armB", "armC"), + ratio = c("armB" = 1L,"armA" = 1L, "armC" = 1L) + ) + + block_data <- + block_results(current_data = data) + + data <- + data %>% + select(-arm) %>% + mutate( + minimize_equal_weights_arms = minimize_equal_weights, + minimize_unequal_weights_arms = minimize_unequal_weights, + minimize_unequal_weights_triple_arms = minimize_unequal_weights_triple, + simple_data_arms = simple_data, + block_data_arms = block_data + ) %>% + tibble::add_column(simnr = i, .before = 1) + + return(data) + +}) + +stopCluster(cl) diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/minimization_randomization_comparison.Rmd index abb0b0b..a5f5b60 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -75,13 +75,10 @@ where: In this simulation, we are using a real use case - the planned FootCell study - non-commercial clinical research in the area of civilisation diseases - to guide our data generation process. For the FootCell study, it is anticipated that a total of 105 patients will be randomized into the trial. These patients will be equally divided among three research groups - Group A, Group B, and Group C - with each group comprising 35 patients. -The number of iterations, indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters. ```{r, define-parameters} # defined number of patients n <- 105 -# defined number of iterations -no_of_iterations <- 20 ``` ## Defining parameters for Monte-Carlo simulation @@ -120,14 +117,14 @@ simulate_proportions_trunc <- a = lower, b = upper, mean = mean, - sd = sd) <= threshold - - sum(simulate_data == TRUE)/n + sd = sd + ) <= threshold + + sum(simulate_data == TRUE) / n } ``` ```{r, parameters-result-table, tab.cap = "Summary of literature verification about strata selected parameters (Mrozikiewicz-Rakowska et. al., 2023)"} - set.seed(123) data.frame( @@ -136,12 +133,14 @@ data.frame( age = simulate_proportions_trunc(1000, 0, 100, 59.2, 9.7, 55), wound_size = simulate_proportions_trunc(1000, 0, 20, 2.7, 2.28, 2) ) |> - rename('wound size' = wound_size) |> - pivot_longer(cols = everything(), - names_to = "parametr", - values_to = "proportions") |> -mutate('first catogory of strata' = c('<=9', '<=50', '<=55', '<=2')) |> -gt() + rename("wound size" = wound_size) |> + pivot_longer( + cols = everything(), + names_to = "parametr", + values_to = "proportions" + ) |> + mutate("first catogory of strata" = c("<=9", "<=50", "<=55", "<=2")) |> + gt() ``` ## Generate data using Monte-Carlo simulations @@ -160,30 +159,14 @@ def <- simstudy::defData(def, varname = "hba1c", formula = "0.888", dist = "bina # <= 50 - 0.354 def <- simstudy::defData(def, varname = "tpo2", formula = "0.354", dist = "binary") # correlation with diabetes type -def = simstudy::defData(def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary") +def <- simstudy::defData(def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary") # <= 2 - 0.302 -def = simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") -``` - -```{r, monte-carlo-function} -simulate_data_monte_carlo <- - function(def, n, iterations) { - data <- tibble::tibble() - - for (i in 1:iterations) { - generated_data <- genData(n, def) |> - dplyr::mutate(simnr = i) - data <- rbind(data, generated_data) |> - select(simnr, everything()) - - } - return(data) - } +def <- simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") ``` ```{r, create-data} data <- - simulate_data_monte_carlo(def, n, iterations = no_of_iterations)|> + genData(n, def) |> mutate( sex = as.character(sex), age = as.character(age), @@ -191,7 +174,8 @@ data <- hba1c = as.character(hba1c), tpo2 = as.character(tpo2), wound_size = as.character(wound_size) - ) |> as_tibble() + ) |> + as_tibble() ``` ```{r, data-generate} @@ -215,36 +199,27 @@ To generate appropriate research arms for each simulation, a function called `mi - **minimize_unequal_weights** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 2. The remaining covariates have been assigned a weight of 1. -- **minimize_unequal_weights_2** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. +- **minimize_unequal_weights_triple** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. The tables present information about allocations for the first 5 patients in the initial iteration of the simulation. ```{r, minimize-results} # drawing an arm for each patient minimize_results <- - function(data, arms, weights) { - for (i in unique(data$simnr)) + function(current_data, arms, weights) { + for (n in 1:nrow(current_data)) { - for (j in 1:n) - { - current_data <- data[data$simnr == i, ] - - current_state <- current_data[1:j, 3:ncol(current_data)] - - start <- 1 + (i - 1) * n - end <- i * n - - data[start:end,]$arm[j] <- - randomize_minimisation_pocock( - arms = arms, - current_state = current_state, - weights = weights - ) - - - } + current_state <- current_data[1:n, 2:ncol(current_data)] + + current_data$arm[n] <- + randomize_minimisation_pocock( + arms = arms, + current_state = current_state, + weights = weights + ) } - return(data) + + return(current_data) } ``` @@ -253,7 +228,7 @@ set.seed(123) # eqal weights - 1/6 minimize_equal_weights <- minimize_results( - data = data, + current_data = data, arms = c("armA", "armB", "armC") ) @@ -266,7 +241,7 @@ set.seed(123) # double weights where the covariant is of high clinical significance minimize_unequal_weights <- minimize_results( - data = data, + current_data = data, arms = c("armA", "armB", "armC"), weights = c( "sex" = 1, @@ -285,9 +260,9 @@ minimize_unequal_weights[1:5, 2:ncol(minimize_unequal_weights)] |> ```{r, minimize-unequal-2} set.seed(123) # triple weights where the covariant is of high clinical significance -minimize_unequal_weights_2 <- +minimize_unequal_weights_triple <- minimize_results( - data = data, + current_data = data, arms = c("armA", "armB", "armC"), weights = c( "sex" = 1, @@ -299,7 +274,7 @@ minimize_unequal_weights_2 <- ) ) -minimize_unequal_weights_2[1:5, 2:ncol(minimize_unequal_weights_2)] |> +minimize_unequal_weights_triple[1:5, 2:ncol(minimize_unequal_weights_triple)] |> gt() ``` @@ -312,21 +287,20 @@ The function relies on the use of the `tbl_summary` function available in the `g statistics_table <- function(data) { data |> - filter(simnr == 1) |> mutate( - sex = ifelse(sex == '1', "men", "women"), + sex = ifelse(sex == "1", "men", "women"), diabetes_type = ifelse(diabetes_type == "1", "type1", "type2"), - hba1c = ifelse(hba1c == '1', "<=9", "(9,11>"), - tpo2 = ifelse(tpo2 == '1', "<=50", ">50"), - age = ifelse(age == '1', "<=55", ">50"), - wound_size = ifelse(wound_size == '1', "<=2", ">2") + hba1c = ifelse(hba1c == "1", "<=9", "(9,11>"), + tpo2 = ifelse(tpo2 == "1", "<=50", ">50"), + age = ifelse(age == "1", "<=55", ">50"), + wound_size = ifelse(wound_size == "1", "<=2", ">2") ) |> tbl_summary( include = c(sex, diabetes_type, hba1c, tpo2, age, wound_size), by = arm ) |> modify_header(label = "") |> - modify_header(all_stat_cols() ~ "**{level}**, N = {n}") |> + modify_header(all_stat_cols() ~ "**{level}**, N = {n}") |> bold_labels() |> add_p() } @@ -337,19 +311,19 @@ The table presents a statistical summary of results for the first iteration for: - **Minimization with all weights equal to 1/6**. ```{r, chi2-1, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} -statistics_table(minimize_equal_weights) +statistics_table(minimize_equal_weights) ``` - **Minimization with weights 2:1**. ```{r, chi2-2, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} -statistics_table(minimize_unequal_weights) +statistics_table(minimize_unequal_weights) ``` - **Minimization with weights 3:1**. ```{r, chi2-3, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} -statistics_table(minimize_unequal_weights_2) +statistics_table(minimize_unequal_weights_triple) ``` ## Simple randomization @@ -361,23 +335,14 @@ Since this is simple randomization, it does not take into account the initial co ```{r, simple-result} # simple randomization simple_results <- - function(data, arms, ratio) { - for (i in unique(data$simnr)) + function(current_data, arms, ratio) { + for (n in 1:nrow(current_data)) { - for (j in 1:n) - { - current_data <- data[data$simnr == i, ] - - start <- 1 + (i - 1) * n - end <- i * n - - data[start:end,]$arm[j] <- - randomize_simple(arms, ratio) - - - } + current_data$arm[n] <- + randomize_simple(arms, ratio) } - return(data) + + return(current_data) } ``` @@ -385,7 +350,11 @@ simple_results <- set.seed(123) simple_data <- - simple_results(data, c("armA", "armB", "armC"), c("armB" = 1L,"armA" = 1L, "armC" = 1L)) + simple_results( + current_data = data, + arms = c("armA", "armB", "armC"), + ratio = c("armB" = 1L, "armA" = 1L, "armC" = 1L) + ) simple_data[1:5, 2:ncol(simple_data)] |> gt() @@ -408,27 +377,25 @@ The tables show the assignment of patients to groups using block randomisation f ```{r, block-rand} # Function to generate a randomisation list block_rand <- - function(N, block, n_groups, strata) { - strata_grid = expand.grid(strata) - - strata_n = nrow(strata_grid) - - ratio = rep(1, n_groups) - - arm_names = LETTERS[1:n_groups] - + function(N, block, n_groups, strata, arms = LETTERS[1:n_groups]) { + strata_grid <- expand.grid(strata) + + strata_n <- nrow(strata_grid) + + ratio <- rep(1, n_groups) + genSeq_list <- lapply(seq_len(strata_n), function(i) { rand <- rpbrPar( N = N, rb = block, K = n_groups, ratio = ratio, - groups = arm_names, + groups = arms, filledBlock = FALSE ) - getRandList(genSeq(rand))[1,] + getRandList(genSeq(rand))[1, ] }) - df_list = tibble::tibble() + df_list <- tibble::tibble() for (i in seq_len(strata_n)) { local_df <- strata_grid |> dplyr::slice(i) |> @@ -443,64 +410,41 @@ block_rand <- ```{r, block-results} # Generate a research arm for patients in each iteration -block_results <- function(data) { - rand_data <- tibble::tibble() - - for (i in unique(data$simnr)) { - simulation_result <- - block_rand( - N = n, - block = c(3, 6, 9), - n_groups = 3, - strata = - list( - sex = c("0", "1"), - diabetes_type = c("0", "1"), - hba1c = c("0", "1"), - tpo2 = c("0", "1"), - age = c("0", "1"), - wound_size = c("0", "1") - ) - ) |> dplyr::mutate(simnr = i) |> - select(simnr, everything()) - - rand_data <- dplyr::bind_rows(rand_data, simulation_result) - } - - datarand <- rand_data - - for (k in unique(data$simnr)) { - datax <- data[data$simnr == k, ] - - datay <- datarand[datarand$simnr == k, ] - - for (i in data$id) { - matching_rows <- - which( - datay[2] == datax[3][datax[2] == i] & - datay[3] == datax[4][datax[2] == i] & - datay[4] == datax[5][datax[2] == i] & - datay[5] == datax[6][datax[2] == i] & - datay[6] == datax[7][datax[2] == i] & - datay[7] == datax[8][datax[2] == i] - ) - - if (length(matching_rows) > 0) { - id <- i - arm <- datay$rand_arm[matching_rows[1]] - - start <- 1 + (k - 1) * n - end <- k * n - - data[start:end,]$arm[i] <- arm - - # Delate row with randomization list - datay <- datay[-matching_rows[1], , drop = FALSE] - } +block_results <- function(current_data) { + simulation_result <- + block_rand( + N = n, + block = c(3, 6, 9), + n_groups = 3, + strata = + list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ), + arms = c("armA", "armB", "armC") + ) + + for (n in 1:nrow(current_data)) + { + # "-1" is for "arm" column + current_state <- current_data[n, 2:(ncol(current_data) - 1)] + + matching_rows <- which(apply(simulation_result[, -ncol(simulation_result)], 1, function(row) all(row == current_state))) + + if (length(matching_rows) > 0) { + current_data$arm[n] <- + simulation_result[matching_rows[1], "rand_arm"] + + # Delete row from randomization list + simulation_result <- simulation_result[-matching_rows[1], , drop = FALSE] } } - - return(data) + + return(current_data) } ``` @@ -520,6 +464,24 @@ statistics_table(block_data) ## Check balance using smd test +## Generate 1000 simulations + +The number of iterations indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters. + +```{r, simulations} +# define number of iterations +# no_of_iterations <- 1000 +# define number of cores +# no_of_cores <- 20 +# perform simutations (run carefully!) +# source("~/unbiased/vignettes/helpers/run_parallel.R") + +# read data from file +sim_data <- readRDS("~/unbiased/vignettes/1000_sim_data.Rds") +``` + +We have performed 1000 iterations of data generation with parameters defined above (...) + In order to select the test and define the precision at a specified level, above which we assume no imbalance, a literature analysis was conducted based on publications such as @lee2021estimating, @austin2009balance, @doah2021impact, @brown2020novel, @nguyen2017double, @sanchez2003effect, @lee2022propensity, @berger2021roadmap. To assess the balance for covariates between the research groups A, B, C, the Standardized Mean Difference (SMD) test was employed, which compares two groups. Since there are three groups in the example, the SMD test is computed for each pair of comparisons: A vs B, A vs C, and B vs C. The average SMD test for a given covariate is then calculated based on these comparisons. @@ -533,86 +495,61 @@ A function called `smd_covariants_data` was written to generate frames that prod The results for each randomization method were stored in the `cov_balance_data`. ```{r, define-strata-vars} -# definied covariants, and strata -vars = c("sex", "age", "diabetes_type", "wound_size", "tpo2", "hba1c") -strata = "arm" +# definied covariants +vars <- c("sex", "age", "diabetes_type", "wound_size", "tpo2", "hba1c") ``` ```{r, smd-covariants-data} smd_covariants_data <- function(data, vars, strata) { - x <- - rep(unique(data$simnr), times = rep(1, times = length(unique(data$simnr))) * length(vars)) - result_table <- - data.frame(simnr = x, covariants = rep(vars, length(unique(data$simnr)))) |> - tibble::add_column(results = "") - - for (i in 1:length(unique(data$simnr))) { - current_data <- data[data$simnr == i,] - - # check SMD for any covariants - tab <- - CreateTableOne(vars = vars, - data = current_data, - strata = strata) - results_smd <- print(tab, smd = TRUE, TEST = FALSE) |> - as.data.frame() - results_smd <- - results_smd[2:nrow(results_smd),] |> - mutate(SMD = case_when(SMD == "<0.001" ~ "0", - TRUE ~ SMD), - SMD = as.numeric(SMD)) - results <- as.numeric(results_smd$SMD) - - start <- 1 + (i - 1) * length(vars) - end <- i * length(vars) - - result_table[start:end, ]$results <- results - } - result_table <- - result_table |> - mutate(results = as.numeric(results)) + lapply(unique(data$simnr), function(i) { + current_data <- data[data$simnr == i, ] + arms_to_check <- setdiff(names(current_data), c(vars, "id", "simnr")) + # check SMD for any covariants + lapply(arms_to_check, function(arm) { + tab <- + CreateTableOne( + vars = vars, + data = current_data, + strata = arm + ) + + results_smd <- + ExtractSmd(tab) |> + as.data.frame() %>% + tibble::rownames_to_column("covariants") %>% + select(covariants, results = average) |> + mutate(results = round(as.numeric(results), 3)) + + results <- + bind_cols( + simnr = i, + strata = arm, + results_smd + ) + return(results) + }) |> bind_rows() + }) |> bind_rows() + return(result_table) } ``` ```{r, cov-balance-data, echo = TRUE, results='hide'} -cov1 <- - smd_covariants_data(data = minimize_equal_weights, - vars = vars, - strata = strata) |> - tibble::add_column(method = "minimize equal") -cov2 <- - smd_covariants_data(data = minimize_unequal_weights, - vars = vars, - strata = strata) |> - tibble::add_column(method = "minimize unequal 2:1") -cov3 <- - smd_covariants_data(data = simple_data, - vars = vars, - strata = strata) |> - tibble::add_column(method = "simple randomization") - -cov4 <- - smd_covariants_data( - data = block_data, - vars = vars, - strata = strata - ) |> - tibble::add_column(method = "block randomization") - -cov5 <- - smd_covariants_data( - data = minimize_unequal_weights_2, - vars = vars, - strata = strata - ) |> - tibble::add_column(method = "minimize unequal 3:1") - - cov_balance_data <- - bind_rows(cov1, cov2, cov3, cov4, cov5) + smd_covariants_data( + data = sim_data, + vars = vars + ) %>% + mutate(method = case_when( + strata == "minimize_equal_weights_arms" ~ "minimize equal", + strata == "minimize_unequal_weights_arms" ~ "minimize unequal 2:1", + strata == "minimize_unequal_weights_triple_arms" ~ "minimize unequal 3:1", + strata == "simple_data_arms" ~ "simple randomization", + strata == "block_data_arms" ~ "block randomization" + )) %>% + select(-strata) ``` Below are the results of the SMD test presented in the form of boxplot and violin plot, depicting the outcomes for each randomization method. The red dashed line indicates the adopted precision threshold. @@ -625,7 +562,7 @@ cov_balance_data |> select(simnr, results, method) |> group_by(simnr, method) |> mutate(results = mean(results)) |> - distinct() |> + distinct() |> ggplot(aes(x = method, y = results, fill = method)) + geom_boxplot() + geom_hline(yintercept = 0.2, linetype = "dashed", color = "red") + @@ -635,13 +572,15 @@ cov_balance_data |> - **Violin plot** ```{r, violinplot, fig.cap= "Summary smd in each randomization methods in each covariants", warning = FALSE, fig.width=9, fig.height=6} -# violin plot +# violin plot cov_balance_data |> ggplot(aes(x = method, y = results, fill = covariants)) + geom_violin() + - geom_hline(yintercept = 0.2, - linetype = "dashed", - color = "red") + + geom_hline( + yintercept = 0.2, + linetype = "dashed", + color = "red" + ) + theme_bw() ``` @@ -658,35 +597,27 @@ The results are summarized in a table as the percentage of success for each ran success_power <- function(cov_data) { result_table <- - data.frame(simnr = unique(cov_data$simnr), - results = numeric(length(unique(cov_data$simnr)))) - for (i in 1:length(unique(data$simnr))) { - current_data <- cov_data[cov_data$simnr == i,] - - results <- ifelse(any(current_data$results > 0.2), 0, 1) - result_table$results[i] <- results - - success <- - sum(result_table$results) / nrow(result_table) * 100 - - } - + lapply(unique(cov_data$simnr), function(i) { + current_data <- cov_data[cov_data$simnr == i, ] + + current_data %>% + group_by(method) %>% + summarise(success = ifelse(any(results > 0.2), 0, 1)) %>% + tibble::add_column(simnr = i, .before = 1) + }) %>% bind_rows() + + success <- + result_table %>% + group_by(method) %>% + summarise(results_power = sum(success) / n() * 100) + + return(success) - } ``` ```{r, success-result-data, tab.cap = "Summary of percent success in each randomization methods"} -data.frame( - method = c( - 'minimize equal weights', - 'minimize unequal weights 2:1', - 'simple randomization', - 'block randomization', - 'minimize unequal weights 3:1' - ), - results_power = c(success_power(cov1), success_power(cov2), success_power(cov3), success_power(cov4), success_power(cov5)) -) |> +success_power(cov_balance_data) |> as.data.frame() |> rename(`power results [%]` = results_power) |> gt() From e97775fb85fd9a2480eb06cde91c0edd4684f3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 5 Feb 2024 13:42:11 +0000 Subject: [PATCH 164/240] Add more sensible lintr config --- .lintr | 4 ++++ R/api_randomize.R | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 .lintr diff --git a/.lintr b/.lintr new file mode 100644 index 0000000..3377b64 --- /dev/null +++ b/.lintr @@ -0,0 +1,4 @@ +linters: linters_with_defaults( + line_length_linter = line_length_linter(120), + object_usage_linter = NULL + ) diff --git a/R/api_randomize.R b/R/api_randomize.R index 9cb6e31..0d1cdc0 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -8,7 +8,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { checkmate::check_subset( x = req$args$study_id, choices = dplyr::tbl(db_connection_pool, "study") |> - dplyr::select("id") |> + dplyr::select(id) |> dplyr::pull() ), .var.name = "Study ID", @@ -19,7 +19,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { method_randomization <- dplyr::tbl(db_connection_pool, "study") |> dplyr::filter(.data$id == study_id) |> - dplyr::select("method") |> + dplyr::select(method) |> dplyr::pull() checkmate::assert( @@ -105,7 +105,7 @@ parse_pocock_parameters <- ratio_arms <- dplyr::tbl(db_connetion_pool, "arm") |> dplyr::filter(study_id == !!study_id) |> - dplyr::select("name", "ratio") |> + dplyr::select(name, ratio) |> dplyr::collect() params <- list( From 8f6feaaf6c924886c90a5ac62d8ab300d92f851a Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 6 Feb 2024 10:51:14 +0000 Subject: [PATCH 165/240] Added new tests for API. The error detection method has been unified across the entire API. Added library::function notation where it was missing. The structure has been changed to reflect changes caused by the implementation of code coverage. --- R/api_create_study.R | 177 ++++---- R/api_randomize.R | 160 ++++---- R/db.R | 21 +- tests/testthat/test-DB-study.R | 106 ++--- .../test-E2E-study-minimisation-pocock.R | 387 +++++++++++++++--- 5 files changed, 554 insertions(+), 297 deletions(-) diff --git a/R/api_create_study.R b/R/api_create_study.R index a6a0157..9744dcd 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -1,139 +1,104 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. identifier, name, method, arms, covariates, p, req, res) { - validation_errors <- vector() - err <- checkmate::check_character(name, min.chars = 1, max.chars = 255) - if (err != TRUE) { - validation_errors <- unbiased:::append_error( - validation_errors, "name", err - ) - } + collection <- checkmate::makeAssertCollection() - err <- checkmate::check_character(identifier, min.chars = 1, max.chars = 12) - if (err != TRUE) { - validation_errors <- unbiased:::append_error( - validation_errors, - "identifier", - err - ) - } + checkmate::assert( + checkmate::check_character(name, min.chars = 1, max.chars = 255), + .var.name = "name", + add = collection + ) - err <- checkmate::check_choice(method, choices = c("range", "var", "sd")) - if (err != TRUE) { - validation_errors <- unbiased:::append_error( - validation_errors, - "method", - err - ) - } + checkmate::assert( + checkmate::check_character(identifier, min.chars = 1, max.chars = 12), + .var.name = "identifier", + add = collection + ) + + checkmate::assert( + checkmate::check_choice(method, choices = c("range", "var", "sd")), + .var.name = "method", + add = collection) - err <- + checkmate::assert( checkmate::check_list( arms, types = "integerish", any.missing = FALSE, min.len = 2, names = "unique" - ) - if (err != TRUE) { - validation_errors <- unbiased:::append_error( - validation_errors, - "arms", - err - ) - } + ), + .var.name = "arms", + add = collection + ) - err <- + checkmate::assert( checkmate::check_list( covariates, types = c("numeric", "list", "character"), any.missing = FALSE, - min.len = 2, + min.len = 1, names = "unique" - ) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error(validation_errors, "covariates", err) - } + ), + .var.name = "covariates3", + add = collection + ) response <- list() for (c_name in names(covariates)) { c_content <- covariates[[c_name]] - err <- checkmate::check_list( - c_content, - any.missing = FALSE, - len = 2, - ) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error( - validation_errors, - glue::glue("covariates[{c_name}]"), - err - ) - } - err <- checkmate::check_names( - names(c_content), - permutation.of = c("weight", "levels"), - ) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error( - validation_errors, - glue::glue("covariates[{c_name}]"), - err - ) - } + checkmate::assert( + checkmate::check_list( + c_content, + any.missing = FALSE, + len = 2, + ), + .var.name = "covariates1", + add = collection) + + checkmate::assert( + checkmate::check_names( + names(c_content), + permutation.of = c("weight", "levels"), + ), + .var.name = "covariates2", + add = collection) # check covariate weight - err <- checkmate::check_numeric(c_content$weight, - lower = 0, - finite = TRUE, - len = 1, - null.ok = FALSE - ) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error( - validation_errors, - glue::glue("covariates[{c_name}][weight]"), - err - ) - } - - err <- checkmate::check_character(c_content$levels, - min.chars = 1, - min.len = 2, - unique = TRUE - ) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error( - validation_errors, - glue::glue("covariates[{c_name}][levels]"), - err - ) - } + checkmate::assert( + checkmate::check_numeric(c_content$weight, + lower = 0, + finite = TRUE, + len = 1, + null.ok = FALSE + ), + .var.name = "weight", + add = collection) + + checkmate::assert( + checkmate::check_character(c_content$levels, + min.chars = 1, + min.len = 2, + unique = TRUE + ), + .var.name = "levels", + add = collection) } # check probability - p <- as.numeric(p) - err <- checkmate::check_numeric(p, lower = 0, upper = 1, len = 1) - if (err != TRUE) { - validation_errors <- - unbiased:::append_error( - validation_errors, - "p", - err - ) - } + checkmate::assert( + checkmate::check_numeric(p, lower = 0, upper = 1, len = 1, + any.missing = FALSE, null.ok = FALSE), + .var.name = "p", + add = collection) + - if (length(validation_errors) > 0) { + if (length(collection$getMessages()) > 0) { res$status <- 400 return(list( - error = "Input validation failed", - validation_errors = validation_errors + error = "There was a problem with the input data to create the study", + validation_errors = collection$getMessages() )) } @@ -167,7 +132,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. if (!is.null(r$error)) { res$status <- 503 return(list( - error = "There was a problem creating the study", + error = "There was a problem saving created study to the database", details = r$error )) } diff --git a/R/api_randomize.R b/R/api_randomize.R index 9cb6e31..4093904 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -1,84 +1,3 @@ -api__randomize_patient <- function(study_id, current_state, req, res) { - collection <- checkmate::makeAssertCollection() - - db_connection_pool <- get("db_connection_pool") - - # Check whether study with study_id exists - checkmate::assert( - checkmate::check_subset( - x = req$args$study_id, - choices = dplyr::tbl(db_connection_pool, "study") |> - dplyr::select("id") |> - dplyr::pull() - ), - .var.name = "Study ID", - add = collection - ) - - # Retrieve study details, especially the ones about randomization - method_randomization <- - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(.data$id == study_id) |> - dplyr::select("method") |> - dplyr::pull() - - checkmate::assert( - checkmate::check_scalar(method_randomization, null.ok = FALSE), - .var.name = "Randomization method", - add = collection - ) - - if (length(collection$getMessages()) > 0) { - res$status <- 400 - return(list( - error = "Study input validation failed", - validation_errors = collection$getMessages() - )) - } - - # Dispatch based on randomization method to parse parameters - params <- - switch(method_randomization, - minimisation_pocock = tryCatch( - { - do.call( - parse_pocock_parameters, - list(db_connection_pool, study_id, current_state) - ) - }, - error = function(e) { - res$status <- 400 - res$body <- glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err = e) - } - ) - ) - - arm_name <- - switch(method_randomization, - minimisation_pocock = tryCatch( - { - do.call(unbiased:::randomize_minimisation_pocock, params) - }, - error = function(e) { - res$status <- 400 - res$body <- glue::glue("Error message: {conditionMessage(e)}") - logger::log_error("Error: {err}", err = e) - } - ) - ) - - arm <- dplyr::tbl(db_connection_pool, "arm") |> - dplyr::filter(study_id == !!study_id & .data$name == arm_name) |> - dplyr::select(arm_id = "id", "name", "ratio") |> - dplyr::collect() - - unbiased:::save_patient(study_id, arm$arm_id) |> - dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = "id") |> - as.list() -} - parse_pocock_parameters <- function(db_connetion_pool, study_id, current_state) { parameters <- @@ -129,3 +48,82 @@ parse_pocock_parameters <- return(params) } + +api__randomize_patient <- function(study_id, current_state, req, res) { + collection <- checkmate::makeAssertCollection() + + db_connection_pool <- get("db_connection_pool") + + # Check whether study with study_id exists + checkmate::assert( + checkmate::check_subset( + x = req$args$study_id, + choices = dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(id) |> + dplyr::pull() + ), + .var.name = "study_id", + add = collection + ) + + # Retrieve study details, especially the ones about randomization + method_randomization <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::select("method") |> + dplyr::pull() + + checkmate::assert( + checkmate::check_scalar(method_randomization, null.ok = FALSE), + .var.name = "method_randomization", + add = collection + ) + + if (length(collection$getMessages()) > 0) { + res$status <- 400 + return(list( + error = "There was a problem with the randomization preparation", + validation_errors = collection$getMessages() + )) + } + + # Dispatch based on randomization method to parse parameters + params <- + switch( + method_randomization, + minimisation_pocock = do.call( + parse_pocock_parameters, list(db_connection_pool, study_id, current_state) + ) + ) + + arm_name <- + switch( + method_randomization, + minimisation_pocock = do.call( + unbiased:::randomize_minimisation_pocock, params + ) + ) + + arm <- dplyr::tbl(db_connection_pool, "arm") |> + dplyr::filter(study_id == !!study_id & .data$name == arm_name) |> + dplyr::select("arm_id" = "id", "name", "ratio") |> + dplyr::collect() + + randomized_patient <- unbiased:::save_patient(study_id, arm$arm_id) + + if (!is.null(randomized_patient$error)) { + res$status <- 503 + return(list( + error = "There was a problem saving randomized patient to the database", + details = randomized_patient$error + )) + } else { + randomized_patient <- + randomized_patient |> + dplyr::mutate(arm_name = arm$name) |> + dplyr::rename(patient_id = id) |> + as.list() + + return(randomized_patient) + } +} diff --git a/R/db.R b/R/db.R index 8bca246..c2f6846 100644 --- a/R/db.R +++ b/R/db.R @@ -139,15 +139,22 @@ create_study <- function( r } -save_patient <- function(study_id, arm_id) { - db_connection_pool <- get("db_connection_pool") - randomized_patient <- DBI::dbGetQuery( - db_connection_pool, - "INSERT INTO patient (arm_id, study_id) +save_patient <- function(study_id, arm_id){ + + r <- tryCatch({ + randomized_patient <- DBI::dbGetQuery( + db_connection_pool, + "INSERT INTO patient (arm_id, study_id) VALUES ($1, $2) RETURNING id, arm_id", - list(arm_id, study_id) + list(arm_id, study_id) + ) + }, + error = function(cond) { + logger::log_error("Error randomizing patient: {cond}", cond=cond) + list(error = conditionMessage(cond)) + } ) - return(randomized_patient) + return(r) } diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index ca474cb..54c05a5 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -5,10 +5,10 @@ pool <- get("db_connection_pool", envir = globalenv()) test_that("it is enough to provide a name, an identifier, and a method id", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_no_error({ - tbl(conn, "study") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "study") |> + dplyr::rows_append( + tibble::tibble( identifier = "FINE", name = "Correctly working study", method = "minimisation_pocock" @@ -19,25 +19,25 @@ test_that("it is enough to provide a name, an identifier, and a method id", { }) # first study id is 1 -new_study_id <- 1 |> as.integer() +new_study_id <- as.integer(1) test_that("deleting archivizes a study", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_no_error({ - tbl(conn, "study") |> - rows_delete( - tibble(id = new_study_id), + testthat::expect_no_error({ + dplyr::tbl(conn, "study") |> + dplyr::rows_delete( + tibble::tibble(id = new_study_id), copy = TRUE, in_place = TRUE, unmatched = "ignore" ) }) - expect_identical( - tbl(conn, "study_history") |> - filter(id == new_study_id) |> - select(-parameters, -sys_period, -timestamp) |> - collect(), - tibble( + testthat::expect_identical( + dplyr::tbl(conn, "study_history") |> + dplyr::filter(id == new_study_id) |> + dplyr::select(-parameters, -sys_period, -timestamp) |> + dplyr::collect(), + tibble::tibble( id = new_study_id, identifier = "TEST", name = "Test Study", @@ -49,11 +49,11 @@ test_that("deleting archivizes a study", { test_that("can't push arm with negative ratio", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_error( + testthat::expect_error( { - tbl(conn, "arm") |> - rows_append( - tibble( + dplyr::tbl(conn, "arm") |> + dplyr::rows_append( + tibble::tibble( study_id = 1, name = "Exception-throwing arm", ratio = -1 @@ -68,7 +68,7 @@ test_that("can't push arm with negative ratio", { test_that("can't push stratum other than factor or numeric", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - expect_error( + testthat::expect_error( { tbl(conn, "stratum") |> rows_append( @@ -89,10 +89,10 @@ test_that("can't push stratum level outside of defined levels", { with_db_fixtures("fixtures/example_study.yml") # create a new patient return <- - expect_no_error({ - tbl(conn, "patient") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "patient") |> + dplyr::rows_append( + tibble::tibble( study_id = 1, arm_id = 1, used = TRUE @@ -104,11 +104,11 @@ test_that("can't push stratum level outside of defined levels", { added_patient_id <- return$id - expect_error( + testthat::expect_error( { - tbl(conn, "patient_stratum") |> - rows_append( - tibble( + dplyr::tbl(conn, "patient_stratum") |> + dplyr::rows_append( + tibble::tibble( patient_id = added_patient_id, stratum_id = 1, fct_value = "Female" @@ -120,10 +120,10 @@ test_that("can't push stratum level outside of defined levels", { ) # add legal value - expect_no_error({ - tbl(conn, "patient_stratum") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "patient_stratum") |> + dplyr::rows_append( + tibble::tibble( patient_id = added_patient_id, stratum_id = 1, fct_value = "F" @@ -136,12 +136,12 @@ test_that("can't push stratum level outside of defined levels", { test_that("numerical constraints are enforced", { conn <- pool::localCheckout(pool) with_db_fixtures("fixtures/example_study.yml") - added_patient_id <- 1 |> as.integer() + added_patient_id <- as.integer(1) return <- - expect_no_error({ - tbl(conn, "stratum") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "stratum") |> + dplyr::rows_append( + tibble::tibble( study_id = 1, name = "age", value_type = "numeric" @@ -153,10 +153,10 @@ test_that("numerical constraints are enforced", { added_stratum_id <- return$id - expect_no_error({ - tbl(conn, "numeric_constraint") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "numeric_constraint") |> + dplyr::rows_append( + tibble::tibble( stratum_id = added_stratum_id, min_value = 18, max_value = 64 @@ -166,11 +166,11 @@ test_that("numerical constraints are enforced", { }) # and you can't add an illegal value - expect_error( + testthat::expect_error( { - tbl(conn, "patient_stratum") |> - rows_append( - tibble( + dplyr::tbl(conn, "patient_stratum") |> + dplyr::rows_append( + tibble::tibble( patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 16 @@ -182,10 +182,10 @@ test_that("numerical constraints are enforced", { ) # you can add valid value - expect_no_error({ - tbl(conn, "patient_stratum") |> - rows_append( - tibble( + testthat::expect_no_error({ + dplyr::tbl(conn, "patient_stratum") |> + dplyr::rows_append( + dplyr::tibble( patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 23 @@ -195,11 +195,11 @@ test_that("numerical constraints are enforced", { }) # but you cannot add two values for one patient one stratum - expect_error( + testthat::expect_error( { - tbl(conn, "patient_stratum") |> - rows_append( - tibble( + dplyr::tbl(conn, "patient_stratum") |> + dplyr::rows_append( + tibble::tibble( patient_id = added_patient_id, stratum_id = added_stratum_id, num_value = 24 diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index c366092..6cb6917 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -1,4 +1,6 @@ -test_that("endpoint returns the study id, can randomize 2 patients", { +pool <- get("db_connection_pool", envir = globalenv()) + +test_that("correct request with the structure of the returned result", { response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> @@ -10,8 +12,7 @@ test_that("endpoint returns the study id, can randomize 2 patients", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1 - ), + "active" = 1), covariates = list( sex = list( weight = 1, @@ -21,10 +22,10 @@ test_that("endpoint returns the study id, can randomize 2 patients", { weight = 1, levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) - ) - ) + )) ) |> req_perform() + response_body <- response |> resp_body_json() @@ -36,60 +37,346 @@ test_that("endpoint returns the study id, can randomize 2 patients", { req_url_path("study", response_body$study$id, "patient") |> req_method("POST") |> req_body_json( - data = list( - current_state = - tibble::tibble( - "sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", "") - ) - ) + data = list(current_state = + tibble::tibble("sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", ""))) ) |> req_perform() + response_patient_body <- response_patient |> resp_body_json() testthat::expect_equal(response$status_code, 200) - expect_number(response_patient_body$patient_id, lower = 1) + checkmate::expect_number(response_patient_body$patient_id, lower = 1) # Endpoint Response Structure Test - checkmate::expect_names( - names(response_patient_body), - identical.to = c("patient_id", "arm_id", "arm_name") - ) - checkmate::expect_list( - response_patient_body, - any.missing = TRUE, - null.ok = FALSE, - len = 3, type = c("numeric", "numeric", "character") - ) - - # Incorrect Study ID + checkmate::expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) + checkmate::expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) +}) + +test_that("request with one covariate at two levels", { + + response_cov <- + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + )) + ) + ) |> + req_perform() + + response_cov_body <- + response_cov |> + resp_body_json() + + testthat::expect_equal(response_cov$status_code, 200) +}) + +test_that("request with incorrect study id", { + + response <- request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform() + + response_body <- + response |> + resp_body_json() response_study <- - tryCatch( - { - request(api_url) |> - req_url_path("study", response_body$study$id + 1, "patient") |> - req_method("POST") |> - req_body_json( - data = list( - current_state = - tibble::tibble( - "sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", "") - ) - ) - ) |> - req_perform() - }, - error = function(e) e - ) - - checkmate::expect_set_equal( - response_study$status, 400, - label = "HTTP status code" - ) + tryCatch({ + request(api_url) |> + req_url_path("study", response_body$study$id + 1, "patient") |> + req_method("POST") |> + req_body_json( + data = list(current_state = + tibble::tibble("sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", ""))) + ) |> + req_perform() + }, error = function(e) e) + + testthat::expect_equal(response_study$status, 400, label = "HTTP status code") +}) + +test_that("request with patient that is assigned an arm at entry", { + + response <- request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform() + + response_body <- + response |> + resp_body_json() + + response_current_state <- + tryCatch({ + request(api_url) |> + req_url_path("study", response_body$study$id, "patient") |> + req_method("POST") |> + req_body_json( + data = list(current_state = + tibble::tibble("sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "control"))) + ) |> + req_perform() + }, error = function(e) e) + + testthat::expect_equal(response_current_state$status, 500, label = "HTTP status code") +}) + +test_that("request with incorrect number of levels", { + + response_cov <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_cov$status, 400) + +}) + +test_that("request with incorrect parameter p", { + response_p <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = "A", + arms = list( + "placebo" = 1, + "active" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_p$status, 400) +}) + +test_that("request with incorrect arms", { + response_arms <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_raw('{ + "identifier": "ABC-X", + "name": "Study ABC-X", + "method": "var", + "p": 0.85, + "arms": { + "placebo": 1, + "placebo": 1 + }, + "covariates": { + "sex": { + "weight": 1, + "levels": ["female", "male"] + }, + "weight": { + "weight": 1, + "levels": ["up to 60kg", "61-80 kg", "81 kg or more"] + } + } + }' + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_arms$status, 400) +}) + +test_that("request with incorrect method", { + + response_method <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = 1, + p = 0.85, + arms = list( + "placebo" = 1, + "control" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_method$status, 400) +}) + +test_that("request with incorrect weights", { + response_weights <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "control" = 1), + covariates = list( + sex = list( + weight = "1", + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_weights$status, 400) +}) + +test_that("request with incorrect ratio", { + + response_ratio <- + tryCatch({ + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = "1", + "control" = 1), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + )) + ) |> + req_perform()}, + error = function(e) e) + + testthat::expect_equal(response_ratio$status, 400) + }) From 26eecdacdf51befc808fab8564d99bcdd1b07b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 6 Feb 2024 12:13:03 +0000 Subject: [PATCH 166/240] add sentry support --- DESCRIPTION | 1 + R/run-api.R | 66 ++++++++++++++++++++++ README.md | 9 ++- inst/plumber/unbiased_api/meta.R | 4 +- inst/plumber/unbiased_api/plumber.R | 6 +- inst/plumber/unbiased_api/study.R | 10 ++-- renv.lock | 25 ++++++++ tests/testthat/setup-testing-environment.R | 4 ++ 8 files changed, 116 insertions(+), 9 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index e1ea907..964897c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -43,6 +43,7 @@ Suggests: glue, jsonlite, purrr + sentryR RdMacros: mathjaxr Config/testthat/edition: 3 Encoding: UTF-8 diff --git a/R/run-api.R b/R/run-api.R index 9030be7..3f8e0de 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -12,6 +12,15 @@ #' #' @export run_unbiased <- function() { + tryCatch( + { + rlang::global_entrace() + }, + error = function(e) { + message("Error setting up global_entrace, it is expected in testing environment: ", e$message) + } + ) + setup_sentry() host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) assign("db_connection_pool", @@ -38,3 +47,60 @@ run_unbiased <- function() { plumber::pr_run(host = host, port = port) } } + + +#' setup_sentry function +#' +#' This function is used to configure Sentry, a service for real-time error tracking. +#' It uses the sentryR package to set up Sentry based on environment variables. +#' +#' @param None +#' +#' @return None. If the SENTRY_DSN environment variable is not set, the function will +#' return a message and stop execution. +#' +#' @examples +#' setup_sentry() +#' +#' @details +#' The function first checks if the SENTRY_DSN environment variable is set. If not, it +#' returns a message and stops execution. +#' If SENTRY_DSN is set, it uses the sentryR::configure_sentry function to set up Sentry with +#' the following parameters: +#' - dsn: The Data Source Name (DSN) is retrieved from the SENTRY_DSN environment variable. +#' - app_name: The application name is set to "unbiased". +#' - app_version: The application version is retrieved from the GITHUB_SHA environment variable. +#' If not set, it defaults to "unspecified". +#' - environment: The environment is retrieved from the SENTRY_ENVIRONMENT environment variable. +#' If not set, it defaults to "development". +#' - release: The release is retrieved from the SENTRY_RELEASE environment variable. +#' If not set, it defaults to "unspecified". +#' +#' @seealso \url{https://docs.sentry.io/} +#' +#' @export +setup_sentry <- function() { + sentry_dsn <- Sys.getenv("SENTRY_DSN") + if (sentry_dsn == "") { + message("SENTRY_DSN not set, skipping Sentry setup") + return() + } + + sentryR::configure_sentry( + dsn = sentry_dsn, + app_name = "unbiased", + app_version = Sys.getenv("GITHUB_SHA", "unspecified"), + environment = Sys.getenv("SENTRY_ENVIRONMENT", "development"), + release = Sys.getenv("SENTRY_RELEASE", "unspecified") + ) + + globalCallingHandlers( + error = global_calling_handler + ) +} + +global_calling_handler <- function(error) { + error$function_calls <- sys.calls() + sentryR::capture_exception(error) + signalCondition(error) +} diff --git a/README.md b/README.md index 5eadab3..9a3dc09 100644 --- a/README.md +++ b/README.md @@ -54,4 +54,11 @@ To calculate code coverage, you will need to install the `covr` package. Once in - `covr::report()`: This method runs all tests and generates a detailed coverage report in HTML format. - `covr::package_coverage()`: This method provides a simpler, text-based code coverage report. -Alternatively, you can use the provided `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. \ No newline at end of file +Alternatively, you can use the provided `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. + +# Configuring Sentry +The Unbiased server offers robust error reporting capabilities through the integration of the Sentry service. To activate Sentry, simply set the `SENTRY_DSN` environment variable. Additionally, you have the flexibility to customize the setup further by configuring the following environment variables: + +* `SENTRY_ENVIRONMENT` This is used to set the environment (e.g., "production", "staging", "development"). If not set, the environment defaults to "development". + +* `SENTRY_RELEASE` This is used to set the release in Sentry. If not set, the release defaults to "unspecified". \ No newline at end of file diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 171e191..09622bf 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -6,7 +6,7 @@ #* @tag other #* @get /sha #* @serializer unboxedJSON -function(res) { +sentryR::with_captured_calls(function(req, res) { sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") if (sha == "NULL") { res$status <- 404 @@ -14,4 +14,4 @@ function(res) { } else { return(sha) } -} +}) diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 3f2b07d..06add32 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -19,8 +19,10 @@ #* #* @plumber function(api) { - meta <- plumber::pr("meta.R") - study <- plumber::pr("study.R") + meta <- plumber::pr("meta.R") |> + plumber::pr_set_error(sentryR::sentry_error_handler) + study <- plumber::pr("study.R") |> + plumber::pr_set_error(sentryR::sentry_error_handler) api |> plumber::pr_mount("/meta", meta) |> diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index f613b4e..07e7f95 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -18,13 +18,15 @@ #* @post /minimisation_pocock #* @serializer unboxedJSON #* -function(identifier, name, method, arms, covariates, p, req, res) { +sentryR::with_captured_calls(function( + identifier, name, method, arms, covariates, p, req, res +) { return( unbiased:::api__minimization_pocock( identifier, name, method, arms, covariates, p, req, res ) ) -} +}) #* Randomize one patient #* @@ -37,8 +39,8 @@ function(identifier, name, method, arms, covariates, p, req, res) { #* @serializer unboxedJSON #* -function(study_id, current_state, req, res) { +sentryR::with_captured_calls(function(study_id, current_state, req, res) { return( unbiased:::api__randomize_patient(study_id, current_state, req, res) ) -} +}) diff --git a/renv.lock b/renv.lock index f303d39..7f046af 100644 --- a/renv.lock +++ b/renv.lock @@ -1212,6 +1212,21 @@ ], "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" }, + "sentryR": { + "Package": "sentryR", + "Version": "1.1.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "httr", + "jsonlite", + "stats", + "stringr", + "tibble", + "uuid" + ], + "Hash": "f37e91d605fbf665d7b5467ded4e539e" + }, "sessioninfo": { "Package": "sessioninfo", "Version": "1.2.2", @@ -1506,6 +1521,16 @@ ], "Hash": "1fe17157424bb09c48a8b3b550c753bc" }, + "uuid": { + "Package": "uuid", + "Version": "1.2-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "303c19bfd970bece872f93a824e323d9" + }, "vctrs": { "Package": "vctrs", "Version": "0.6.4", diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index c09cd7b..b417a83 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -131,6 +131,10 @@ setup_test_db_connection_pool <- function(envir = parent.frame()) { ) } +# Make sure to disable Sentry during testing +withr::local_envvar( + SENTRY_DSN = NULL +) # We will always run the API on the localhost # and on a random port From 622cfdb41357d266a4b0e983ae2e9da0f8e4517f Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 6 Feb 2024 12:27:45 +0000 Subject: [PATCH 167/240] Improved code style by {styler} --- .../test-E2E-study-minimisation-pocock.R | 412 ++++++++++-------- 1 file changed, 235 insertions(+), 177 deletions(-) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 6cb6917..3ba3e19 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -12,7 +12,8 @@ test_that("correct request with the structure of the returned result", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1), + "active" = 1 + ), covariates = list( sex = list( weight = 1, @@ -22,7 +23,8 @@ test_that("correct request with the structure of the returned result", { weight = 1, levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) - )) + ) + ) ) |> req_perform() @@ -37,10 +39,14 @@ test_that("correct request with the structure of the returned result", { req_url_path("study", response_body$study$id, "patient") |> req_method("POST") |> req_body_json( - data = list(current_state = - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", ""))) + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "") + ) + ) ) |> req_perform() @@ -52,12 +58,21 @@ test_that("correct request with the structure of the returned result", { checkmate::expect_number(response_patient_body$patient_id, lower = 1) # Endpoint Response Structure Test - checkmate::expect_names(names(response_patient_body), identical.to = c("patient_id", "arm_id", "arm_name")) - checkmate::expect_list(response_patient_body, any.missing = TRUE, null.ok = FALSE, len = 3, type = c("numeric", "numeric", "character")) + checkmate::expect_names( + names(response_patient_body), + identical.to = c("patient_id", "arm_id", "arm_name") + ) + + checkmate::expect_list( + response_patient_body, + any.missing = TRUE, + null.ok = FALSE, + len = 3, + type = c("numeric", "numeric", "character") + ) }) test_that("request with one covariate at two levels", { - response_cov <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> @@ -70,12 +85,14 @@ test_that("request with one covariate at two levels", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1), + "active" = 1 + ), covariates = list( sex = list( weight = 1, levels = c("female", "male") - )) + ) + ) ) ) |> req_perform() @@ -88,7 +105,6 @@ test_that("request with one covariate at two levels", { }) test_that("request with incorrect study id", { - response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> @@ -100,7 +116,8 @@ test_that("request with incorrect study id", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1), + "active" = 1 + ), covariates = list( sex = list( weight = 1, @@ -110,7 +127,8 @@ test_that("request with incorrect study id", { weight = 1, levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) - )) + ) + ) ) |> req_perform() @@ -119,24 +137,30 @@ test_that("request with incorrect study id", { resp_body_json() response_study <- - tryCatch({ - request(api_url) |> - req_url_path("study", response_body$study$id + 1, "patient") |> - req_method("POST") |> - req_body_json( - data = list(current_state = - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", ""))) - ) |> - req_perform() - }, error = function(e) e) + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id + 1, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "") + ) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_study$status, 400, label = "HTTP status code") }) test_that("request with patient that is assigned an arm at entry", { - response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> @@ -148,7 +172,8 @@ test_that("request with patient that is assigned an arm at entry", { p = 0.85, arms = list( "placebo" = 1, - "active" = 1), + "active" = 1 + ), covariates = list( sex = list( weight = 1, @@ -158,7 +183,8 @@ test_that("request with patient that is assigned an arm at entry", { weight = 1, levels = c("up to 60kg", "61-80 kg", "81 kg or more") ) - )) + ) + ) ) |> req_perform() @@ -167,95 +193,114 @@ test_that("request with patient that is assigned an arm at entry", { resp_body_json() response_current_state <- - tryCatch({ - request(api_url) |> - req_url_path("study", response_body$study$id, "patient") |> - req_method("POST") |> - req_body_json( - data = list(current_state = - tibble::tibble("sex" = c("female", "male"), - "weight" = c("61-80 kg", "81 kg or more"), - "arm" = c("placebo", "control"))) - ) |> - req_perform() - }, error = function(e) e) - - testthat::expect_equal(response_current_state$status, 500, label = "HTTP status code") + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more"), + "arm" = c("placebo", "control") + ) + ) + ) |> + req_perform() + }, + error = function(e) e + ) + + testthat::expect_equal( + response_current_state$status, 500, + label = "HTTP status code" + ) }) test_that("request with incorrect number of levels", { - response_cov <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_json( - data = list( - identifier = "ABC-X", - name = "Study ABC-X", - method = "var", - p = 0.85, - arms = list( - "placebo" = 1, - "active" = 1), - covariates = list( - sex = list( - weight = 1, - levels = c("female") + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1 ), - weight = list( - weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more") + covariates = list( + sex = list( + weight = 1, + levels = c("female") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) ) - )) - ) |> - req_perform()}, - error = function(e) e) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_cov$status, 400) - }) test_that("request with incorrect parameter p", { response_p <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_json( - data = list( - identifier = "ABC-X", - name = "Study ABC-X", - method = "var", - p = "A", - arms = list( - "placebo" = 1, - "active" = 1), - covariates = list( - sex = list( - weight = 1, - levels = c("female", "male") + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = "A", + arms = list( + "placebo" = 1, + "active" = 1 ), - weight = list( - weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more") + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) ) - )) - ) |> - req_perform()}, - error = function(e) e) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_p$status, 400) }) test_that("request with incorrect arms", { response_arms <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_raw('{ + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_raw('{ "identifier": "ABC-X", "name": "Study ABC-X", "method": "var", @@ -274,109 +319,122 @@ test_that("request with incorrect arms", { "levels": ["up to 60kg", "61-80 kg", "81 kg or more"] } } - }' - ) |> - req_perform()}, - error = function(e) e) + }') |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_arms$status, 400) }) test_that("request with incorrect method", { - response_method <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_json( - data = list( - identifier = "ABC-X", - name = "Study ABC-X", - method = 1, - p = 0.85, - arms = list( - "placebo" = 1, - "control" = 1), - covariates = list( - sex = list( - weight = 1, - levels = c("female", "male") + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = 1, + p = 0.85, + arms = list( + "placebo" = 1, + "control" = 1 ), - weight = list( - weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more") + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) ) - )) - ) |> - req_perform()}, - error = function(e) e) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_method$status, 400) }) test_that("request with incorrect weights", { response_weights <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_json( - data = list( - identifier = "ABC-X", - name = "Study ABC-X", - method = "var", - p = 0.85, - arms = list( - "placebo" = 1, - "control" = 1), - covariates = list( - sex = list( - weight = "1", - levels = c("female", "male") + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "control" = 1 ), - weight = list( - weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more") + covariates = list( + sex = list( + weight = "1", + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) ) - )) - ) |> - req_perform()}, - error = function(e) e) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_weights$status, 400) }) test_that("request with incorrect ratio", { - response_ratio <- - tryCatch({ - request(api_url) |> - req_url_path("study", "minimisation_pocock") |> - req_method("POST") |> - req_body_json( - data = list( - identifier = "ABC-X", - name = "Study ABC-X", - method = "var", - p = 0.85, - arms = list( - "placebo" = "1", - "control" = 1), - covariates = list( - sex = list( - weight = 1, - levels = c("female", "male") + tryCatch( + { + request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = "1", + "control" = 1 ), - weight = list( - weight = 1, - levels = c("up to 60kg", "61-80 kg", "81 kg or more") + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) ) - )) - ) |> - req_perform()}, - error = function(e) e) + ) + ) |> + req_perform() + }, + error = function(e) e + ) testthat::expect_equal(response_ratio$status, 400) - }) From 37c4de53abc78fa6a9a681371d2bdd4c9da19916 Mon Sep 17 00:00:00 2001 From: Ola Date: Wed, 7 Feb 2024 13:41:08 +0000 Subject: [PATCH 168/240] changes in text after .RDS adding add author add source in run_parallel.R --- vignettes/helpers/run_parallel.R | 2 + .../minimization_randomization_comparison.Rmd | 44 ++++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/vignettes/helpers/run_parallel.R b/vignettes/helpers/run_parallel.R index 2fda449..ed1f17d 100644 --- a/vignettes/helpers/run_parallel.R +++ b/vignettes/helpers/run_parallel.R @@ -1,3 +1,5 @@ +source("helpers/functions.R") + # set cluster library(parallel) # Start parallel cluster diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/minimization_randomization_comparison.Rmd index a5f5b60..2be3400 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -1,12 +1,13 @@ --- -title: "Comparison of Minimization Randomization with Other Randomization Methods - balance of covariates" -author: "Aleksandra Duda - Tranistion Technologies Science" +title: "Comparison of Minimization Randomization with Other Randomization Methods. Assessing the balance of covariates." +author: + - Aleksandra Duda, Jagoda Głowacka-Walas^[Tranistion Technologies Science] date: "`r Sys.Date()`" output: html_vignette: toc: yes vignette: > - %\VignetteIndexEntry{Comparison of Minimization Randomization with Other Randomization Methods - balance of covariates} + %\VignetteIndexEntry{Comparison of Minimization Randomization with Other Randomization Methods. Assessing the balance of covariates.} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} bibliography: references.bib @@ -29,7 +30,7 @@ It's important to note, however, that while simple randomization is powerful in This document provides a summary of the comparison of three randomization methods: simple randomization, block randomization, and adaptive randomization. Simple randomization and adaptive randomization (minimization method) are tools available in the `unbiased` package as `randomize_simple` and `randomize_minimisation_pocock` functions (@unbiased). The comparison aims to demonstrate the superiority of adaptive randomization (minimization method) over other methods in assessing the least imbalance of accompanying variables between therapeutic groups. Monte Carlo simulations were used to generate data, utilizing the `simstudy` package (@goldfeld2020simstudy). Parameters for the binary distribution of variables were based on data from the publication by @mrozikiewicz2023allogenic and information from researchers. -The document structure is as follows: first, data will be simulated using the Monte-Carlo method, in the next step, for each of the given simulations, study groups will be assigned to all patients using the three randomisation methods - these data will be summarised for the first iteration using statistical tests, finally, the results based on the standardised mean difference (SMD) test will be discussed in visual form (boxplot, violin plot) and as a percentage of success achieved in each method for the given precision (tabular summary). +The document structure is as follows: first, based on the defined parameters, data will be simulated using the Monte Carlo method for a single simulation; then, for the generated patient data, appropriate groups will be assigned to them using three randomization methods; these data will be summarized in the form of descriptive statistics along with the relevant statistical test; next, data prepared in .Rds format generated for 1000 simulations will be loaded., the results based on the standardised mean difference (SMD) test will be discussed in visual form (boxplot, violin plot) and as a percentage of success achieved in each method for the given precision (tabular summary) ```{r setup, warning = FALSE, message=FALSE} # load packages @@ -38,7 +39,6 @@ library(dplyr) library(devtools) library(simstudy) library(tableone) -library(checkmate) library(ggplot2) library(gt) library(gtsummary) @@ -75,7 +75,6 @@ where: In this simulation, we are using a real use case - the planned FootCell study - non-commercial clinical research in the area of civilisation diseases - to guide our data generation process. For the FootCell study, it is anticipated that a total of 105 patients will be randomized into the trial. These patients will be equally divided among three research groups - Group A, Group B, and Group C - with each group comprising 35 patients. - ```{r, define-parameters} # defined number of patients n <- 105 @@ -147,9 +146,11 @@ data.frame( Monte-Carlo simulations were used to accumulate the data. This method is designed to model variables based on defined parameters. Variables were defined using the `simstudy` package, utilizing the `defData` function (@goldfeld2020simstudy). As all variables specify proportions, `dist = 'binary'` was used to define the variables. Due to the likely association between the type of diabetes and age – meaning that the older the patient, the higher the probability of having type II diabetes – a relationship with diabetes was established when defining the `age` variable using a logit function `link = "logit"`. The proportions for gender and diabetes were defined by the researchers and were consistent with the literature @mrozikiewicz2023allogenic. -Using the `simulate_data_monte_carlo` function (using `genData` function - `simstudy` package), a data frame was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients in each iteration. +Using `genData` function from `simstudy` package, a data frame (**data**) was generated with an artificially adopted variable `arm`, which will be filled in by subsequent randomization methods in the arm allocation process for all `n` patients. ```{r, defdata} +# defining variables + # male - 0.9 def <- simstudy::defData(varname = "sex", formula = "0.9", dist = "binary") # type I - 0.15 @@ -165,6 +166,7 @@ def <- simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = ``` ```{r, create-data} +# generate data using genData() data <- genData(n, def) |> mutate( @@ -193,7 +195,7 @@ data[1:5, 2:ncol(data)] |> ## Minimization randomization -To generate appropriate research arms for each simulation, a function called `minimize_results` was written, utilizing the `randomize_minimisation_pocock` function available within the `unbiased` package (@unbiased). The probability parameter was set at the level defined within the function (p = 0.85). In the case of minimization randomization, to verify which type of minimization (with equal weights or unequal weights) was used, three calls to the minimize_results function were prepared: +To generate appropriate research arms, a function called `minimize_results` was written, utilizing the `randomize_minimisation_pocock` function available within the `unbiased` package (@unbiased). The probability parameter was set at the level defined within the function (p = 0.85). In the case of minimization randomization, to verify which type of minimization (with equal weights or unequal weights) was used, three calls to the minimize_results function were prepared: - **minimize_equal_weights** - each covariate weight takes a value equal to 1 divided by the number of covariates. In this case, the weight is 1/6, @@ -201,7 +203,7 @@ To generate appropriate research arms for each simulation, a function called `mi - **minimize_unequal_weights_triple** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. -The tables present information about allocations for the first 5 patients in the initial iteration of the simulation. +The tables present information about allocations for the first 5 patients. ```{r, minimize-results} # drawing an arm for each patient @@ -330,7 +332,7 @@ statistics_table(minimize_unequal_weights_triple) In the next step, appropriate arms were generated for patients using simple randomization, available through the `unbiased` package - the `randomize_simple` function (@unbiased). The `simple_results` function was called within `simple_data`, considering the initial assumption of assigning patients to three arms in a 1:1:1 ratio. -Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly (flip coin method). The tables illustrate an example of data output for simple randomization in the first iteration and summary statistics including a summary of the statistical tests. +Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly (flip coin method). The tables illustrate an example of data output and summary statistics including a summary of the statistical tests. ```{r, simple-result} # simple randomization @@ -370,9 +372,9 @@ Block randomization, as opposed to minimization and simple randomization methods Based on the `block_rand` function, it is possible to generate a randomisation list, based on which patients will be allocated, with characteristics from the output `data` frame. Due to the 3 arms and the need to blind the allocation of consecutive patients, block sizes 3,6 and 9 were used for the calculations. -In the next step, patients were assigned to research groups using the `block_results` function (based on the list generated by the function `block_rand`). For each iteration, the first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). +In the next step, patients were assigned to research groups using the `block_results` function (based on the list generated by the function `block_rand`). A first available code from the randomization list that meets specific conditions is selected, and then it is removed from the list of available codes. Based on this, research arms are generated to ensure the appropriate number of patients in each group (based on the assumed ratio of 1:1:1). -The tables show the assignment of patients to groups using block randomisation for the first 5 rows, first iteration and summary statistics including a summary of the statistical tests. +The tables show the assignment of patients to groups using block randomisation and summary statistics including a summary of the statistical tests. ```{r, block-rand} # Function to generate a randomisation list @@ -462,25 +464,25 @@ block_data[1:5, 2:ncol(block_data)] |> statistics_table(block_data) ``` -## Check balance using smd test - ## Generate 1000 simulations -The number of iterations indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters. +We have performed 1000 iterations of data generation with parameters defined above. The number of iterations indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters This allowed for the generation of data 1000 times for 105 patients to more efficiently assess the effect of randomization methods in the context of covariate balance. + +These data were assigned to the variable sim_data based on the data stored in the .Rds file `1000_sim_data.Rds`, available within the vignette information on the GitHub repository of the `unbiased` package. ```{r, simulations} # define number of iterations # no_of_iterations <- 1000 # define number of cores # no_of_cores <- 20 -# perform simutations (run carefully!) +# perform simulations (run carefully!) # source("~/unbiased/vignettes/helpers/run_parallel.R") # read data from file sim_data <- readRDS("~/unbiased/vignettes/1000_sim_data.Rds") ``` -We have performed 1000 iterations of data generation with parameters defined above (...) +## Check balance using smd test In order to select the test and define the precision at a specified level, above which we assume no imbalance, a literature analysis was conducted based on publications such as @lee2021estimating, @austin2009balance, @doah2021impact, @brown2020novel, @nguyen2017double, @sanchez2003effect, @lee2022propensity, @berger2021roadmap. @@ -574,14 +576,16 @@ cov_balance_data |> ```{r, violinplot, fig.cap= "Summary smd in each randomization methods in each covariants", warning = FALSE, fig.width=9, fig.height=6} # violin plot cov_balance_data |> - ggplot(aes(x = method, y = results, fill = covariants)) + + ggplot(aes(x = method, y = results, fill = method)) + geom_violin() + geom_hline( yintercept = 0.2, linetype = "dashed", color = "red" ) + - theme_bw() + facet_wrap(~covariants, ncol = 3) + + theme_bw() + + theme(axis.text = element_text(angle=45, vjust = 0.5, hjust=1)) ``` - **Summary table of success** @@ -590,7 +594,7 @@ Based on the specified precision threshold of 0.2, a function defining randomiza The final success power is calculated as the sum of successes in each iteration divided by the total number of specified iterations. -The results are summarized in a table as the percentage of success for each randomization method. +The results are summarized in a table as the percentage of success for each randomization method. ```{r, success-power} # function defining success of randomisation From ce41eb1e6d5f6b20607e27d41655ee10c816c9d4 Mon Sep 17 00:00:00 2001 From: Ola Date: Wed, 7 Feb 2024 14:34:55 +0000 Subject: [PATCH 169/240] interpunction --- vignettes/minimization_randomization_comparison.Rmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/minimization_randomization_comparison.Rmd index 2be3400..255618b 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -466,9 +466,9 @@ statistics_table(block_data) ## Generate 1000 simulations -We have performed 1000 iterations of data generation with parameters defined above. The number of iterations indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters This allowed for the generation of data 1000 times for 105 patients to more efficiently assess the effect of randomization methods in the context of covariate balance. +We have performed 1000 iterations of data generation with parameters defined above. The number of iterations indicates the number of iterations included in the Monte-Carlo simulations to accumulate data for the given parameters. This allowed for the generation of data 1000 times for 105 patients to more efficiently assess the effect of randomization methods in the context of covariate balance. -These data were assigned to the variable sim_data based on the data stored in the .Rds file `1000_sim_data.Rds`, available within the vignette information on the GitHub repository of the `unbiased` package. +These data were assigned to the variable `sim_data` based on the data stored in the .Rds file `1000_sim_data.Rds`, available within the vignette information on the GitHub repository of the `unbiased` package. ```{r, simulations} # define number of iterations From 343dae515d2d922ba926be08a4fe82a298335315 Mon Sep 17 00:00:00 2001 From: Ola Date: Thu, 8 Feb 2024 07:48:59 +0000 Subject: [PATCH 170/240] add missing id column --- vignettes/minimization_randomization_comparison.Rmd | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/minimization_randomization_comparison.Rmd index 255618b..11ebbe1 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/minimization_randomization_comparison.Rmd @@ -189,7 +189,7 @@ data <- ```{r, data-show} # first 5 rows of the data -data[1:5, 2:ncol(data)] |> +head(data, 5) |> gt() ``` @@ -234,7 +234,7 @@ minimize_equal_weights <- arms = c("armA", "armB", "armC") ) -minimize_equal_weights[1:5, 2:ncol(minimize_equal_weights)] |> +head(minimize_equal_weights, 5) |> gt() ``` @@ -255,7 +255,7 @@ minimize_unequal_weights <- ) ) -minimize_unequal_weights[1:5, 2:ncol(minimize_unequal_weights)] |> +head(minimize_unequal_weights, 5) |> gt() ``` @@ -276,7 +276,7 @@ minimize_unequal_weights_triple <- ) ) -minimize_unequal_weights_triple[1:5, 2:ncol(minimize_unequal_weights_triple)] |> +head(minimize_unequal_weights_triple, 5) |> gt() ``` @@ -358,7 +358,7 @@ simple_data <- ratio = c("armB" = 1L, "armA" = 1L, "armC" = 1L) ) -simple_data[1:5, 2:ncol(simple_data)] |> +head(simple_data, 5) |> gt() ``` @@ -456,7 +456,7 @@ set.seed(123) block_data <- block_results(data) -block_data[1:5, 2:ncol(block_data)] |> +head(block_data, 5) |> gt() ``` From 916f6db5b2eb8eed3e2ecbb42e5140fc8de536db Mon Sep 17 00:00:00 2001 From: Jagoda Date: Thu, 8 Feb 2024 10:48:34 +0100 Subject: [PATCH 171/240] first part of README --- DESCRIPTION | 2 + README.md | 145 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index e1ea907..78894ef 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,6 +10,8 @@ Authors@R: c( role = c("aut")), person("Łukasz", "Wałejko", , "lukasz.walejko@ttsi.com.pl", role = c("aut")), + person("Jagoda", "Głowacka-Walas", "jagoda.glowacka-walas@ttsi.com.pl", + role = c("aut"), comment = c(ORCID = "0000-0002-7628-8691")), person("Michał", "Seweryn", , "michal.seweryn@biol.uni.lodz.pl", role = c("ctr"), comment = c(ORCID = "0000-0002-9090-3435")), person("Transition Technologies Science Sp. z o.o.", role = c("fnd", "cph")) diff --git a/README.md b/README.md index 5eadab3..c53bd9e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,95 @@ -# unbiased -API for clinical trial randomization +# **unbiased**: An R package for Clinical Trial Randomization -## Configuration +The challenge of allocating participants fairly and efficiently is a cornerstone for the success of clinical trials. Recognizing this critical need, we developed the **unbiased** package. This tool is designed to offer a comprehensive suite of randomization algorithms, suitable for a wide range of clinical trial designs. -The Unbiased API server can be configured using environment variables. The following environment variables need to be set for the server to start: +## Why choose **unbiased**? + +Our goal in creating **unbiased** was to provide a user-friendly yet powerful tool that addresses the nuanced demands of clinical trial randomization. It offers: + +- **Ease of Integration**: Designed to fit effortlessly into your research workflow. +- **Adaptability**: Whether for small-scale studies or large, multi-center trials, **unbiased** scales to meet your needs. +- **Comprehensive Documentation**: To support you in applying the package effectively. + +By choosing **unbiased**, you're adopting a sophisticated approach to trial randomization, ensuring fair and efficient participant allocation across your studies. + +## Core features + +The **unbiased** package integrates dynamic and traditional randomization methods, including: + +- **Minimization Method**: For balanced allocation considering covariates. +- **Simple Randomization**: For straightforward, unbiased participant assignment. +- **Block Randomization**: To ensure equal group sizes throughout the trial. + +Available both as a standard R package and through an API, **unbiased** provides flexibility for researchers. It ensures seamless integration with electronic Case Report Form (eCRF) systems, facilitating efficient patient management. + +## Table of Contents +1. [Background](#background) + - [Purpose and Scope for Clinical Trial Randomization](#purpose-and-scope-for-clinical-trial-randomization) + - [Comparative Analysis of Randomization Methods](#comparative-analysis-of-randomization-methods) + - [Comparison with other solutions](#comparison-with-other-solutions) +2. [Installation](#installation) + - [Installation Instructions](#installation-instructions) + - [Deploying the API](#deploying-the-api) +4. [Getting Started](#getting-started) + - [Quickstart Guide](#quickstart-guide) + - [Basic Usage Examples](#basic-usage-examples) +3. [Technical Implementation](#technical-implementation) + - [Quality Assurance Measures](#quality-assurance-measures) + +# Background + +## Purpose and Scope for Clinical Trial Randomization + +Randomization is a fundamental aspect of clinical trials, ensuring that participants are allocated to treatment groups in an unbiased manner. This is essential for maintaining the integrity of the trial and ensuring that the results are reliable. The primary goal of randomization is to minimize the potential for bias and confounding factors that could affect the outcome of the trial. + +The **unbiased** package provides a comprehensive suite of randomization algorithms to support a wide range of clinical trial designs. It is designed to be flexible and adaptable, allowing researchers to select the most appropriate randomization method for their specific study. + +## Comparative Analysis of Randomization Methods + +(Ola - skrócona wersja z winietki, może obrazki?) + +The **unbiased** package offers a range of randomization methods, each with its own strengths and limitations. The choice of randomization method will depend on the specific requirements of the trial, including the number of treatment groups, the size of the trial, and the need for stratification or minimization. + +The **unbiased** package includes the following randomization methods: + +- **Simple Randomization**: This is the most basic form of randomization, in which participants are assigned to treatment groups with equal probability. This method is simple and easy to implement, but it does not account for any potential imbalances in baseline characteristics between treatment groups. + +- **Block Randomization**: This method involves dividing participants into blocks and then randomly assigning them to treatment groups within each block. This ensures that the number of participants in each treatment group is balanced over time, but it does not account for any potential imbalances in baseline characteristics between treatment groups. + +- **Minimization Method**: This method is designed to minimize imbalances in baseline characteristics between treatment groups. It uses an adaptive algorithm to assign participants to treatment groups based on their baseline characteristics, with the goal of achieving balance across treatment groups. + +... + +To find out more, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/minimization_randomization_comparison.Rmd). + +## Comparison with other solutions + +(Ola - randpack, others...?) + + +# Getting Started + +Initiating your work with **unbiased** involves simple setup steps. Whether you're integrating it into your R environment or deploying its API, we provide detailed instructions and examples to facilitate a smooth start. We aim to equip you with a reliable tool that enhances the integrity and efficiency of your clinical trials. + +## Installation + +The **unbiased** package can be installed from GitHub using the `devtools` package. To install **unbiased**, run the following command in your R environment: + +```R +devtools::install_github("ttscience/unbiased") +``` + +## Deploying the API + +Execute the API by calling the`run_unbiased()` function: +```R +unbiased::run_unbiased() +``` +After running this command, the API should be up and running, as default listening on a port on your localhost (http://localhost:3838). You can interact with the API using any HTTP client, such as curl in the command line, Postman, or directly from R using packages like httr. + +## API configuration + +The **unbiased** API server can be configured using environment variables. The following environment variables need to be set for the server to start: - `POSTGRES_DB`: The name of the PostgreSQL database to connect to. - `POSTGRES_HOST`: The host of the PostgreSQL database. This could be a hostname, such as `localhost` or `database.example.com`, or an IP address. @@ -13,6 +99,53 @@ The Unbiased API server can be configured using environment variables. The follo - `UNBIASED_HOST`: The host on which the API will run. Defaults to `0.0.0.0` if not provided. - `UNBIASED_PORT`: The port on which the API will listen. Defaults to `3838` if not provided. +# Use Cases + +## Using randomization functions within R + +The **unbiased** package provides a set of functions that can be used to perform randomization within R. These functions can be used to assign participants to treatment groups in a clinical trial, ensuring that the randomization process is unbiased and transparent. + + +### Simple randomization + +```R +# Load the unbiased package +library(unbiased) + +# Create a data frame with participant IDs and treatment group assignments +participants <- data.frame( + id = 1:100, + treatment_group = simple_randomization(100, 2) +) + +``` + +### Minimization method + +The minimization method function provided by **unbiased** assume that there is a study initialized and the previous patients assigments is stored in the dataframe/database. The functions will then use this data to assign new participant to treatment groups in a way that minimizes the potential for bias and confounding factors. If the data is not available (e.g. when first patient is randomized), he will be randomly assigned to a treatment group. + +```R +# Load the unbiased package +library(unbiased) + +# Create a data frame with participant IDs and treatment group assignments +participants <- data.frame( + id = 1:100, + treatment_group = minimization_method( + 100, + 2, + covariates = c("age + )) +``` + +## API endpoints + +### Study creation + +### Patient randomization + +# Technical details + ## Running Tests Unbiased provides an extensive collection of tests to ensure correct functionality. @@ -23,7 +156,7 @@ To execute tests using an interactive R session, run the following commands: ```R devtools::load_all() -testthat::test_package("unbiased") +testthat::test_package(**unbiased**) ``` Make sure that `devtools` package is installed in your environment. @@ -54,4 +187,4 @@ To calculate code coverage, you will need to install the `covr` package. Once in - `covr::report()`: This method runs all tests and generates a detailed coverage report in HTML format. - `covr::package_coverage()`: This method provides a simpler, text-based code coverage report. -Alternatively, you can use the provided `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. \ No newline at end of file +Alternatively, you can use the provided `run_tests_with_coverage.sh` script to run Unbiased tests with code coverage. From 3d8d65b2d0e9dc270a6789fbe1baa2265c89fecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 8 Feb 2024 10:11:17 +0000 Subject: [PATCH 172/240] redirect stdout and stderr to temporary files during test run --- tests/testthat/setup-testing-environment.R | 24 +++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index c09cd7b..fefd381 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -219,6 +219,16 @@ withr::local_envvar( ) ) +stdout_file <- withr::local_tempfile( + fileext = ".log", + .local_envir = teardown_env() +) + +stderr_file <- withr::local_tempfile( + fileext = ".log", + .local_envir = teardown_env() +) + plumber_process <- callr::r_bg( \() { if (!requireNamespace("unbiased", quietly = TRUE)) { @@ -232,19 +242,19 @@ plumber_process <- callr::r_bg( unbiased:::run_unbiased() }, - supervise = TRUE + supervise = TRUE, + stdout = stdout_file, + stderr = stderr_file, ) withr::defer( { print("Server STDOUT:") - while (length(lines <- plumber_process$read_output_lines())) { - writeLines(lines) - } + lines <- readLines(stdout_file) + writeLines(lines) print("Server STDERR:") - while (length(lines <- plumber_process$read_error_lines())) { - writeLines(lines) - } + lines <- readLines(stderr_file) + writeLines(lines) print("Sending SIGINT to plumber process") plumber_process$interrupt() From 8423d15c85c001faf786ddb096991f887ca7fac3 Mon Sep 17 00:00:00 2001 From: jagoda Date: Thu, 8 Feb 2024 10:20:55 +0000 Subject: [PATCH 173/240] vignette as article --- vignettes/.gitignore | 2 - vignettes/{ => articles}/1000_sim_data.Rds | Bin vignettes/{ => articles}/helpers/functions.R | 0 .../{ => articles}/helpers/run_parallel.R | 0 .../minimization_randomization_comparison.Rmd | 9 +- vignettes/{ => articles}/references.bib | 0 vignettes/renv.lock | 2959 ----------------- 7 files changed, 2 insertions(+), 2968 deletions(-) delete mode 100644 vignettes/.gitignore rename vignettes/{ => articles}/1000_sim_data.Rds (100%) rename vignettes/{ => articles}/helpers/functions.R (100%) rename vignettes/{ => articles}/helpers/run_parallel.R (100%) rename vignettes/{ => articles}/minimization_randomization_comparison.Rmd (99%) rename vignettes/{ => articles}/references.bib (100%) delete mode 100644 vignettes/renv.lock diff --git a/vignettes/.gitignore b/vignettes/.gitignore deleted file mode 100644 index 3432c3f..0000000 --- a/vignettes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.html - diff --git a/vignettes/1000_sim_data.Rds b/vignettes/articles/1000_sim_data.Rds similarity index 100% rename from vignettes/1000_sim_data.Rds rename to vignettes/articles/1000_sim_data.Rds diff --git a/vignettes/helpers/functions.R b/vignettes/articles/helpers/functions.R similarity index 100% rename from vignettes/helpers/functions.R rename to vignettes/articles/helpers/functions.R diff --git a/vignettes/helpers/run_parallel.R b/vignettes/articles/helpers/run_parallel.R similarity index 100% rename from vignettes/helpers/run_parallel.R rename to vignettes/articles/helpers/run_parallel.R diff --git a/vignettes/minimization_randomization_comparison.Rmd b/vignettes/articles/minimization_randomization_comparison.Rmd similarity index 99% rename from vignettes/minimization_randomization_comparison.Rmd rename to vignettes/articles/minimization_randomization_comparison.Rmd index 11ebbe1..12c197d 100644 --- a/vignettes/minimization_randomization_comparison.Rmd +++ b/vignettes/articles/minimization_randomization_comparison.Rmd @@ -4,12 +4,8 @@ author: - Aleksandra Duda, Jagoda Głowacka-Walas^[Tranistion Technologies Science] date: "`r Sys.Date()`" output: - html_vignette: + html_document: toc: yes -vignette: > - %\VignetteIndexEntry{Comparison of Minimization Randomization with Other Randomization Methods. Assessing the balance of covariates.} - %\VignetteEngine{knitr::rmarkdown} - %\VignetteEncoding{UTF-8} bibliography: references.bib link-citations: true --- @@ -36,7 +32,6 @@ The document structure is as follows: first, based on the defined parameters, da # load packages library(unbiased) library(dplyr) -library(devtools) library(simstudy) library(tableone) library(ggplot2) @@ -479,7 +474,7 @@ These data were assigned to the variable `sim_data` based on the data stored in # source("~/unbiased/vignettes/helpers/run_parallel.R") # read data from file -sim_data <- readRDS("~/unbiased/vignettes/1000_sim_data.Rds") +sim_data <- readRDS("1000_sim_data.Rds") ``` ## Check balance using smd test diff --git a/vignettes/references.bib b/vignettes/articles/references.bib similarity index 100% rename from vignettes/references.bib rename to vignettes/articles/references.bib diff --git a/vignettes/renv.lock b/vignettes/renv.lock deleted file mode 100644 index 07ee0a4..0000000 --- a/vignettes/renv.lock +++ /dev/null @@ -1,2959 +0,0 @@ -{ - "R": { - "Version": "4.2.3", - "Repositories": [ - { - "Name": "CRAN", - "URL": "https://packagemanager.posit.co/cran/latest" - } - ] - }, - "Packages": { - "BH": { - "Package": "BH", - "Version": "1.81.0-1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "68122010f01c4dcfbe58ce7112f2433d" - }, - "DBI": { - "Package": "DBI", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "3e0051431dff9acfe66c23765e55c556" - }, - "MASS": { - "Package": "MASS", - "Version": "7.3-57", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "methods", - "stats", - "utils" - ], - "Hash": "71476c1d88d1ebdf31580e5a257d5d31" - }, - "Matrix": { - "Package": "Matrix", - "Version": "1.4-1", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "grid", - "lattice", - "methods", - "stats", - "utils" - ], - "Hash": "699c47c606293bdfbc9fd78a93c9c8fe" - }, - "PwrGSD": { - "Package": "PwrGSD", - "Version": "2.3.6", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "survival" - ], - "Hash": "c26126e59b9b078953521379ee219a05" - }, - "R6": { - "Package": "R6", - "Version": "2.5.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "470851b6d5d0ac559e9d01bb352b4021" - }, - "RColorBrewer": { - "Package": "RColorBrewer", - "Version": "1.1-3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "45f0398006e83a5b10b72a90663d8d8c" - }, - "Rcpp": { - "Package": "Rcpp", - "Version": "1.0.11", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods", - "utils" - ], - "Hash": "ae6cbbe1492f4de79c45fce06f967ce8" - }, - "RcppArmadillo": { - "Package": "RcppArmadillo", - "Version": "0.12.6.6.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp", - "methods", - "stats", - "utils" - ], - "Hash": "d2b60e0a15d73182a3a766ff0a7d0d7f" - }, - "RcppEigen": { - "Package": "RcppEigen", - "Version": "0.3.3.9.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp", - "stats", - "utils" - ], - "Hash": "acb0a5bf38490f26ab8661b467f4f53a" - }, - "TH.data": { - "Package": "TH.data", - "Version": "1.1-2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "MASS", - "R", - "survival" - ], - "Hash": "5b250ad4c5863ee4a68e280fcb0a3600" - }, - "V8": { - "Package": "V8", - "Version": "4.4.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "Rcpp", - "curl", - "jsonlite", - "utils" - ], - "Hash": "435359b59b8a9b8f9235135da471ea3c" - }, - "askpass": { - "Package": "askpass", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "sys" - ], - "Hash": "cad6cf7f1d5f6e906700b9d3e718c796" - }, - "backports": { - "Package": "backports", - "Version": "1.4.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "c39fbec8a30d23e721980b8afb31984c" - }, - "base64enc": { - "Package": "base64enc", - "Version": "0.1-3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "543776ae6848fde2f48ff3816d0628bc" - }, - "bigD": { - "Package": "bigD", - "Version": "0.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "93637e906f3fe962413912c956eb44db" - }, - "bigmemory": { - "Package": "bigmemory", - "Version": "4.6.2", - "Source": "GitHub", - "RemoteType": "github", - "RemoteHost": "api.github.com", - "RemoteRepo": "bigmemory", - "RemoteUsername": "kaneplusplus", - "RemoteRef": "HEAD", - "RemoteSha": "3064277f4a83b74490464ea4ac5a43f76e426ada", - "Requirements": [ - "BH", - "R", - "Rcpp", - "bigmemory.sri", - "methods", - "utils", - "uuid" - ], - "Hash": "65fe01c6e8e22c8bd0c6f5b5e3ccf19e" - }, - "bigmemory.sri": { - "Package": "bigmemory.sri", - "Version": "0.1.6", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods" - ], - "Hash": "cd3e474a907284c598e60417a5edeb79" - }, - "bit": { - "Package": "bit", - "Version": "4.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "d242abec29412ce988848d0294b208fd" - }, - "bit64": { - "Package": "bit64", - "Version": "4.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "bit", - "methods", - "stats", - "utils" - ], - "Hash": "9fe98599ca456d6552421db0d6772d8f" - }, - "bitops": { - "Package": "bitops", - "Version": "1.0-7", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "b7d8d8ee39869c18d8846a184dd8a1af" - }, - "blob": { - "Package": "blob", - "Version": "1.2.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods", - "rlang", - "vctrs" - ], - "Hash": "40415719b5a479b87949f3aa0aee737c" - }, - "brew": { - "Package": "brew", - "Version": "1.0-10", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "8f4a384e19dccd8c65356dc096847b76" - }, - "brio": { - "Package": "brio", - "Version": "1.1.3", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "976cf154dfb043c012d87cddd8bca363" - }, - "broom": { - "Package": "broom", - "Version": "1.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "backports", - "dplyr", - "ellipsis", - "generics", - "glue", - "lifecycle", - "purrr", - "rlang", - "stringr", - "tibble", - "tidyr" - ], - "Hash": "fd25391c3c4f6ecf0fa95f1e6d15378c" - }, - "broom.helpers": { - "Package": "broom.helpers", - "Version": "1.14.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "cli", - "dplyr", - "labelled", - "lifecycle", - "purrr", - "rlang", - "stats", - "stringr", - "tibble", - "tidyr" - ], - "Hash": "ea30eb5d9412a4a5c2740685f680cd49" - }, - "bslib": { - "Package": "bslib", - "Version": "0.6.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "base64enc", - "cachem", - "grDevices", - "htmltools", - "jquerylib", - "jsonlite", - "lifecycle", - "memoise", - "mime", - "rlang", - "sass" - ], - "Hash": "c0d8599494bc7fb408cd206bbdd9cab0" - }, - "cachem": { - "Package": "cachem", - "Version": "1.0.8", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "fastmap", - "rlang" - ], - "Hash": "c35768291560ce302c0a6589f92e837d" - }, - "callr": { - "Package": "callr", - "Version": "3.7.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "processx", - "utils" - ], - "Hash": "9b2191ede20fa29828139b9900922e51" - }, - "cellranger": { - "Package": "cellranger", - "Version": "1.1.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "rematch", - "tibble" - ], - "Hash": "f61dbaec772ccd2e17705c1e872e9e7c" - }, - "checkmate": { - "Package": "checkmate", - "Version": "2.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "backports", - "utils" - ], - "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" - }, - "class": { - "Package": "class", - "Version": "7.3-20", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "MASS", - "R", - "stats", - "utils" - ], - "Hash": "da09d82223e669d270e47ed24ac8686e" - }, - "cli": { - "Package": "cli", - "Version": "3.6.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "utils" - ], - "Hash": "89e6d8219950eac806ae0c489052048a" - }, - "clipr": { - "Package": "clipr", - "Version": "0.8.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "utils" - ], - "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" - }, - "codetools": { - "Package": "codetools", - "Version": "0.2-18", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R" - ], - "Hash": "019388fc48e48b3da0d3a76ff94608a8" - }, - "coin": { - "Package": "coin", - "Version": "1.4-3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "libcoin", - "matrixStats", - "methods", - "modeltools", - "multcomp", - "mvtnorm", - "parallel", - "stats", - "stats4", - "survival", - "utils" - ], - "Hash": "4084b5070a40ad99dad581ed3b67bd55" - }, - "colorspace": { - "Package": "colorspace", - "Version": "2.1-0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "graphics", - "methods", - "stats" - ], - "Hash": "f20c47fd52fae58b4e377c37bb8c335b" - }, - "commonmark": { - "Package": "commonmark", - "Version": "1.9.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "d691c61bff84bd63c383874d2d0c3307" - }, - "conflicted": { - "Package": "conflicted", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "memoise", - "rlang" - ], - "Hash": "bb097fccb22d156624fd07cd2894ddb6" - }, - "cpp11": { - "Package": "cpp11", - "Version": "0.4.6", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "707fae4bbf73697ec8d85f9d7076c061" - }, - "crayon": { - "Package": "crayon", - "Version": "1.5.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "grDevices", - "methods", - "utils" - ], - "Hash": "e8a1e41acf02548751f45c718d55aa6a" - }, - "credentials": { - "Package": "credentials", - "Version": "2.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "askpass", - "curl", - "jsonlite", - "openssl", - "sys" - ], - "Hash": "c7844b32098dcbd1c59cbd8dddb4ecc6" - }, - "curl": { - "Package": "curl", - "Version": "5.1.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" - }, - "data.table": { - "Package": "data.table", - "Version": "1.14.10", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "6ea17a32294d8ca00455825ab0cf71b9" - }, - "dbplyr": { - "Package": "dbplyr", - "Version": "2.4.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "DBI", - "R", - "R6", - "blob", - "cli", - "dplyr", - "glue", - "lifecycle", - "magrittr", - "methods", - "pillar", - "purrr", - "rlang", - "tibble", - "tidyr", - "tidyselect", - "utils", - "vctrs", - "withr" - ], - "Hash": "59351f28a81f0742720b85363c4fdd61" - }, - "desc": { - "Package": "desc", - "Version": "1.4.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "cli", - "rprojroot", - "utils" - ], - "Hash": "6b9602c7ebbe87101a9c8edb6e8b6d21" - }, - "devtools": { - "Package": "devtools", - "Version": "2.4.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "desc", - "ellipsis", - "fs", - "lifecycle", - "memoise", - "miniUI", - "pkgbuild", - "pkgdown", - "pkgload", - "profvis", - "rcmdcheck", - "remotes", - "rlang", - "roxygen2", - "rversions", - "sessioninfo", - "stats", - "testthat", - "tools", - "urlchecker", - "usethis", - "utils", - "withr" - ], - "Hash": "ea5bc8b4a6a01e4f12d98b58329930bb" - }, - "diffobj": { - "Package": "diffobj", - "Version": "0.3.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "crayon", - "methods", - "stats", - "tools", - "utils" - ], - "Hash": "bcaa8b95f8d7d01a5dedfd959ce88ab8" - }, - "digest": { - "Package": "digest", - "Version": "0.6.33", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "utils" - ], - "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" - }, - "downlit": { - "Package": "downlit", - "Version": "0.4.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "brio", - "desc", - "digest", - "evaluate", - "fansi", - "memoise", - "rlang", - "vctrs", - "withr", - "yaml" - ], - "Hash": "14fa1f248b60ed67e1f5418391a17b14" - }, - "dplyr": { - "Package": "dplyr", - "Version": "1.1.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "cli", - "generics", - "glue", - "lifecycle", - "magrittr", - "methods", - "pillar", - "rlang", - "tibble", - "tidyselect", - "utils", - "vctrs" - ], - "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" - }, - "dtplyr": { - "Package": "dtplyr", - "Version": "1.3.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "data.table", - "dplyr", - "glue", - "lifecycle", - "rlang", - "tibble", - "tidyselect", - "vctrs" - ], - "Hash": "54ed3ea01b11e81a86544faaecfef8e2" - }, - "e1071": { - "Package": "e1071", - "Version": "1.7-14", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "class", - "grDevices", - "graphics", - "methods", - "proxy", - "stats", - "utils" - ], - "Hash": "4ef372b716824753719a8a38b258442d" - }, - "ellipsis": { - "Package": "ellipsis", - "Version": "0.3.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "rlang" - ], - "Hash": "bb0eec2fe32e88d9e2836c2f73ea2077" - }, - "evaluate": { - "Package": "evaluate", - "Version": "0.22", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "66f39c7a21e03c4dcb2c2d21d738d603" - }, - "fansi": { - "Package": "fansi", - "Version": "1.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "utils" - ], - "Hash": "3e8583a60163b4bc1a80016e63b9959e" - }, - "farver": { - "Package": "farver", - "Version": "2.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "8106d78941f34855c440ddb946b8f7a5" - }, - "fastglm": { - "Package": "fastglm", - "Version": "0.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "BH", - "Rcpp", - "RcppEigen", - "bigmemory", - "methods" - ], - "Hash": "e0f222ad320efdaa48ebf88eb576bb21" - }, - "fastmap": { - "Package": "fastmap", - "Version": "1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "f7736a18de97dea803bde0a2daaafb27" - }, - "fontawesome": { - "Package": "fontawesome", - "Version": "0.5.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "htmltools", - "rlang" - ], - "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d" - }, - "forcats": { - "Package": "forcats", - "Version": "1.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "lifecycle", - "magrittr", - "rlang", - "tibble" - ], - "Hash": "1a0a9a3d5083d0d573c4214576f1e690" - }, - "fs": { - "Package": "fs", - "Version": "1.6.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "47b5f30c720c23999b913a1a635cf0bb" - }, - "gargle": { - "Package": "gargle", - "Version": "1.5.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "fs", - "glue", - "httr", - "jsonlite", - "lifecycle", - "openssl", - "rappdirs", - "rlang", - "stats", - "utils", - "withr" - ], - "Hash": "fc0b272e5847c58cd5da9b20eedbd026" - }, - "gdata": { - "Package": "gdata", - "Version": "3.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "gtools", - "methods", - "stats", - "utils" - ], - "Hash": "d3d6e4c174b8a5f251fd273f245f2471" - }, - "generics": { - "Package": "generics", - "Version": "0.1.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "15e9634c0fcd294799e9b2e929ed1b86" - }, - "gert": { - "Package": "gert", - "Version": "2.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "askpass", - "credentials", - "openssl", - "rstudioapi", - "sys", - "zip" - ], - "Hash": "f70d3fe2d9e7654213a946963d1591eb" - }, - "ggplot2": { - "Package": "ggplot2", - "Version": "3.4.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "MASS", - "R", - "cli", - "glue", - "grDevices", - "grid", - "gtable", - "isoband", - "lifecycle", - "mgcv", - "rlang", - "scales", - "stats", - "tibble", - "vctrs", - "withr" - ], - "Hash": "313d31eff2274ecf4c1d3581db7241f9" - }, - "gh": { - "Package": "gh", - "Version": "1.4.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "gitcreds", - "httr2", - "ini", - "jsonlite", - "rlang" - ], - "Hash": "03533b1c875028233598f848fda44c4c" - }, - "gitcreds": { - "Package": "gitcreds", - "Version": "0.1.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "ab08ac61f3e1be454ae21911eb8bc2fe" - }, - "glue": { - "Package": "glue", - "Version": "1.6.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods" - ], - "Hash": "4f2596dfb05dac67b9dc558e5c6fba2e" - }, - "gmodels": { - "Package": "gmodels", - "Version": "2.18.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "MASS", - "R", - "gdata" - ], - "Hash": "6713a242cb6909e492d8169a35dfe0b0" - }, - "googledrive": { - "Package": "googledrive", - "Version": "2.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "gargle", - "glue", - "httr", - "jsonlite", - "lifecycle", - "magrittr", - "pillar", - "purrr", - "rlang", - "tibble", - "utils", - "uuid", - "vctrs", - "withr" - ], - "Hash": "e99641edef03e2a5e87f0a0b1fcc97f4" - }, - "googlesheets4": { - "Package": "googlesheets4", - "Version": "1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cellranger", - "cli", - "curl", - "gargle", - "glue", - "googledrive", - "httr", - "ids", - "lifecycle", - "magrittr", - "methods", - "purrr", - "rematch2", - "rlang", - "tibble", - "utils", - "vctrs", - "withr" - ], - "Hash": "d6db1667059d027da730decdc214b959" - }, - "gsDesign": { - "Package": "gsDesign", - "Version": "3.6.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "dplyr", - "ggplot2", - "graphics", - "gt", - "magrittr", - "methods", - "r2rtf", - "rlang", - "stats", - "tibble", - "tidyr", - "tools", - "xtable" - ], - "Hash": "496b38bfc6524e1a1fc04220da550892" - }, - "gt": { - "Package": "gt", - "Version": "0.10.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "base64enc", - "bigD", - "bitops", - "cli", - "commonmark", - "dplyr", - "fs", - "glue", - "htmltools", - "htmlwidgets", - "juicyjuice", - "magrittr", - "markdown", - "reactable", - "rlang", - "sass", - "scales", - "tibble", - "tidyselect", - "xml2" - ], - "Hash": "21737c74811cccac01b5097bcb0f8b4c" - }, - "gtable": { - "Package": "gtable", - "Version": "0.3.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "grid", - "lifecycle", - "rlang" - ], - "Hash": "b29cf3031f49b04ab9c852c912547eef" - }, - "gtools": { - "Package": "gtools", - "Version": "3.9.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods", - "stats", - "utils" - ], - "Hash": "588d091c35389f1f4a9d533c8d709b35" - }, - "gtsummary": { - "Package": "gtsummary", - "Version": "1.7.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "broom.helpers", - "cli", - "dplyr", - "forcats", - "glue", - "gt", - "knitr", - "lifecycle", - "purrr", - "rlang", - "stringr", - "tibble", - "tidyr", - "vctrs" - ], - "Hash": "08df7405a102e3f0bdf7a13a29e8c6ab" - }, - "haven": { - "Package": "haven", - "Version": "2.5.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "cpp11", - "forcats", - "hms", - "lifecycle", - "methods", - "readr", - "rlang", - "tibble", - "tidyselect", - "vctrs" - ], - "Hash": "9171f898db9d9c4c1b2c745adc2c1ef1" - }, - "highr": { - "Package": "highr", - "Version": "0.10", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "xfun" - ], - "Hash": "06230136b2d2b9ba5805e1963fa6e890" - }, - "hms": { - "Package": "hms", - "Version": "1.1.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "lifecycle", - "methods", - "pkgconfig", - "rlang", - "vctrs" - ], - "Hash": "b59377caa7ed00fa41808342002138f9" - }, - "htmltools": { - "Package": "htmltools", - "Version": "0.5.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "base64enc", - "digest", - "ellipsis", - "fastmap", - "grDevices", - "rlang", - "utils" - ], - "Hash": "2d7b3857980e0e0d0a1fd6f11928ab0f" - }, - "htmlwidgets": { - "Package": "htmlwidgets", - "Version": "1.6.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "grDevices", - "htmltools", - "jsonlite", - "knitr", - "rmarkdown", - "yaml" - ], - "Hash": "04291cc45198225444a397606810ac37" - }, - "httpuv": { - "Package": "httpuv", - "Version": "1.6.11", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "Rcpp", - "later", - "promises", - "utils" - ], - "Hash": "838602f54e32c1a0f8cc80708cefcefa" - }, - "httr": { - "Package": "httr", - "Version": "1.4.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "curl", - "jsonlite", - "mime", - "openssl" - ], - "Hash": "ac107251d9d9fd72f0ca8049988f1d7f" - }, - "httr2": { - "Package": "httr2", - "Version": "1.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "cli", - "curl", - "glue", - "lifecycle", - "magrittr", - "openssl", - "rappdirs", - "rlang", - "vctrs", - "withr" - ], - "Hash": "e2b30f1fc039a0bab047dd52bb20ef71" - }, - "ids": { - "Package": "ids", - "Version": "1.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "openssl", - "uuid" - ], - "Hash": "99df65cfef20e525ed38c3d2577f7190" - }, - "ini": { - "Package": "ini", - "Version": "0.3.1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "6154ec2223172bce8162d4153cda21f7" - }, - "insight": { - "Package": "insight", - "Version": "0.19.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods", - "stats", - "utils" - ], - "Hash": "750aba9b42391da33ac290b71a749023" - }, - "isoband": { - "Package": "isoband", - "Version": "0.2.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "grid", - "utils" - ], - "Hash": "0080607b4a1a7b28979aecef976d8bc2" - }, - "jquerylib": { - "Package": "jquerylib", - "Version": "0.1.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "htmltools" - ], - "Hash": "5aab57a3bd297eee1c1d862735972182" - }, - "jsonlite": { - "Package": "jsonlite", - "Version": "1.8.8", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods" - ], - "Hash": "e1b9c55281c5adc4dd113652d9e26768" - }, - "juicyjuice": { - "Package": "juicyjuice", - "Version": "0.1.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "V8" - ], - "Hash": "3bcd11943da509341838da9399e18bce" - }, - "knitr": { - "Package": "knitr", - "Version": "1.45", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "evaluate", - "highr", - "methods", - "tools", - "xfun", - "yaml" - ], - "Hash": "1ec462871063897135c1bcbe0fc8f07d" - }, - "labeling": { - "Package": "labeling", - "Version": "0.4.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "graphics", - "stats" - ], - "Hash": "b64ec208ac5bc1852b285f665d6368b3" - }, - "labelled": { - "Package": "labelled", - "Version": "2.12.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "dplyr", - "haven", - "lifecycle", - "rlang", - "stringr", - "tidyr", - "vctrs" - ], - "Hash": "1ec27c624ece6c20431e9249bd232797" - }, - "later": { - "Package": "later", - "Version": "1.3.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "Rcpp", - "rlang" - ], - "Hash": "40401c9cf2bc2259dfe83311c9384710" - }, - "lattice": { - "Package": "lattice", - "Version": "0.20-45", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "grDevices", - "graphics", - "grid", - "stats", - "utils" - ], - "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" - }, - "libcoin": { - "Package": "libcoin", - "Version": "1.0-10", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "mvtnorm", - "stats" - ], - "Hash": "3f3775a14588ff5d013e5eab4453bf28" - }, - "lifecycle": { - "Package": "lifecycle", - "Version": "1.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "rlang" - ], - "Hash": "001cecbeac1cff9301bdc3775ee46a86" - }, - "lubridate": { - "Package": "lubridate", - "Version": "1.9.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "generics", - "methods", - "timechange" - ], - "Hash": "680ad542fbcf801442c83a6ac5a2126c" - }, - "magrittr": { - "Package": "magrittr", - "Version": "2.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "7ce2733a9826b3aeb1775d56fd305472" - }, - "markdown": { - "Package": "markdown", - "Version": "1.12", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "commonmark", - "utils", - "xfun" - ], - "Hash": "765cf53992401b3b6c297b69e1edb8bd" - }, - "mathjaxr": { - "Package": "mathjaxr", - "Version": "1.6-0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "87da6ccdcee6077a7d5719406bf3ae45" - }, - "matrixStats": { - "Package": "matrixStats", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "33a3ca9e732b57244d14f5d732ffc9eb" - }, - "memoise": { - "Package": "memoise", - "Version": "2.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "cachem", - "rlang" - ], - "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c" - }, - "mgcv": { - "Package": "mgcv", - "Version": "1.8-40", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "Matrix", - "R", - "graphics", - "methods", - "nlme", - "splines", - "stats", - "utils" - ], - "Hash": "c6b2fdb18cf68ab613bd564363e1ba0d" - }, - "mime": { - "Package": "mime", - "Version": "0.12", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "tools" - ], - "Hash": "18e9c28c1d3ca1560ce30658b22ce104" - }, - "miniUI": { - "Package": "miniUI", - "Version": "0.1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "htmltools", - "shiny", - "utils" - ], - "Hash": "fec5f52652d60615fdb3957b3d74324a" - }, - "minqa": { - "Package": "minqa", - "Version": "1.2.6", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "Rcpp" - ], - "Hash": "f48238f8d4740426ca12f53f27d004dd" - }, - "mitools": { - "Package": "mitools", - "Version": "2.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "DBI", - "methods", - "stats" - ], - "Hash": "a4b659bd0528226724d55034f11ed7cb" - }, - "modelr": { - "Package": "modelr", - "Version": "0.1.11", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "magrittr", - "purrr", - "rlang", - "tibble", - "tidyr", - "tidyselect", - "vctrs" - ], - "Hash": "4f50122dc256b1b6996a4703fecea821" - }, - "modeltools": { - "Package": "modeltools", - "Version": "0.2-23", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "methods", - "stats", - "stats4" - ], - "Hash": "f5a957c02222589bdf625a67be68b2a9" - }, - "mstate": { - "Package": "mstate", - "Version": "0.3.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "RColorBrewer", - "data.table", - "lattice", - "rlang", - "survival", - "viridisLite" - ], - "Hash": "53ca2f4a1ab4ac93fec33c92dc22c886" - }, - "multcomp": { - "Package": "multcomp", - "Version": "1.4-25", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "TH.data", - "codetools", - "graphics", - "mvtnorm", - "sandwich", - "stats", - "survival" - ], - "Hash": "2688bf2f8d54c19534ee7d8a876d9fc7" - }, - "munsell": { - "Package": "munsell", - "Version": "0.5.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "colorspace", - "methods" - ], - "Hash": "6dfe8bf774944bd5595785e3229d8771" - }, - "mvnfast": { - "Package": "mvnfast", - "Version": "0.2.8", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "BH", - "Rcpp", - "RcppArmadillo" - ], - "Hash": "e65cac8e8501bdfbdca0412c37bb18c9" - }, - "mvtnorm": { - "Package": "mvtnorm", - "Version": "1.2-4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "stats" - ], - "Hash": "17e96668f44a28aef0981d9e17c49b59" - }, - "nlme": { - "Package": "nlme", - "Version": "3.1-157", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "graphics", - "lattice", - "stats", - "utils" - ], - "Hash": "dbca60742be0c9eddc5205e5c7ca1f44" - }, - "numDeriv": { - "Package": "numDeriv", - "Version": "2016.8-1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "df58958f293b166e4ab885ebcad90e02" - }, - "openssl": { - "Package": "openssl", - "Version": "2.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "askpass" - ], - "Hash": "2a0dc8c6adfb6f032e4d4af82d258ab5" - }, - "pbv": { - "Package": "pbv", - "Version": "0.5-47", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp", - "RcppArmadillo" - ], - "Hash": "b0fa64575651e261cfa1fdb46025cb44" - }, - "pillar": { - "Package": "pillar", - "Version": "1.9.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "cli", - "fansi", - "glue", - "lifecycle", - "rlang", - "utf8", - "utils", - "vctrs" - ], - "Hash": "15da5a8412f317beeee6175fbc76f4bb" - }, - "pkgbuild": { - "Package": "pkgbuild", - "Version": "1.4.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "callr", - "cli", - "crayon", - "desc", - "prettyunits", - "processx", - "rprojroot" - ], - "Hash": "beb25b32a957a22a5c301a9e441190b3" - }, - "pkgconfig": { - "Package": "pkgconfig", - "Version": "2.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "utils" - ], - "Hash": "01f28d4278f15c76cddbea05899c5d6f" - }, - "pkgdown": { - "Package": "pkgdown", - "Version": "2.0.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "bslib", - "callr", - "cli", - "desc", - "digest", - "downlit", - "fs", - "httr", - "jsonlite", - "magrittr", - "memoise", - "purrr", - "ragg", - "rlang", - "rmarkdown", - "tibble", - "whisker", - "withr", - "xml2", - "yaml" - ], - "Hash": "16fa15449c930bf3a7761d3c68f8abf9" - }, - "pkgload": { - "Package": "pkgload", - "Version": "1.3.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "crayon", - "desc", - "fs", - "glue", - "methods", - "pkgbuild", - "rlang", - "rprojroot", - "utils", - "withr" - ], - "Hash": "903d68319ae9923fb2e2ee7fa8230b91" - }, - "plotrix": { - "Package": "plotrix", - "Version": "3.8-4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "graphics", - "stats", - "utils" - ], - "Hash": "d47fdfc45aeba360ce9db50643de3fbd" - }, - "plumber": { - "Package": "plumber", - "Version": "1.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "crayon", - "ellipsis", - "httpuv", - "jsonlite", - "lifecycle", - "magrittr", - "mime", - "promises", - "rlang", - "sodium", - "stringi", - "swagger", - "webutils" - ], - "Hash": "8b65a7a00ef8edc5ddc6fabf0aff1194" - }, - "plyr": { - "Package": "plyr", - "Version": "1.8.9", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp" - ], - "Hash": "6b8177fd19982f0020743fadbfdbd933" - }, - "praise": { - "Package": "praise", - "Version": "1.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "a555924add98c99d2f411e37e7d25e9f" - }, - "prettyunits": { - "Package": "prettyunits", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "6b01fc98b1e86c4f705ce9dcfd2f57c7" - }, - "processx": { - "Package": "processx", - "Version": "3.8.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "ps", - "utils" - ], - "Hash": "3efbd8ac1be0296a46c55387aeace0f3" - }, - "profvis": { - "Package": "profvis", - "Version": "0.3.8", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "htmlwidgets", - "purrr", - "rlang", - "stringr", - "vctrs" - ], - "Hash": "aa5a3864397ce6ae03458f98618395a1" - }, - "progress": { - "Package": "progress", - "Version": "1.2.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "crayon", - "hms", - "prettyunits" - ], - "Hash": "f4625e061cb2865f111b47ff163a5ca6" - }, - "promises": { - "Package": "promises", - "Version": "1.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R6", - "Rcpp", - "fastmap", - "later", - "magrittr", - "rlang", - "stats" - ], - "Hash": "0d8a15c9d000970ada1ab21405387dee" - }, - "proxy": { - "Package": "proxy", - "Version": "0.4-27", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "stats", - "utils" - ], - "Hash": "e0ef355c12942cf7a6b91a6cfaea8b3e" - }, - "ps": { - "Package": "ps", - "Version": "1.7.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "utils" - ], - "Hash": "709d852d33178db54b17c722e5b1e594" - }, - "purrr": { - "Package": "purrr", - "Version": "1.0.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "lifecycle", - "magrittr", - "rlang", - "vctrs" - ], - "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc" - }, - "r2rtf": { - "Package": "r2rtf", - "Version": "1.1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "tools" - ], - "Hash": "807989b4dccfab6440841a5e8aaa95f1" - }, - "ragg": { - "Package": "ragg", - "Version": "1.2.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "systemfonts", - "textshaping" - ], - "Hash": "90a1b8b7e518d7f90480d56453b4d062" - }, - "randomizeR": { - "Package": "randomizeR", - "Version": "3.0.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "PwrGSD", - "R", - "coin", - "dplyr", - "ggplot2", - "gsDesign", - "insight", - "magrittr", - "methods", - "mstate", - "mvtnorm", - "plotrix", - "purrr", - "reshape2", - "rlang", - "survival" - ], - "Hash": "d22309ab2b609eb233d4b2e931dad265" - }, - "rappdirs": { - "Package": "rappdirs", - "Version": "0.3.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "5e3c5dc0b071b21fa128676560dbe94d" - }, - "rcmdcheck": { - "Package": "rcmdcheck", - "Version": "1.4.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R6", - "callr", - "cli", - "curl", - "desc", - "digest", - "pkgbuild", - "prettyunits", - "rprojroot", - "sessioninfo", - "utils", - "withr", - "xopen" - ], - "Hash": "8f25ebe2ec38b1f2aef3b0d2ef76f6c4" - }, - "reactR": { - "Package": "reactR", - "Version": "0.5.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "htmltools" - ], - "Hash": "c9014fd1a435b2d790dd506589cb24e5" - }, - "reactable": { - "Package": "reactable", - "Version": "0.4.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "digest", - "htmltools", - "htmlwidgets", - "jsonlite", - "reactR" - ], - "Hash": "6069eb2a6597963eae0605c1875ff14c" - }, - "readr": { - "Package": "readr", - "Version": "2.1.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "cli", - "clipr", - "cpp11", - "crayon", - "hms", - "lifecycle", - "methods", - "rlang", - "tibble", - "tzdb", - "utils", - "vroom" - ], - "Hash": "b5047343b3825f37ad9d3b5d89aa1078" - }, - "readxl": { - "Package": "readxl", - "Version": "1.4.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cellranger", - "cpp11", - "progress", - "tibble", - "utils" - ], - "Hash": "8cf9c239b96df1bbb133b74aef77ad0a" - }, - "rematch": { - "Package": "rematch", - "Version": "2.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "cbff1b666c6fa6d21202f07e2318d4f1" - }, - "rematch2": { - "Package": "rematch2", - "Version": "2.1.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "tibble" - ], - "Hash": "76c9e04c712a05848ae7a23d2f170a40" - }, - "remotes": { - "Package": "remotes", - "Version": "2.4.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "methods", - "stats", - "tools", - "utils" - ], - "Hash": "63d15047eb239f95160112bcadc4fcb9" - }, - "renv": { - "Package": "renv", - "Version": "1.0.0", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "utils" - ], - "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" - }, - "reprex": { - "Package": "reprex", - "Version": "2.0.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "callr", - "cli", - "clipr", - "fs", - "glue", - "knitr", - "lifecycle", - "rlang", - "rmarkdown", - "rstudioapi", - "utils", - "withr" - ], - "Hash": "d66fe009d4c20b7ab1927eb405db9ee2" - }, - "reshape2": { - "Package": "reshape2", - "Version": "1.4.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp", - "plyr", - "stringr" - ], - "Hash": "bb5996d0bd962d214a11140d77589917" - }, - "rlang": { - "Package": "rlang", - "Version": "1.1.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "utils" - ], - "Hash": "50a6dbdc522936ca35afc5e2082ea91b" - }, - "rmarkdown": { - "Package": "rmarkdown", - "Version": "2.25", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "bslib", - "evaluate", - "fontawesome", - "htmltools", - "jquerylib", - "jsonlite", - "knitr", - "methods", - "stringr", - "tinytex", - "tools", - "utils", - "xfun", - "yaml" - ], - "Hash": "d65e35823c817f09f4de424fcdfa812a" - }, - "roxygen2": { - "Package": "roxygen2", - "Version": "7.2.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "brew", - "cli", - "commonmark", - "cpp11", - "desc", - "knitr", - "methods", - "pkgload", - "purrr", - "rlang", - "stringi", - "stringr", - "utils", - "withr", - "xml2" - ], - "Hash": "7b153c746193b143c14baa072bae4e27" - }, - "rprojroot": { - "Package": "rprojroot", - "Version": "2.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "1de7ab598047a87bba48434ba35d497d" - }, - "rstudioapi": { - "Package": "rstudioapi", - "Version": "0.15.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "5564500e25cffad9e22244ced1379887" - }, - "rversions": { - "Package": "rversions", - "Version": "2.1.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "curl", - "utils", - "xml2" - ], - "Hash": "a9881dfed103e83f9de151dc17002cd1" - }, - "rvest": { - "Package": "rvest", - "Version": "1.0.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "httr", - "lifecycle", - "magrittr", - "rlang", - "selectr", - "tibble", - "withr", - "xml2" - ], - "Hash": "a4a5ac819a467808c60e36e92ddf195e" - }, - "sandwich": { - "Package": "sandwich", - "Version": "3.1-0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "stats", - "utils", - "zoo" - ], - "Hash": "1cf6ae532f0179350862fefeb0987c9b" - }, - "sass": { - "Package": "sass", - "Version": "0.4.8", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R6", - "fs", - "htmltools", - "rappdirs", - "rlang" - ], - "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" - }, - "scales": { - "Package": "scales", - "Version": "1.3.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "RColorBrewer", - "cli", - "farver", - "glue", - "labeling", - "lifecycle", - "munsell", - "rlang", - "viridisLite" - ], - "Hash": "c19df082ba346b0ffa6f833e92de34d1" - }, - "selectr": { - "Package": "selectr", - "Version": "0.4-2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "methods", - "stringr" - ], - "Hash": "3838071b66e0c566d55cc26bd6e27bf4" - }, - "sessioninfo": { - "Package": "sessioninfo", - "Version": "1.2.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "tools", - "utils" - ], - "Hash": "3f9796a8d0a0e8c6eb49a4b029359d1f" - }, - "shiny": { - "Package": "shiny", - "Version": "1.8.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "bslib", - "cachem", - "commonmark", - "crayon", - "ellipsis", - "fastmap", - "fontawesome", - "glue", - "grDevices", - "htmltools", - "httpuv", - "jsonlite", - "later", - "lifecycle", - "methods", - "mime", - "promises", - "rlang", - "sourcetools", - "tools", - "utils", - "withr", - "xtable" - ], - "Hash": "3a1f41807d648a908e3c7f0334bf85e6" - }, - "simstudy": { - "Package": "simstudy", - "Version": "0.7.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "Rcpp", - "backports", - "data.table", - "fastglm", - "glue", - "methods", - "mvnfast", - "pbv" - ], - "Hash": "deb66424ac81e3aa78066791e0e6b97f" - }, - "sodium": { - "Package": "sodium", - "Version": "1.3.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "bd436c1e48dc1982125e4d955017724e" - }, - "sourcetools": { - "Package": "sourcetools", - "Version": "0.1.7-1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "5f5a7629f956619d519205ec475fe647" - }, - "stringi": { - "Package": "stringi", - "Version": "1.7.12", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "stats", - "tools", - "utils" - ], - "Hash": "ca8bd84263c77310739d2cf64d84d7c9" - }, - "stringr": { - "Package": "stringr", - "Version": "1.5.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "lifecycle", - "magrittr", - "rlang", - "stringi", - "vctrs" - ], - "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" - }, - "survey": { - "Package": "survey", - "Version": "4.2-1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "Matrix", - "R", - "graphics", - "grid", - "lattice", - "methods", - "minqa", - "mitools", - "numDeriv", - "splines", - "stats", - "survival" - ], - "Hash": "03195177db81a992f22361f8f54852f4" - }, - "survival": { - "Package": "survival", - "Version": "3.3-1", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "Matrix", - "R", - "graphics", - "methods", - "splines", - "stats", - "utils" - ], - "Hash": "f6189c70451d3d68e0d571235576e833" - }, - "swagger": { - "Package": "swagger", - "Version": "3.33.1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "f28d25ed70c903922254157c11b0081d" - }, - "sys": { - "Package": "sys", - "Version": "3.4.2", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "3a1be13d68d47a8cd0bfd74739ca1555" - }, - "systemfonts": { - "Package": "systemfonts", - "Version": "1.0.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cpp11" - ], - "Hash": "15b594369e70b975ba9f064295983499" - }, - "tableone": { - "Package": "tableone", - "Version": "0.13.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "MASS", - "e1071", - "gmodels", - "labelled", - "nlme", - "survey", - "zoo" - ], - "Hash": "b1a77da61a4c3585987241b8a1cc6b95" - }, - "testthat": { - "Package": "testthat", - "Version": "3.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "R6", - "brio", - "callr", - "cli", - "desc", - "digest", - "evaluate", - "jsonlite", - "lifecycle", - "magrittr", - "methods", - "pkgload", - "praise", - "processx", - "ps", - "rlang", - "utils", - "waldo", - "withr" - ], - "Hash": "4767a686ebe986e6cb01d075b3f09729" - }, - "textshaping": { - "Package": "textshaping", - "Version": "0.3.7", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cpp11", - "systemfonts" - ], - "Hash": "997aac9ad649e0ef3b97f96cddd5622b" - }, - "tibble": { - "Package": "tibble", - "Version": "3.2.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "fansi", - "lifecycle", - "magrittr", - "methods", - "pillar", - "pkgconfig", - "rlang", - "utils", - "vctrs" - ], - "Hash": "a84e2cc86d07289b3b6f5069df7a004c" - }, - "tidyr": { - "Package": "tidyr", - "Version": "1.3.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "cpp11", - "dplyr", - "glue", - "lifecycle", - "magrittr", - "purrr", - "rlang", - "stringr", - "tibble", - "tidyselect", - "utils", - "vctrs" - ], - "Hash": "e47debdc7ce599b070c8e78e8ac0cfcf" - }, - "tidyselect": { - "Package": "tidyselect", - "Version": "1.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "lifecycle", - "rlang", - "vctrs", - "withr" - ], - "Hash": "79540e5fcd9e0435af547d885f184fd5" - }, - "tidyverse": { - "Package": "tidyverse", - "Version": "2.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "broom", - "cli", - "conflicted", - "dbplyr", - "dplyr", - "dtplyr", - "forcats", - "ggplot2", - "googledrive", - "googlesheets4", - "haven", - "hms", - "httr", - "jsonlite", - "lubridate", - "magrittr", - "modelr", - "pillar", - "purrr", - "ragg", - "readr", - "readxl", - "reprex", - "rlang", - "rstudioapi", - "rvest", - "stringr", - "tibble", - "tidyr", - "xml2" - ], - "Hash": "c328568cd14ea89a83bd4ca7f54ae07e" - }, - "timechange": { - "Package": "timechange", - "Version": "0.2.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cpp11" - ], - "Hash": "8548b44f79a35ba1791308b61e6012d7" - }, - "tinytex": { - "Package": "tinytex", - "Version": "0.49", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "xfun" - ], - "Hash": "5ac22900ae0f386e54f1c307eca7d843" - }, - "truncnorm": { - "Package": "truncnorm", - "Version": "1.0-9", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "ef5b32c5194351ff409dfb37ca9468f1" - }, - "tzdb": { - "Package": "tzdb", - "Version": "0.4.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cpp11" - ], - "Hash": "f561504ec2897f4d46f0c7657e488ae1" - }, - "unbiased": { - "Package": "unbiased", - "Version": "0.0.0.9003", - "Source": "unknown", - "Requirements": [ - "checkmate", - "dbplyr", - "dplyr", - "mathjaxr", - "plumber", - "rlang", - "tibble", - "tidyr" - ], - "Hash": "10b4a8733ed5a18c78c6f683d9023b06" - }, - "urlchecker": { - "Package": "urlchecker", - "Version": "1.0.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "curl", - "tools", - "xml2" - ], - "Hash": "409328b8e1253c8d729a7836fe7f7a16" - }, - "usethis": { - "Package": "usethis", - "Version": "2.2.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "clipr", - "crayon", - "curl", - "desc", - "fs", - "gert", - "gh", - "glue", - "jsonlite", - "lifecycle", - "purrr", - "rappdirs", - "rlang", - "rprojroot", - "rstudioapi", - "stats", - "utils", - "whisker", - "withr", - "yaml" - ], - "Hash": "60e51f0b94d0324dc19e44110098fa9f" - }, - "utf8": { - "Package": "utf8", - "Version": "1.2.3", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "1fe17157424bb09c48a8b3b550c753bc" - }, - "uuid": { - "Package": "uuid", - "Version": "1.1-1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "3d78edfb977a69fc7a0341bee25e163f" - }, - "vctrs": { - "Package": "vctrs", - "Version": "0.6.4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "glue", - "lifecycle", - "rlang" - ], - "Hash": "266c1ca411266ba8f365fcc726444b87" - }, - "viridisLite": { - "Package": "viridisLite", - "Version": "0.4.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R" - ], - "Hash": "c826c7c4241b6fc89ff55aaea3fa7491" - }, - "vroom": { - "Package": "vroom", - "Version": "1.6.5", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "bit64", - "cli", - "cpp11", - "crayon", - "glue", - "hms", - "lifecycle", - "methods", - "progress", - "rlang", - "stats", - "tibble", - "tidyselect", - "tzdb", - "vctrs", - "withr" - ], - "Hash": "390f9315bc0025be03012054103d227c" - }, - "waldo": { - "Package": "waldo", - "Version": "0.5.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "cli", - "diffobj", - "fansi", - "glue", - "methods", - "rematch2", - "rlang", - "tibble" - ], - "Hash": "2c993415154cdb94649d99ae138ff5e5" - }, - "webutils": { - "Package": "webutils", - "Version": "1.1", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "curl", - "jsonlite" - ], - "Hash": "75d8b5b05fe22659b54076563f83f26a" - }, - "whisker": { - "Package": "whisker", - "Version": "0.4.1", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "c6abfa47a46d281a7d5159d0a8891e88" - }, - "withr": { - "Package": "withr", - "Version": "2.5.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "graphics", - "stats" - ], - "Hash": "4b25e70111b7d644322e9513f403a272" - }, - "xfun": { - "Package": "xfun", - "Version": "0.41", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "stats", - "tools" - ], - "Hash": "460a5e0fe46a80ef87424ad216028014" - }, - "xml2": { - "Package": "xml2", - "Version": "1.3.6", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "cli", - "methods", - "rlang" - ], - "Hash": "1d0336142f4cd25d8d23cd3ba7a8fb61" - }, - "xopen": { - "Package": "xopen", - "Version": "1.0.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "processx" - ], - "Hash": "6c85f015dee9cc7710ddd20f86881f58" - }, - "xtable": { - "Package": "xtable", - "Version": "1.8-4", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "stats", - "utils" - ], - "Hash": "b8acdf8af494d9ec19ccb2481a9b11c2" - }, - "yaml": { - "Package": "yaml", - "Version": "2.3.8", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "29240487a071f535f5e5d5a323b7afbd" - }, - "zip": { - "Package": "zip", - "Version": "2.3.0", - "Source": "Repository", - "Repository": "RSPM", - "Hash": "d98c94dacb7e0efcf83b0a133a705504" - }, - "zoo": { - "Package": "zoo", - "Version": "1.8-12", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R", - "grDevices", - "graphics", - "lattice", - "stats", - "utils" - ], - "Hash": "5c715954112b45499fb1dadc6ee6ee3e" - } - } -} From 75ca03a7806acf7bdc57e09366a71a653eddcd25 Mon Sep 17 00:00:00 2001 From: jagoda Date: Thu, 8 Feb 2024 10:47:19 +0000 Subject: [PATCH 174/240] styler + updated .Rbuildignote --- .Rbuildignore | 1 + vignettes/articles/helpers/functions.R | 118 ++++++++++------------ vignettes/articles/helpers/run_parallel.R | 101 +++++++++--------- 3 files changed, 105 insertions(+), 115 deletions(-) diff --git a/.Rbuildignore b/.Rbuildignore index 7129571..6dc0156 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,4 @@ ^_pkgdown\.yml$ ^docs$ ^pkgdown$ +^vignettes/articles$ diff --git a/vignettes/articles/helpers/functions.R b/vignettes/articles/helpers/functions.R index dc9f902..5d4cf08 100644 --- a/vignettes/articles/helpers/functions.R +++ b/vignettes/articles/helpers/functions.R @@ -2,9 +2,8 @@ simulate_data_monte_carlo <- function(def, n) { - data <- - genData(n, def)|> + genData(n, def) |> mutate( sex = as.character(sex), age = as.character(age), @@ -21,33 +20,28 @@ simulate_data_monte_carlo <- minimize_results <- function(current_data, arms, weights) { - - for (n in 1:nrow(current_data)) - { - - current_state <- current_data[1:n, 2:ncol(current_data)] - - current_data$arm[n] <- - randomize_minimisation_pocock( - arms = arms, - current_state = current_state, - weights = weights - ) - - } + for (n in seq_len(nrow(current_data))) + { + current_state <- current_data[1:n, 2:ncol(current_data)] + + current_data$arm[n] <- + randomize_minimisation_pocock( + arms = arms, + current_state = current_state, + weights = weights + ) + } return(current_data$arm) } simple_results <- function(current_data, arms, ratio) { - - for (n in 1:nrow(current_data)) - { - current_data$arm[n] <- - randomize_simple(arms, ratio) - - } + for (n in seq_len(nrow(current_data))) + { + current_data$arm[n] <- + randomize_simple(arms, ratio) + } return(current_data$arm) } @@ -55,11 +49,11 @@ simple_results <- # Function to generate a randomisation list block_rand <- function(N, block, n_groups, strata, arms = LETTERS[1:n_groups]) { - strata_grid = expand.grid(strata) + strata_grid <- expand.grid(strata) - strata_n = nrow(strata_grid) + strata_n <- nrow(strata_grid) - ratio = rep(1, n_groups) + ratio <- rep(1, n_groups) genSeq_list <- lapply(seq_len(strata_n), function(i) { rand <- rpbrPar( @@ -70,9 +64,9 @@ block_rand <- groups = arms, filledBlock = FALSE ) - getRandList(genSeq(rand))[1,] + getRandList(genSeq(rand))[1, ] }) - df_list = tibble::tibble() + df_list <- tibble::tibble() for (i in seq_len(strata_n)) { local_df <- strata_grid |> dplyr::slice(i) |> @@ -86,42 +80,38 @@ block_rand <- # Generate a research arm for patients in each iteration block_results <- function(current_data) { - - simulation_result <- - block_rand( - N = n, - block = c(3, 6, 9), - n_groups = 3, - strata = - list( - sex = c("0", "1"), - diabetes_type = c("0", "1"), - hba1c = c("0", "1"), - tpo2 = c("0", "1"), - age = c("0", "1"), - wound_size = c("0", "1") - ), - arms = c("armA", "armB", "armC") - ) - - for (n in 1:nrow(current_data)) - { - - #"-1" is for "arm" column - current_state <- current_data[n, 2:(ncol(current_data)-1)] - - matching_rows <- which(apply(simulation_result[,-ncol(simulation_result)], 1, function(row) all(row == current_state))) - - if (length(matching_rows) > 0) { - - current_data$arm[n] <- - simulation_result[matching_rows[1],"rand_arm"] - - # Delete row from randomization list - simulation_result <- simulation_result[-matching_rows[1], , drop = FALSE] - } + simulation_result <- + block_rand( + N = n, + block = c(3, 6, 9), + n_groups = 3, + strata = + list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ), + arms = c("armA", "armB", "armC") + ) + + for (n in seq_len(nrow(current_data))) + { + # "-1" is for "arm" column + current_state <- current_data[n, 2:(ncol(current_data) - 1)] + + matching_rows <- which(apply(simulation_result[, -ncol(simulation_result)], 1, function(row) all(row == current_state))) + + if (length(matching_rows) > 0) { + current_data$arm[n] <- + simulation_result[matching_rows[1], "rand_arm"] + + # Delete row from randomization list + simulation_result <- simulation_result[-matching_rows[1], , drop = FALSE] } + } - return(current_data$arm) - + return(current_data$arm) } diff --git a/vignettes/articles/helpers/run_parallel.R b/vignettes/articles/helpers/run_parallel.R index ed1f17d..e79c795 100644 --- a/vignettes/articles/helpers/run_parallel.R +++ b/vignettes/articles/helpers/run_parallel.R @@ -7,72 +7,71 @@ cl <- makeForkCluster(no_of_cores) results <- parLapply(cl, 1:no_of_iterations, function(i) { - # lapply(1:no_of_iterations, funĆction(i) { - set.seed(i) + # lapply(1:no_of_iterations, funĆction(i) { + set.seed(i) - data <- simulate_data_monte_carlo(def, n) + data <- simulate_data_monte_carlo(def, n) - # eqal weights - 1/6 - minimize_equal_weights <- - minimize_results( - current_data = data, - arms = c("armA", "armB", "armC") - ) + # eqal weights - 1/6 + minimize_equal_weights <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC") + ) - # double weights where the covariant is of high clinical significance - minimize_unequal_weights <- - minimize_results( - current_data = data, - arms = c("armA", "armB", "armC"), - weights = c( - "sex" = 1, - "diabetes_type" = 1, - "hba1c" = 2, - "tpo2" = 2, - "age" = 1, - "wound_size" = 2 + # double weights where the covariant is of high clinical significance + minimize_unequal_weights <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 2, + "tpo2" = 2, + "age" = 1, + "wound_size" = 2 + ) ) - ) - # triple weights where the covariant is of high clinical significance - minimize_unequal_weights_triple <- - minimize_results( - current_data = data, - arms = c("armA", "armB", "armC"), - weights = c( - "sex" = 1, - "diabetes_type" = 1, - "hba1c" = 3, - "tpo2" = 3, - "age" = 1, - "wound_size" = 3 + # triple weights where the covariant is of high clinical significance + minimize_unequal_weights_triple <- + minimize_results( + current_data = data, + arms = c("armA", "armB", "armC"), + weights = c( + "sex" = 1, + "diabetes_type" = 1, + "hba1c" = 3, + "tpo2" = 3, + "age" = 1, + "wound_size" = 3 + ) ) - ) - simple_data <- - simple_results( - current_data = data, - arms = c("armA", "armB", "armC"), - ratio = c("armB" = 1L,"armA" = 1L, "armC" = 1L) - ) + simple_data <- + simple_results( + current_data = data, + arms = c("armA", "armB", "armC"), + ratio = c("armB" = 1L, "armA" = 1L, "armC" = 1L) + ) - block_data <- - block_results(current_data = data) + block_data <- + block_results(current_data = data) - data <- - data %>% - select(-arm) %>% + data <- + data %>% + select(-arm) %>% mutate( minimize_equal_weights_arms = minimize_equal_weights, minimize_unequal_weights_arms = minimize_unequal_weights, minimize_unequal_weights_triple_arms = minimize_unequal_weights_triple, simple_data_arms = simple_data, block_data_arms = block_data - ) %>% - tibble::add_column(simnr = i, .before = 1) - - return(data) + ) %>% + tibble::add_column(simnr = i, .before = 1) -}) + return(data) + }) stopCluster(cl) From 0c8240c2caed7b538c1666a4a5109a44e8643ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 8 Feb 2024 11:55:22 +0000 Subject: [PATCH 175/240] increase number of connection retries for create_db_connection_pool --- R/db.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/db.R b/R/db.R index 8bca246..9751168 100644 --- a/R/db.R +++ b/R/db.R @@ -23,7 +23,7 @@ create_db_connection_pool <- purrr::insistently(function() { user = Sys.getenv("POSTGRES_USER"), password = Sys.getenv("POSTGRES_PASSWORD") ) -}, rate = purrr::rate_delay(2, max_times = 5)) +}, rate = purrr::rate_delay(2, max_times = 15)) get_similar_studies <- function(name, identifier) { From 5ac44cadf623b937f4dd5d7c06d8608a8efadd53 Mon Sep 17 00:00:00 2001 From: Ola Date: Thu, 8 Feb 2024 12:24:30 +0000 Subject: [PATCH 176/240] add text to readme and png --- README.md | 24 +++++++++++++++++------- vignettes/boxplot.png | Bin 0 -> 25970 bytes 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 vignettes/boxplot.png diff --git a/README.md b/README.md index c53bd9e..4951bba 100644 --- a/README.md +++ b/README.md @@ -40,32 +40,42 @@ Available both as a standard R package and through an API, **unbiased** provides ## Purpose and Scope for Clinical Trial Randomization -Randomization is a fundamental aspect of clinical trials, ensuring that participants are allocated to treatment groups in an unbiased manner. This is essential for maintaining the integrity of the trial and ensuring that the results are reliable. The primary goal of randomization is to minimize the potential for bias and confounding factors that could affect the outcome of the trial. +Randomization is the gold standard for conducting clinical trials and a fundamental aspect of clinical trials, in studies comparing two or more arms. Although there are sometimes ethical constraints preventing the use of randomization, in most cases randomization is a desirable technique that will ensure that patients are randomly allocated to defined groups. -The **unbiased** package provides a comprehensive suite of randomization algorithms to support a wide range of clinical trial designs. It is designed to be flexible and adaptable, allowing researchers to select the most appropriate randomization method for their specific study. +Randomization then ensures that the predictability of the allocation of consecutive patients to groups is blinded, allowing the study participants overseeing the clinical trial to be appropriately blinded. This is essential for maintaining the integrity of the trial and ensuring that the results are reliable. + +However, there are situations where it is desirable for studies to balance patients in terms of numbers in each group or, in addition, to achieve balance with respect to other relevant factors, such as sex or diabetes type. + +Adequate selection of randomization methods allows the intended randomization goals to be realized; however, in the case of balance between groups in terms of patient characteristics, more adaptive methods of patient allocation are required, e.g. by verifying the overall imbalance on the basis of current allocations to the study groups. This is ensured, for example, by using the minimization method. + +**Unbiased** specifically caters to the needs of clinical trial randomization. It streamlines the randomization process, ensuring a balanced and impartial allocation of participants across different trial groups, which is vital for minimizing bias and ensuring the reliability of trial outcomes. Unbiased allows the use of simple, block and advanced randomization methods relevant to the conduct of clinical trials. Consequently, it addresses the needs arising from the need to balance against key variables, ensuring that the population in each treatment group is as comparable as possible. ## Comparative Analysis of Randomization Methods -(Ola - skrócona wersja z winietki, może obrazki?) +**Unbiased** compared to standard and most commonly used randomization methods, e.g. the simple method or the block method, additionally offers enhanced features of more flexible adaptive methods, which are based on current information about the allocation of patients in the trial. Compared to, for example, block randomization, adaptive randomization not only ensures relatively equal allocation to patient groups, but also allows the groups to be balanced on the basis of certain important covariates, which is its key advantage. This randomization requires predefined criteria, such as the probability with which a given patient will be assigned to a group based on minimizing the total imbalance, or weights that can be assigned personally for each individual covariate. Its advanced algorithmic approach sets it apart from others by minimizing selection bias and improving the overall efficiency of the randomization process in clinical trials. -The **unbiased** package offers a range of randomization methods, each with its own strengths and limitations. The choice of randomization method will depend on the specific requirements of the trial, including the number of treatment groups, the size of the trial, and the need for stratification or minimization. +The **unbiased** package offers the use of different randomization methods, each with its own strengths and limitations. The choice of randomization method will depend on the specific requirements of the trial, including the number of treatment groups, the size of the trial, and the need for stratification or covariate balance. The **unbiased** package includes the following randomization methods: -- **Simple Randomization**: This is the most basic form of randomization, in which participants are assigned to treatment groups with equal probability. This method is simple and easy to implement, but it does not account for any potential imbalances in baseline characteristics between treatment groups. +- **Simple Randomization**: This is the most basic form of randomization, in which participants are assigned to treatment groups with equal probability. This method is simple and easy to implement. Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly (flip coin method). + +- **Minimization Method**: This method is designed to minimize imbalances in baseline characteristics between treatment groups. It uses an adaptive algorithm to assign participants to treatment groups based on their baseline characteristics, with the goal of achieving balance across treatment groups. - **Block Randomization**: This method involves dividing participants into blocks and then randomly assigning them to treatment groups within each block. This ensures that the number of participants in each treatment group is balanced over time, but it does not account for any potential imbalances in baseline characteristics between treatment groups. -- **Minimization Method**: This method is designed to minimize imbalances in baseline characteristics between treatment groups. It uses an adaptive algorithm to assign participants to treatment groups based on their baseline characteristics, with the goal of achieving balance across treatment groups. +Depending on the aims and objectives of the randomised trial, the **unbiased** approach allows a choice of alternative methods to effectively implement appropriate algorithms for the randomized patient allocation process in a clinical trial. A comparison of these methods is shown in a boxplot, where a lower threshold value of the SMD index indicates a greater balance in covariates retained by the method. +![Comparison of covariate balances.](vignettes/boxplot.png) ... To find out more, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/minimization_randomization_comparison.Rmd). ## Comparison with other solutions -(Ola - randpack, others...?) +There are many packages that perform specific randomization methods in R. Most of them are designed for stratified randomization, and permuted blocks -e.g. blockrand, randomizeR. More recently, there have also been options for using minimization randomization - randpack, or minirand. +Unlike the other packages, unbiased incorporates several different types of minimization algorithms - from simple simple randomization methods to advanced ones based on the Pocok minimization method. In addition, the advantage of using unbiased is that it can be used in the form of an API, which is not possible in the existing software, making **unbiased** appear complete from the point of view of usability, as well as the possibility of testing multiple methods for an individual study within a single package. # Getting Started diff --git a/vignettes/boxplot.png b/vignettes/boxplot.png new file mode 100644 index 0000000000000000000000000000000000000000..aac9a3eb1462403b971358cdb13bcde234a686f3 GIT binary patch literal 25970 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≦wt*J95eQ+QL!eo=q4B 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 literal 0 HcmV?d00001 From 9a13e280bc5e51645914edb6fd9a42a49ed3a640 Mon Sep 17 00:00:00 2001 From: jagoda Date: Thu, 8 Feb 2024 12:25:42 +0000 Subject: [PATCH 177/240] lintr corrections --- vignettes/articles/helpers/functions.R | 41 +++++---- vignettes/articles/helpers/run_parallel.R | 4 +- .../minimization_randomization_comparison.Rmd | 88 ++++++++++--------- 3 files changed, 68 insertions(+), 65 deletions(-) diff --git a/vignettes/articles/helpers/functions.R b/vignettes/articles/helpers/functions.R index 5d4cf08..2fde29a 100644 --- a/vignettes/articles/helpers/functions.R +++ b/vignettes/articles/helpers/functions.R @@ -20,8 +20,7 @@ simulate_data_monte_carlo <- minimize_results <- function(current_data, arms, weights) { - for (n in seq_len(nrow(current_data))) - { + for (n in seq_len(nrow(current_data))) { current_state <- current_data[1:n, 2:ncol(current_data)] current_data$arm[n] <- @@ -37,8 +36,7 @@ minimize_results <- simple_results <- function(current_data, arms, ratio) { - for (n in seq_len(nrow(current_data))) - { + for (n in seq_len(nrow(current_data))) { current_data$arm[n] <- randomize_simple(arms, ratio) } @@ -48,23 +46,23 @@ simple_results <- # Function to generate a randomisation list block_rand <- - function(N, block, n_groups, strata, arms = LETTERS[1:n_groups]) { + function(n, block, n_groups, strata, arms = LETTERS[1:n_groups]) { strata_grid <- expand.grid(strata) strata_n <- nrow(strata_grid) ratio <- rep(1, n_groups) - genSeq_list <- lapply(seq_len(strata_n), function(i) { + gen_seq_list <- lapply(seq_len(strata_n), function(i) { rand <- rpbrPar( - N = N, + N = n, rb = block, K = n_groups, ratio = ratio, groups = arms, filledBlock = FALSE ) - getRandList(genSeq(rand))[1, ] + getRandList(gen_seq_list(rand))[1, ] }) df_list <- tibble::tibble() for (i in seq_len(strata_n)) { @@ -82,27 +80,28 @@ block_rand <- block_results <- function(current_data) { simulation_result <- block_rand( - N = n, + n = n, block = c(3, 6, 9), n_groups = 3, - strata = - list( - sex = c("0", "1"), - diabetes_type = c("0", "1"), - hba1c = c("0", "1"), - tpo2 = c("0", "1"), - age = c("0", "1"), - wound_size = c("0", "1") - ), + strata = list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ), arms = c("armA", "armB", "armC") ) - for (n in seq_len(nrow(current_data))) - { + for (n in seq_len(nrow(current_data))) { # "-1" is for "arm" column current_state <- current_data[n, 2:(ncol(current_data) - 1)] - matching_rows <- which(apply(simulation_result[, -ncol(simulation_result)], 1, function(row) all(row == current_state))) + matching_rows <- which(apply( + simulation_result[, -ncol(simulation_result)], 1, + function(row) all(row == current_state) + )) if (length(matching_rows) > 0) { current_data$arm[n] <- diff --git a/vignettes/articles/helpers/run_parallel.R b/vignettes/articles/helpers/run_parallel.R index e79c795..1d13604 100644 --- a/vignettes/articles/helpers/run_parallel.R +++ b/vignettes/articles/helpers/run_parallel.R @@ -35,7 +35,7 @@ results <- ) # triple weights where the covariant is of high clinical significance - minimize_unequal_weights_triple <- + minimize_unequal_weights_3 <- minimize_results( current_data = data, arms = c("armA", "armB", "armC"), @@ -65,7 +65,7 @@ results <- mutate( minimize_equal_weights_arms = minimize_equal_weights, minimize_unequal_weights_arms = minimize_unequal_weights, - minimize_unequal_weights_triple_arms = minimize_unequal_weights_triple, + minimize_unequal_weights_triple_arms = minimize_unequal_weights_3, simple_data_arms = simple_data, block_data_arms = block_data ) %>% diff --git a/vignettes/articles/minimization_randomization_comparison.Rmd b/vignettes/articles/minimization_randomization_comparison.Rmd index 12c197d..70758e1 100644 --- a/vignettes/articles/minimization_randomization_comparison.Rmd +++ b/vignettes/articles/minimization_randomization_comparison.Rmd @@ -155,7 +155,9 @@ def <- simstudy::defData(def, varname = "hba1c", formula = "0.888", dist = "bina # <= 50 - 0.354 def <- simstudy::defData(def, varname = "tpo2", formula = "0.354", dist = "binary") # correlation with diabetes type -def <- simstudy::defData(def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary") +def <- simstudy::defData( + def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary" +) # <= 2 - 0.302 def <- simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") ``` @@ -196,7 +198,7 @@ To generate appropriate research arms, a function called `minimize_results` was - **minimize_unequal_weights** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 2. The remaining covariates have been assigned a weight of 1. -- **minimize_unequal_weights_triple** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. +- **minimize_unequal_weights_3** - following the expert assessment by physicians, parameters with potentially significant impact on treatment outcomes (hba1c, tpo2, wound size) have been assigned a weight of 3. The remaining covariates have been assigned a weight of 1. The tables present information about allocations for the first 5 patients. @@ -204,8 +206,7 @@ The tables present information about allocations for the first 5 patients. # drawing an arm for each patient minimize_results <- function(current_data, arms, weights) { - for (n in 1:nrow(current_data)) - { + for (n in seq_len(nrow(current_data))) { current_state <- current_data[1:n, 2:ncol(current_data)] current_data$arm[n] <- @@ -257,7 +258,7 @@ head(minimize_unequal_weights, 5) |> ```{r, minimize-unequal-2} set.seed(123) # triple weights where the covariant is of high clinical significance -minimize_unequal_weights_triple <- +minimize_unequal_weights_3 <- minimize_results( current_data = data, arms = c("armA", "armB", "armC"), @@ -271,7 +272,7 @@ minimize_unequal_weights_triple <- ) ) -head(minimize_unequal_weights_triple, 5) |> +head(minimize_unequal_weights_3, 5) |> gt() ``` @@ -320,7 +321,7 @@ statistics_table(minimize_unequal_weights) - **Minimization with weights 3:1**. ```{r, chi2-3, tab.cap = "Summary of proportion test for minimization randomization with equal weights"} -statistics_table(minimize_unequal_weights_triple) +statistics_table(minimize_unequal_weights_3) ``` ## Simple randomization @@ -333,8 +334,7 @@ Since this is simple randomization, it does not take into account the initial co # simple randomization simple_results <- function(current_data, arms, ratio) { - for (n in 1:nrow(current_data)) - { + for (n in seq_len(nrow(current_data))) { current_data$arm[n] <- randomize_simple(arms, ratio) } @@ -374,16 +374,16 @@ The tables show the assignment of patients to groups using block randomisation a ```{r, block-rand} # Function to generate a randomisation list block_rand <- - function(N, block, n_groups, strata, arms = LETTERS[1:n_groups]) { + function(n, block, n_groups, strata, arms = LETTERS[1:n_groups]) { strata_grid <- expand.grid(strata) strata_n <- nrow(strata_grid) ratio <- rep(1, n_groups) - genSeq_list <- lapply(seq_len(strata_n), function(i) { + gen_seq_list <- lapply(seq_len(strata_n), function(i) { rand <- rpbrPar( - N = N, + N = n, rb = block, K = n_groups, ratio = ratio, @@ -398,7 +398,7 @@ block_rand <- dplyr::slice(i) |> dplyr::mutate(count = N) |> tidyr::uncount(count) |> - tibble::add_column(rand_arm = genSeq_list[[i]]) + tibble::add_column(rand_arm = gen_seq_list[[i]]) df_list <- rbind(local_df, df_list) } return(df_list) @@ -410,27 +410,28 @@ block_rand <- block_results <- function(current_data) { simulation_result <- block_rand( - N = n, + n = n, block = c(3, 6, 9), n_groups = 3, - strata = - list( - sex = c("0", "1"), - diabetes_type = c("0", "1"), - hba1c = c("0", "1"), - tpo2 = c("0", "1"), - age = c("0", "1"), - wound_size = c("0", "1") - ), + strata = list( + sex = c("0", "1"), + diabetes_type = c("0", "1"), + hba1c = c("0", "1"), + tpo2 = c("0", "1"), + age = c("0", "1"), + wound_size = c("0", "1") + ), arms = c("armA", "armB", "armC") ) - for (n in 1:nrow(current_data)) - { + for (n in seq_len(nrow(current_data))) { # "-1" is for "arm" column current_state <- current_data[n, 2:(ncol(current_data) - 1)] - matching_rows <- which(apply(simulation_result[, -ncol(simulation_result)], 1, function(row) all(row == current_state))) + matching_rows <- which(apply( + simulation_result[, -ncol(simulation_result)], 1, + function(row) all(row == current_state) + )) if (length(matching_rows) > 0) { current_data$arm[n] <- @@ -467,11 +468,11 @@ These data were assigned to the variable `sim_data` based on the data stored in ```{r, simulations} # define number of iterations -# no_of_iterations <- 1000 +# no_of_iterations <- 1000 # nolint # define number of cores -# no_of_cores <- 20 +# no_of_cores <- 20 # nolint # perform simulations (run carefully!) -# source("~/unbiased/vignettes/helpers/run_parallel.R") +# source("~/unbiased/vignettes/helpers/run_parallel.R") # nolint # read data from file sim_data <- readRDS("1000_sim_data.Rds") @@ -514,8 +515,8 @@ smd_covariants_data <- results_smd <- ExtractSmd(tab) |> - as.data.frame() %>% - tibble::rownames_to_column("covariants") %>% + as.data.frame() |> + tibble::rownames_to_column("covariants") |> select(covariants, results = average) |> mutate(results = round(as.numeric(results), 3)) @@ -526,8 +527,10 @@ smd_covariants_data <- results_smd ) return(results) - }) |> bind_rows() - }) |> bind_rows() + }) |> + bind_rows() + }) |> + bind_rows() return(result_table) } @@ -538,14 +541,14 @@ cov_balance_data <- smd_covariants_data( data = sim_data, vars = vars - ) %>% + ) |> mutate(method = case_when( strata == "minimize_equal_weights_arms" ~ "minimize equal", strata == "minimize_unequal_weights_arms" ~ "minimize unequal 2:1", strata == "minimize_unequal_weights_triple_arms" ~ "minimize unequal 3:1", strata == "simple_data_arms" ~ "simple randomization", strata == "block_data_arms" ~ "block randomization" - )) %>% + )) |> select(-strata) ``` @@ -580,7 +583,7 @@ cov_balance_data |> ) + facet_wrap(~covariants, ncol = 3) + theme_bw() + - theme(axis.text = element_text(angle=45, vjust = 0.5, hjust=1)) + theme(axis.text = element_text(angle = 45, vjust = 0.5, hjust = 1)) ``` - **Summary table of success** @@ -599,15 +602,16 @@ success_power <- lapply(unique(cov_data$simnr), function(i) { current_data <- cov_data[cov_data$simnr == i, ] - current_data %>% - group_by(method) %>% - summarise(success = ifelse(any(results > 0.2), 0, 1)) %>% + current_data |> + group_by(method) |> + summarise(success = ifelse(any(results > 0.2), 0, 1)) |> tibble::add_column(simnr = i, .before = 1) - }) %>% bind_rows() + }) |> + bind_rows() success <- - result_table %>% - group_by(method) %>% + result_table |> + group_by(method) |> summarise(results_power = sum(success) / n() * 100) From 4b72c8139230e5ca9ac8271af441c26ccd2dc6e4 Mon Sep 17 00:00:00 2001 From: Jagoda Date: Thu, 8 Feb 2024 14:29:12 +0100 Subject: [PATCH 178/240] README v2 --- README.md | 144 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 105 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 4951bba..5216e49 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,30 @@ The **unbiased** package integrates dynamic and traditional randomization method Available both as a standard R package and through an API, **unbiased** provides flexibility for researchers. It ensures seamless integration with electronic Case Report Form (eCRF) systems, facilitating efficient patient management. ## Table of Contents + 1. [Background](#background) - [Purpose and Scope for Clinical Trial Randomization](#purpose-and-scope-for-clinical-trial-randomization) - [Comparative Analysis of Randomization Methods](#comparative-analysis-of-randomization-methods) - - [Comparison with other solutions](#comparison-with-other-solutions) -2. [Installation](#installation) - - [Installation Instructions](#installation-instructions) - - [Deploying the API](#deploying-the-api) -4. [Getting Started](#getting-started) - - [Quickstart Guide](#quickstart-guide) - - [Basic Usage Examples](#basic-usage-examples) -3. [Technical Implementation](#technical-implementation) + - [Comparison with Other Solutions](#comparison-with-other-solutions) +2. [Quickstart Guide](#quickstart-guide) + - [Installation Instructions](#installation-instructions) + - [Deploying the API](#deploying-the-api) + - [API Configuration](#api-configuration) +3. [Getting started with **unbiased**](#getting-started-with-unbiased) + - [Using Randomization Functions within R](#using-randomization-functions-within-r) + - [Simple Randomization](#simple-randomization) + - [Minimization Method](#minimization-method) + - [API Endpoints](#api-endpoints) + - [Study Creation](#study-creation) + - [Patient Randomization](#patient-randomization) +4. [Technical Implementation](#technical-implementation) - [Quality Assurance Measures](#quality-assurance-measures) + - [Running Tests](#running-tests) + - [Executing Tests from an R Interactive Session](#executing-tests-from-an-r-interactive-session) + - [Executing Tests from the Command Line](#executing-tests-from-the-command-line) + - [Running Tests with Docker Compose](#running-tests-with-docker-compose) + - [Code Coverage](#code-coverage) + # Background @@ -77,11 +89,11 @@ There are many packages that perform specific randomization methods in R. Most o Unlike the other packages, unbiased incorporates several different types of minimization algorithms - from simple simple randomization methods to advanced ones based on the Pocok minimization method. In addition, the advantage of using unbiased is that it can be used in the form of an API, which is not possible in the existing software, making **unbiased** appear complete from the point of view of usability, as well as the possibility of testing multiple methods for an individual study within a single package. -# Getting Started +# Quickstart Guide -Initiating your work with **unbiased** involves simple setup steps. Whether you're integrating it into your R environment or deploying its API, we provide detailed instructions and examples to facilitate a smooth start. We aim to equip you with a reliable tool that enhances the integrity and efficiency of your clinical trials. +Initiating your work with **unbiased** involves simple setup steps. Whether you're integrating it into your R environment or deploying its API, we aim to equip you with a reliable tool that enhances the integrity and efficiency of your clinical trials. -## Installation +## Installation instructions The **unbiased** package can be installed from GitHub using the `devtools` package. To install **unbiased**, run the following command in your R environment: @@ -109,50 +121,104 @@ The **unbiased** API server can be configured using environment variables. The f - `UNBIASED_HOST`: The host on which the API will run. Defaults to `0.0.0.0` if not provided. - `UNBIASED_PORT`: The port on which the API will listen. Defaults to `3838` if not provided. -# Use Cases +# Getting started with **unbiased** -## Using randomization functions within R +The **unbiased** package offers functions for randomizing participants in clinical trials, ensuring a fair and transparent process. -The **unbiased** package provides a set of functions that can be used to perform randomization within R. These functions can be used to assign participants to treatment groups in a clinical trial, ensuring that the randomization process is unbiased and transparent. +### Simple Randomization - -### Simple randomization +Use `simple_randomization` for uncomplicated, unbiased assignment, giving each participant an equal chance of being allocated to any group. This method requires specifying the `arms` and `ratio` parameters, where `arms` is a vector of treatment group names, and `ratio` is a vector of integers indicating the allocation proportions. ```R -# Load the unbiased package -library(unbiased) +# Treatment group assignments with a 1:1 ratio +treatment_group <- + randomize_simple( + arms = c("treatment", "placebo"), + ratio = c("treatment" = 1, "placebo" = 1) + ) +``` -# Create a data frame with participant IDs and treatment group assignments -participants <- data.frame( - id = 1:100, - treatment_group = simple_randomization(100, 2) -) +*Note: Ensure that the `ratio` parameter accurately reflects an allocation proportion vector, using numeric values to denote the proportions.* + +### Minimization Method +The minimization method considers existing participant assignments to minimize bias. New participants are allocated based on an imbalance score, calculated using specified weights for each covariate. This method dynamically adjusts to maintain balance across treatment groups. + +```R +# Treatment group assignment considering previous participants' data +treatment_group <- randomize_minimisation_pocock( + arms = c("treatment", "placebo"), + current_state = previous_data, + weights = c( + "sex" = 1, + "age" = 1 + ), + ratio = c(1, 1), # Ensure ratio is defined correctly + method = "var", + p = 0.85 +) ``` -### Minimization method +## API Endpoints + +The **unbiased** API facilitates randomization and clinical trial management via HTTP clients. -The minimization method function provided by **unbiased** assume that there is a study initialized and the previous patients assigments is stored in the dataframe/database. The functions will then use this data to assign new participant to treatment groups in a way that minimizes the potential for bias and confounding factors. If the data is not available (e.g. when first patient is randomized), he will be randomly assigned to a treatment group. +### Study Creation + +To initialize a study using Pocock's minimization method, use the POST /minimisation_pocock endpoint. The required JSON payload should detail the study, including treatment groups, allocation ratios, and covariates. ```R -# Load the unbiased package -library(unbiased) - -# Create a data frame with participant IDs and treatment group assignments -participants <- data.frame( - id = 1:100, - treatment_group = minimization_method( - 100, - 2, - covariates = c("age - )) +# Initialize a study with Pocock's minimisation method +response <- request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "My_study_1", + name = "Study 1", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "treatment" = 1 + ), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + age = list( + weight = 1, + levels = c("up to 50", "51 or more") + ) + ) + ) + ) ``` -## API endpoints +This call sets up the study and returns an ID for accessing further study-related endpoints. + +### Patient Randomization -### Study creation +The POST /{study_id}/patient endpoint assigns a new patient to a treatment group, requiring patient details and covariate information in the JSON payload. + +```R +# Randomize a new patient +req_url_path("study", my_study_id, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female"), + "age" = c("up to 50"), + "arm" = c("") + ) + ) + ) +``` -### Patient randomization +This endpoint determines the patient's treatment group. # Technical details From 48b8c753a9c1a91d108bff5f4d58294d76040d6e Mon Sep 17 00:00:00 2001 From: Kinga Date: Thu, 8 Feb 2024 13:58:42 +0000 Subject: [PATCH 179/240] {linter} corrections --- R/api_create_study.R | 42 +++++++++++++++++++++++++----------------- R/api_randomize.R | 12 +++++------- R/db.R | 26 +++++++++++++------------- 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/R/api_create_study.R b/R/api_create_study.R index 9744dcd..0ee2513 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -1,6 +1,6 @@ -api__minimization_pocock <- function( # nolint: cyclocomp_linter. +api__minimization_pocock <- function( + # nolint: cyclocomp_linter. identifier, name, method, arms, covariates, p, req, res) { - collection <- checkmate::makeAssertCollection() checkmate::assert( @@ -18,7 +18,8 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. checkmate::assert( checkmate::check_choice(method, choices = c("range", "var", "sd")), .var.name = "method", - add = collection) + add = collection + ) checkmate::assert( checkmate::check_list( @@ -55,7 +56,8 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. len = 2, ), .var.name = "covariates1", - add = collection) + add = collection + ) checkmate::assert( checkmate::check_names( @@ -63,35 +65,41 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. permutation.of = c("weight", "levels"), ), .var.name = "covariates2", - add = collection) + add = collection + ) # check covariate weight checkmate::assert( checkmate::check_numeric(c_content$weight, - lower = 0, - finite = TRUE, - len = 1, - null.ok = FALSE + lower = 0, + finite = TRUE, + len = 1, + null.ok = FALSE ), .var.name = "weight", - add = collection) + add = collection + ) checkmate::assert( checkmate::check_character(c_content$levels, - min.chars = 1, - min.len = 2, - unique = TRUE + min.chars = 1, + min.len = 2, + unique = TRUE ), .var.name = "levels", - add = collection) + add = collection + ) } # check probability checkmate::assert( - checkmate::check_numeric(p, lower = 0, upper = 1, len = 1, - any.missing = FALSE, null.ok = FALSE), + checkmate::check_numeric(p, + lower = 0, upper = 1, len = 1, + any.missing = FALSE, null.ok = FALSE + ), .var.name = "p", - add = collection) + add = collection + ) if (length(collection$getMessages()) > 0) { diff --git a/R/api_randomize.R b/R/api_randomize.R index dcf044d..855b3e6 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -89,19 +89,17 @@ api__randomize_patient <- function(study_id, current_state, req, res) { # Dispatch based on randomization method to parse parameters params <- - switch( - method_randomization, + switch(method_randomization, minimisation_pocock = do.call( parse_pocock_parameters, list(db_connection_pool, study_id, current_state) - ) + ) ) arm_name <- - switch( - method_randomization, + switch(method_randomization, minimisation_pocock = do.call( unbiased:::randomize_minimisation_pocock, params - ) + ) ) arm <- dplyr::tbl(db_connection_pool, "arm") |> @@ -109,7 +107,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { dplyr::select("arm_id" = "id", "name", "ratio") |> dplyr::collect() - randomized_patient <- unbiased:::save_patient(study_id, arm$arm_id) + randomized_patient <- unbiased:::save_patient(study_id, arm$arm_id) if (!is.null(randomized_patient$error)) { res$status <- 503 diff --git a/R/db.R b/R/db.R index c2f6846..0343988 100644 --- a/R/db.R +++ b/R/db.R @@ -139,21 +139,21 @@ create_study <- function( r } -save_patient <- function(study_id, arm_id){ - - r <- tryCatch({ - randomized_patient <- DBI::dbGetQuery( - db_connection_pool, - "INSERT INTO patient (arm_id, study_id) +save_patient <- function(study_id, arm_id) { + r <- tryCatch( + { + randomized_patient <- DBI::dbGetQuery( + db_connection_pool, + "INSERT INTO patient (arm_id, study_id) VALUES ($1, $2) RETURNING id, arm_id", - list(arm_id, study_id) - ) - }, - error = function(cond) { - logger::log_error("Error randomizing patient: {cond}", cond=cond) - list(error = conditionMessage(cond)) - } + list(arm_id, study_id) + ) + }, + error = function(cond) { + logger::log_error("Error randomizing patient: {cond}", cond = cond) + list(error = conditionMessage(cond)) + } ) return(r) From 52dd63e03ce3021fe879333a75b9726b8003748f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 9 Feb 2024 11:13:11 +0000 Subject: [PATCH 180/240] fix tests --- DESCRIPTION | 1 + R/db.R | 20 +++- renv.lock | 319 ++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 310 insertions(+), 30 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 97e5be0..86bfa8c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -36,6 +36,7 @@ Suggests: callr, httr2, RPostgres, + pool, testthat (>= 3.0.0), usethis, withr, diff --git a/R/db.R b/R/db.R index ea8b3c9..e169868 100644 --- a/R/db.R +++ b/R/db.R @@ -15,15 +15,23 @@ #' pool <- create_db_connection_pool() #' } create_db_connection_pool <- purrr::insistently(function() { + dbname <- Sys.getenv("POSTGRES_DB") + host <- Sys.getenv("POSTGRES_HOST") + port <- Sys.getenv("POSTGRES_PORT", 5432) + user <- Sys.getenv("POSTGRES_USER") + password <- Sys.getenv("POSTGRES_PASSWORD") + print( + glue::glue("Creating database connection pool to {dbname} at {host}:{port} as {user}") + ) pool::dbPool( RPostgres::Postgres(), - dbname = Sys.getenv("POSTGRES_DB"), - host = Sys.getenv("POSTGRES_HOST"), - port = Sys.getenv("POSTGRES_PORT", 5432), - user = Sys.getenv("POSTGRES_USER"), - password = Sys.getenv("POSTGRES_PASSWORD") + dbname = dbname, + host = host, + port = port, + user = user, + password = password ) -}, rate = purrr::rate_delay(2, max_times = 15)) +}, rate = purrr::rate_delay(1, max_times = 15), quiet = FALSE) get_similar_studies <- function(name, identifier) { diff --git a/renv.lock b/renv.lock index c22269d..e2e732f 100644 --- a/renv.lock +++ b/renv.lock @@ -431,6 +431,51 @@ ], "Hash": "3f038e5ac7f41d4ac41ce658c85e3042" }, + "codetools": { + "Package": "codetools", + "Version": "0.2-19", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R" + ], + "Hash": "c089a619a7fae175d149d89164f8c7d8" + }, + "coin": { + "Package": "coin", + "Version": "1.4-3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "libcoin", + "matrixStats", + "methods", + "modeltools", + "multcomp", + "mvtnorm", + "parallel", + "stats", + "stats4", + "survival", + "utils" + ], + "Hash": "4084b5070a40ad99dad581ed3b67bd55" + }, + "colorspace": { + "Package": "colorspace", + "Version": "2.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "methods", + "stats" + ], + "Hash": "f20c47fd52fae58b4e377c37bb8c335b" + }, "commonmark": { "Package": "commonmark", "Version": "1.9.0", @@ -849,9 +894,9 @@ }, "glue": { "Package": "glue", - "Version": "1.6.2", + "Version": "1.7.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "methods" @@ -1111,6 +1156,30 @@ "Repository": "RSPM", "Hash": "6154ec2223172bce8162d4153cda21f7" }, + "insight": { + "Package": "insight", + "Version": "0.19.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "methods", + "stats", + "utils" + ], + "Hash": "adcc19435135a4d211e5aa2e48e4f6b7" + }, + "isoband": { + "Package": "isoband", + "Version": "0.2.7", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "grid", + "utils" + ], + "Hash": "0080607b4a1a7b28979aecef976d8bc2" + }, "jquerylib": { "Package": "jquerylib", "Version": "0.1.4", @@ -1349,6 +1418,129 @@ ], "Hash": "fec5f52652d60615fdb3957b3d74324a" }, + "minqa": { + "Package": "minqa", + "Version": "1.2.6", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "Rcpp" + ], + "Hash": "f48238f8d4740426ca12f53f27d004dd" + }, + "mitools": { + "Package": "mitools", + "Version": "2.4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "DBI", + "methods", + "stats" + ], + "Hash": "a4b659bd0528226724d55034f11ed7cb" + }, + "modeltools": { + "Package": "modeltools", + "Version": "0.2-23", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "methods", + "stats", + "stats4" + ], + "Hash": "f5a957c02222589bdf625a67be68b2a9" + }, + "mstate": { + "Package": "mstate", + "Version": "0.3.2", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "RColorBrewer", + "data.table", + "lattice", + "rlang", + "survival", + "viridisLite" + ], + "Hash": "53ca2f4a1ab4ac93fec33c92dc22c886" + }, + "multcomp": { + "Package": "multcomp", + "Version": "1.4-25", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "TH.data", + "codetools", + "graphics", + "mvtnorm", + "sandwich", + "stats", + "survival" + ], + "Hash": "2688bf2f8d54c19534ee7d8a876d9fc7" + }, + "munsell": { + "Package": "munsell", + "Version": "0.5.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "colorspace", + "methods" + ], + "Hash": "6dfe8bf774944bd5595785e3229d8771" + }, + "mvnfast": { + "Package": "mvnfast", + "Version": "0.2.8", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "BH", + "Rcpp", + "RcppArmadillo" + ], + "Hash": "e65cac8e8501bdfbdca0412c37bb18c9" + }, + "mvtnorm": { + "Package": "mvtnorm", + "Version": "1.2-4", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats" + ], + "Hash": "17e96668f44a28aef0981d9e17c49b59" + }, + "nlme": { + "Package": "nlme", + "Version": "3.1-162", + "Source": "Repository", + "Repository": "CRAN", + "Requirements": [ + "R", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "0984ce8da8da9ead8643c5cbbb60f83e" + }, + "numDeriv": { + "Package": "numDeriv", + "Version": "2016.8-1.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "df58958f293b166e4ab885ebcad90e02" + }, "openssl": { "Package": "openssl", "Version": "2.1.1", @@ -1584,6 +1776,20 @@ ], "Hash": "aa5a3864397ce6ae03458f98618395a1" }, + "progress": { + "Package": "progress", + "Version": "1.2.3", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "crayon", + "hms", + "prettyunits" + ], + "Hash": "f4625e061cb2865f111b47ff163a5ca6" + }, "promises": { "Package": "promises", "Version": "1.2.1", @@ -1766,28 +1972,6 @@ ], "Hash": "b5047343b3825f37ad9d3b5d89aa1078" }, - "rcmdcheck": { - "Package": "rcmdcheck", - "Version": "1.4.0", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "R6", - "callr", - "cli", - "curl", - "desc", - "digest", - "pkgbuild", - "prettyunits", - "rprojroot", - "sessioninfo", - "utils", - "withr", - "xopen" - ], - "Hash": "8f25ebe2ec38b1f2aef3b0d2ef76f6c4" - }, "rematch2": { "Package": "rematch2", "Version": "2.1.2", @@ -1925,6 +2109,19 @@ ], "Hash": "a9881dfed103e83f9de151dc17002cd1" }, + "sandwich": { + "Package": "sandwich", + "Version": "3.1-0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "stats", + "utils", + "zoo" + ], + "Hash": "1cf6ae532f0179350862fefeb0987c9b" + }, "sass": { "Package": "sass", "Version": "0.4.8", @@ -1939,6 +2136,26 @@ ], "Hash": "168f9353c76d4c4b0a0bbf72e2c2d035" }, + "scales": { + "Package": "scales", + "Version": "1.3.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "R6", + "RColorBrewer", + "cli", + "farver", + "glue", + "labeling", + "lifecycle", + "munsell", + "rlang", + "viridisLite" + ], + "Hash": "c19df082ba346b0ffa6f833e92de34d1" + }, "sessioninfo": { "Package": "sessioninfo", "Version": "1.2.2", @@ -1986,6 +2203,24 @@ ], "Hash": "3a1f41807d648a908e3c7f0334bf85e6" }, + "simstudy": { + "Package": "simstudy", + "Version": "0.7.1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "Rcpp", + "backports", + "data.table", + "fastglm", + "glue", + "methods", + "mvnfast", + "pbv" + ], + "Hash": "deb66424ac81e3aa78066791e0e6b97f" + }, "sodium": { "Package": "sodium", "Version": "1.3.0", @@ -2231,6 +2466,27 @@ ], "Hash": "5ac22900ae0f386e54f1c307eca7d843" }, + "truncnorm": { + "Package": "truncnorm", + "Version": "1.0-9", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "ef5b32c5194351ff409dfb37ca9468f1" + }, + "tzdb": { + "Package": "tzdb", + "Version": "0.4.0", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "cpp11" + ], + "Hash": "f561504ec2897f4d46f0c7657e488ae1" + }, "urlchecker": { "Package": "urlchecker", "Version": "1.0.1", @@ -2453,6 +2709,21 @@ "Source": "Repository", "Repository": "RSPM", "Hash": "fcc4bd8e6da2d2011eb64a5e5cc685ab" + }, + "zoo": { + "Package": "zoo", + "Version": "1.8-12", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R", + "grDevices", + "graphics", + "lattice", + "stats", + "utils" + ], + "Hash": "5c715954112b45499fb1dadc6ee6ee3e" } } } From af7cf2a53bd9c3471119d39ed87c5bff3bb67315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 9 Feb 2024 12:30:09 +0000 Subject: [PATCH 181/240] fix docker build and enable image building pipeline to avoid broken docker builds in the future --- .github/workflows/docker-publish.yml | 2 ++ Dockerfile | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1490c6a..a2c680b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -10,6 +10,8 @@ on: branches: [ "main", "devel" ] # Publish semver tags as releases. tags: [ 'v*.*.*' ] + pull_request: + branches: [main, devel] workflow_dispatch: env: diff --git a/Dockerfile b/Dockerfile index 1407623..1c8d604 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,12 +22,14 @@ ENV RENV_CONFIG_SANDBOX_ENABLED=FALSE COPY ./renv ./renv COPY .Rprofile . + +# Both renv.lock and DESCRIPTION are needed to restore the R environment COPY renv.lock . +COPY DESCRIPTION . RUN R -e 'renv::restore()' COPY .Rbuildignore . -COPY DESCRIPTION . COPY NAMESPACE . COPY inst/ ./inst COPY R/ ./R From b704ca06a1371674c5b730ab30b0a40906729d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 12 Feb 2024 09:17:31 +0000 Subject: [PATCH 182/240] update renv --- renv.lock | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/renv.lock b/renv.lock index 8cbb8cb..c4ecca1 100644 --- a/renv.lock +++ b/renv.lock @@ -2557,6 +2557,16 @@ ], "Hash": "1fe17157424bb09c48a8b3b550c753bc" }, + "uuid": { + "Package": "uuid", + "Version": "1.1-1", + "Source": "Repository", + "Repository": "RSPM", + "Requirements": [ + "R" + ], + "Hash": "3d78edfb977a69fc7a0341bee25e163f" + }, "vctrs": { "Package": "vctrs", "Version": "0.6.4", From 29445652f79364e1beb1647b1299a4165e844dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 12 Feb 2024 09:18:19 +0000 Subject: [PATCH 183/240] do not export setup_sentry function --- R/run-api.R | 2 -- 1 file changed, 2 deletions(-) diff --git a/R/run-api.R b/R/run-api.R index 3f8e0de..2fa11a6 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -77,8 +77,6 @@ run_unbiased <- function() { #' If not set, it defaults to "unspecified". #' #' @seealso \url{https://docs.sentry.io/} -#' -#' @export setup_sentry <- function() { sentry_dsn <- Sys.getenv("SENTRY_DSN") if (sentry_dsn == "") { From 044d21d4f21cafbd8c5a1452ae9d298a9dd38dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 12 Feb 2024 10:28:27 +0000 Subject: [PATCH 184/240] add missing comma --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 30fb19c..17fc68b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -45,7 +45,7 @@ Suggests: jsonlite, purrr, knitr, - rmarkdown + rmarkdown, sentryR RdMacros: mathjaxr Config/testthat/edition: 3 From b4d1ceb1c0fac79e50285275ef3b0961289689da Mon Sep 17 00:00:00 2001 From: Kinga Date: Mon, 12 Feb 2024 12:19:51 +0000 Subject: [PATCH 185/240] GET /study endpoint --- R/api_get_study.R | 12 ++++++++++++ inst/plumber/unbiased_api/study.R | 15 +++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 R/api_get_study.R diff --git a/R/api_get_study.R b/R/api_get_study.R new file mode 100644 index 0000000..14d767a --- /dev/null +++ b/R/api_get_study.R @@ -0,0 +1,12 @@ +api_get_study <- function(res, req){ + db_connection_pool <- get("db_connection_pool") + + + study_list <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(study_id = id, name, method, timestamp) |> + dplyr::collect() |> + tibble::as_tibble() + + return(study_list) +} diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index f613b4e..4e8bd59 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -42,3 +42,18 @@ function(study_id, current_state, req, res) { unbiased:::api__randomize_patient(study_id, current_state, req, res) ) } + +#' Get all available studies +#' +#' @return tibble with study_id, identifier, name and method +#' +#' @tag other +#' @get / +#' +#' @serializer unboxedJSON +#' +function(req, res){ + return( + unbiased:::api_get_study(req, res) + ) +} From 0517753536f7ee165d1cfd904a8b462734b5b42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 13 Feb 2024 08:28:00 +0000 Subject: [PATCH 186/240] add some tests for sentry integration --- R/run-api.R | 4 + start_unbiased_api.sh | 0 tests/testthat/test-run-api.R | 74 +++++++++++++++++++ .../minimization_randomization_comparison.Rmd | 3 +- 4 files changed, 80 insertions(+), 1 deletion(-) mode change 100644 => 100755 start_unbiased_api.sh create mode 100644 tests/testthat/test-run-api.R diff --git a/R/run-api.R b/R/run-api.R index 2fa11a6..32bf0a8 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -48,6 +48,10 @@ run_unbiased <- function() { } } +# hack to make sure we can mock the globalCallingHandlers +# this method needs to be present in the package environment for mocking to work +# linter disabled intentionally since this is internal method and cannot be renamed +globalCallingHandlers <- NULL # nolint #' setup_sentry function #' diff --git a/start_unbiased_api.sh b/start_unbiased_api.sh old mode 100644 new mode 100755 diff --git a/tests/testthat/test-run-api.R b/tests/testthat/test-run-api.R new file mode 100644 index 0000000..b404e05 --- /dev/null +++ b/tests/testthat/test-run-api.R @@ -0,0 +1,74 @@ +testthat::test_that("uses correct environment variables when setting up sentry", { + withr::local_envvar( + c( + SENTRY_DSN = "https://sentry.io/123", + GITHUB_SHA = "abc", + SENTRY_ENVIRONMENT = "production", + SENTRY_RELEASE = "1.0.0" + ) + ) + + testthat::local_mocked_bindings( + configure_sentry = function(dsn, + app_name, + app_version, + environment, + release) { + testthat::expect_equal(dsn, "https://sentry.io/123") + testthat::expect_equal(app_name, "unbiased") + testthat::expect_equal(app_version, "abc") + testthat::expect_equal(environment, "production") + testthat::expect_equal(release, "1.0.0") + }, + .package = "sentryR", + ) + + global_calling_handlers_called <- FALSE + + # mock globalCallingHandlers + testthat::local_mocked_bindings( + globalCallingHandlers = function(error) { + global_calling_handlers_called <<- TRUE + testthat::expect_equal( + unbiased:::global_calling_handler, + error + ) + }, + ) + + unbiased:::setup_sentry() + + testthat::expect_true(global_calling_handlers_called) +}) + +testthat::test_that("skips sentry setup if SENTRY_DSN is not set", { + withr::local_envvar( + c( + SENTRY_DSN = "" + ) + ) + + testthat::local_mocked_bindings( + configure_sentry = function(dsn, + app_name, + app_version, + environment, + release) { + # should not be called, so we fail the test + testthat::expect_true(FALSE) + }, + .package = "sentryR", + ) + + was_called <- FALSE + + # mock globalCallingHandlers + testthat::local_mocked_bindings( + globalCallingHandlers = function(error) { + was_called <<- TRUE + }, + ) + + testthat::expect_message(unbiased:::setup_sentry(), "SENTRY_DSN not set, skipping Sentry setup") + testthat::expect_false(was_called) +}) diff --git a/vignettes/articles/minimization_randomization_comparison.Rmd b/vignettes/articles/minimization_randomization_comparison.Rmd index 70758e1..bdac6eb 100644 --- a/vignettes/articles/minimization_randomization_comparison.Rmd +++ b/vignettes/articles/minimization_randomization_comparison.Rmd @@ -156,7 +156,8 @@ def <- simstudy::defData(def, varname = "hba1c", formula = "0.888", dist = "bina def <- simstudy::defData(def, varname = "tpo2", formula = "0.354", dist = "binary") # correlation with diabetes type def <- simstudy::defData( - def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary" + def, + varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary" ) # <= 2 - 0.302 def <- simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") From 23294cbd1281725a92b1c090e603b31200645dbc Mon Sep 17 00:00:00 2001 From: lwalejko Date: Tue, 13 Feb 2024 08:32:11 +0000 Subject: [PATCH 187/240] Update documentation --- man/setup_sentry.Rd | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 man/setup_sentry.Rd diff --git a/man/setup_sentry.Rd b/man/setup_sentry.Rd new file mode 100644 index 0000000..8de319a --- /dev/null +++ b/man/setup_sentry.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/run-api.R +\name{setup_sentry} +\alias{setup_sentry} +\title{setup_sentry function} +\usage{ +setup_sentry() +} +\arguments{ +\item{None}{} +} +\value{ +None. If the SENTRY_DSN environment variable is not set, the function will +return a message and stop execution. +} +\description{ +This function is used to configure Sentry, a service for real-time error tracking. +It uses the sentryR package to set up Sentry based on environment variables. +} +\details{ +The function first checks if the SENTRY_DSN environment variable is set. If not, it +returns a message and stops execution. +If SENTRY_DSN is set, it uses the sentryR::configure_sentry function to set up Sentry with +the following parameters: +\itemize{ +\item dsn: The Data Source Name (DSN) is retrieved from the SENTRY_DSN environment variable. +\item app_name: The application name is set to "unbiased". +\item app_version: The application version is retrieved from the GITHUB_SHA environment variable. +If not set, it defaults to "unspecified". +\item environment: The environment is retrieved from the SENTRY_ENVIRONMENT environment variable. +If not set, it defaults to "development". +\item release: The release is retrieved from the SENTRY_RELEASE environment variable. +If not set, it defaults to "unspecified". +} +} +\examples{ +setup_sentry() + +} +\seealso{ +\url{https://docs.sentry.io/} +} From a11588cd95943e09484114a43bd184666a7a31c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 13 Feb 2024 09:23:09 +0000 Subject: [PATCH 188/240] test for global_calling_handler --- tests/testthat/test-run-api.R | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/testthat/test-run-api.R b/tests/testthat/test-run-api.R index b404e05..d5fd1bf 100644 --- a/tests/testthat/test-run-api.R +++ b/tests/testthat/test-run-api.R @@ -72,3 +72,20 @@ testthat::test_that("skips sentry setup if SENTRY_DSN is not set", { testthat::expect_message(unbiased:::setup_sentry(), "SENTRY_DSN not set, skipping Sentry setup") testthat::expect_false(was_called) }) + +testthat::test_that("global_calling_handler captures exception and signals condition", { + error <- simpleError("test error") + + capture_exception_called <- FALSE + + testthat::local_mocked_bindings( + capture_exception = function(error) { + capture_exception_called <<- TRUE + testthat::expect_equal(error, error) + }, + .package = "sentryR", + ) + + testthat::expect_error(unbiased:::global_calling_handler(error)) + testthat::expect_true(capture_exception_called) +}) From f1f97340dfdc2ef4d6fbf4a19317ab2c6f3dfbae Mon Sep 17 00:00:00 2001 From: Jagoda Date: Tue, 13 Feb 2024 13:43:00 +0100 Subject: [PATCH 189/240] README after Kamil's review --- README.md | 99 ++++++++++++++++++++++--------------------------------- 1 file changed, 39 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 5216e49..8ba83c4 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,18 @@ -# **unbiased**: An R package for Clinical Trial Randomization +# **unbiased**: An API-based solution for Clinical Trial Randomization -The challenge of allocating participants fairly and efficiently is a cornerstone for the success of clinical trials. Recognizing this critical need, we developed the **unbiased** package. This tool is designed to offer a comprehensive suite of randomization algorithms, suitable for a wide range of clinical trial designs. +In clinical trials, the fair and efficient allocation of participants is essential for achieving reliable results. While there are many excellent R randomization packages available, none, to our knowledge, provide a dedicated API for this purpose. The **unbiased** package fills this gap by featuring a production-ready REST API designed for seamless integration. This unique combination allows for easy connection with electronic Case Report Forms (eCRF), enhancing data management and streamlining participant allocation. ## Why choose **unbiased**? Our goal in creating **unbiased** was to provide a user-friendly yet powerful tool that addresses the nuanced demands of clinical trial randomization. It offers: -- **Ease of Integration**: Designed to fit effortlessly into your research workflow. +- **Production-Ready REST API**: Built for seamless integration with eCRF/EDC systems, facilitating real-time randomization and automation. +- **Extensive Database Integration**: Supports robust data management practices, ensuring that participant information and randomization outcomes are securely managed and easily accessible. +- **Commitment to Quality**: Emphasizes development best practices, including comprehensive code coverage, to deliver a reliable and trustworthy solution. - **Adaptability**: Whether for small-scale studies or large, multi-center trials, **unbiased** scales to meet your needs. - **Comprehensive Documentation**: To support you in applying the package effectively. -By choosing **unbiased**, you're adopting a sophisticated approach to trial randomization, ensuring fair and efficient participant allocation across your studies. - -## Core features - -The **unbiased** package integrates dynamic and traditional randomization methods, including: - -- **Minimization Method**: For balanced allocation considering covariates. -- **Simple Randomization**: For straightforward, unbiased participant assignment. -- **Block Randomization**: To ensure equal group sizes throughout the trial. - -Available both as a standard R package and through an API, **unbiased** provides flexibility for researchers. It ensures seamless integration with electronic Case Report Form (eCRF) systems, facilitating efficient patient management. +By choosing **unbiased**, you're adopting a sophisticated approach to trial randomization, ensuring fair and efficient participant allocation across your studies and support of the broader objectives of clinical research through technology. ## Table of Contents @@ -29,13 +21,10 @@ Available both as a standard R package and through an API, **unbiased** provides - [Comparative Analysis of Randomization Methods](#comparative-analysis-of-randomization-methods) - [Comparison with Other Solutions](#comparison-with-other-solutions) 2. [Quickstart Guide](#quickstart-guide) - - [Installation Instructions](#installation-instructions) - - [Deploying the API](#deploying-the-api) - - [API Configuration](#api-configuration) + - [Quick Setup with Docker](#quick-setup-with-docker) + - [API Configuration](#api-configuration) + - [Alternative Installation Method](#alternative-installation-method) 3. [Getting started with **unbiased**](#getting-started-with-unbiased) - - [Using Randomization Functions within R](#using-randomization-functions-within-r) - - [Simple Randomization](#simple-randomization) - - [Minimization Method](#minimization-method) - [API Endpoints](#api-endpoints) - [Study Creation](#study-creation) - [Patient Randomization](#patient-randomization) @@ -46,6 +35,7 @@ Available both as a standard R package and through an API, **unbiased** provides - [Executing Tests from the Command Line](#executing-tests-from-the-command-line) - [Running Tests with Docker Compose](#running-tests-with-docker-compose) - [Code Coverage](#code-coverage) + - [Configuring Sentry](#configuring-sentry) # Background @@ -81,33 +71,33 @@ Depending on the aims and objectives of the randomised trial, the **unbiased** a ![Comparison of covariate balances.](vignettes/boxplot.png) ... -To find out more, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/minimization_randomization_comparison.Rmd). +To find out more, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/articles/minimization_randomization_comparison.Rmd). ## Comparison with other solutions -There are many packages that perform specific randomization methods in R. Most of them are designed for stratified randomization, and permuted blocks -e.g. blockrand, randomizeR. More recently, there have also been options for using minimization randomization - randpack, or minirand. +There are many packages that perform specific randomization methods in R. Most of them are designed for stratified randomization and permuted blocks, such as [blockrand](https://CRAN.R-project.org/package=blockrand) and [randomizeR](https://CRAN.R-project.org/package=randomizeR). Some of them also utilize the options for using minimization randomization - e.g. [randpack]( https://bioconductor.org/packages/randPack/) or [Minirand]( https://CRAN.R-project.org/package=Minirand). -Unlike the other packages, unbiased incorporates several different types of minimization algorithms - from simple simple randomization methods to advanced ones based on the Pocok minimization method. In addition, the advantage of using unbiased is that it can be used in the form of an API, which is not possible in the existing software, making **unbiased** appear complete from the point of view of usability, as well as the possibility of testing multiple methods for an individual study within a single package. +Our unique contribution to the landscape is the integration of a comprehensive API and a commitment to rigorous testing. This dual focus ensures that **unbiased** not only supports the practical needs of clinical trials, but also aligns with the technical requirements of modern clinical research environments. By prioritizing these aspects, **unbiased** addresses a critical gap in the market: the need for an eCRF-compatible randomization solution that is both dependable and easily integrated into existing workflows. This, together with the implementation of minimization techniques, sets **unbiased** apart as a novel, comprehensive tool. # Quickstart Guide Initiating your work with **unbiased** involves simple setup steps. Whether you're integrating it into your R environment or deploying its API, we aim to equip you with a reliable tool that enhances the integrity and efficiency of your clinical trials. -## Installation instructions +## Quick Setup with Docker -The **unbiased** package can be installed from GitHub using the `devtools` package. To install **unbiased**, run the following command in your R environment: +The most straightforward way to deploy **unbiased** is through our Docker images. This ensures that you can get **unbiased** up and running with minimal setup, regardless of your local environment. To use **unbiased**, pull the latest Docker image: -```R -devtools::install_github("ttscience/unbiased") +```sh +docker pull ghcr.io/ttscience/unbiased ``` -## Deploying the API +To run **unbiased** with Docker, ensuring you have set the necessary environment variables: -Execute the API by calling the`run_unbiased()` function: -```R -unbiased::run_unbiased() +```sh +docker run -e POSTGRES_DB=mydb -e POSTGRES_USER=myuser -e POSTGRES_PASSWORD=mypassword -e UNBIASED_PORT=3838 ghcr.io/ttscience/unbiased ``` -After running this command, the API should be up and running, as default listening on a port on your localhost (http://localhost:3838). You can interact with the API using any HTTP client, such as curl in the command line, Postman, or directly from R using packages like httr. + +This command starts the **unbiased** API, making it accessible on the specified port. It's crucial to have your PostgreSQL database ready, as **unbiased** will automatically configure the necessary database structures upon startup. ## API configuration @@ -121,47 +111,36 @@ The **unbiased** API server can be configured using environment variables. The f - `UNBIASED_HOST`: The host on which the API will run. Defaults to `0.0.0.0` if not provided. - `UNBIASED_PORT`: The port on which the API will listen. Defaults to `3838` if not provided. -# Getting started with **unbiased** +## Alternative Installation Method -The **unbiased** package offers functions for randomizing participants in clinical trials, ensuring a fair and transparent process. +For those preferring to work directly within the R environment, the **unbiased** package offers an alternative installation method via GitHub. This approach allows users to easily integrate **unbiased** into their R projects. To proceed with this method, utilize the `devtools` package for installation by executing the following command: -### Simple Randomization +```R +devtools::install_github("ttscience/unbiased") +``` -Use `simple_randomization` for uncomplicated, unbiased assignment, giving each participant an equal chance of being allocated to any group. This method requires specifying the `arms` and `ratio` parameters, where `arms` is a vector of treatment group names, and `ratio` is a vector of integers indicating the allocation proportions. +Following the package installation, the **unbiased** API can be launched within R. Simply invoke the `run_unbiased()` function to start the API: ```R -# Treatment group assignments with a 1:1 ratio -treatment_group <- - randomize_simple( - arms = c("treatment", "placebo"), - ratio = c("treatment" = 1, "placebo" = 1) - ) +unbiased::run_unbiased() ``` -*Note: Ensure that the `ratio` parameter accurately reflects an allocation proportion vector, using numeric values to denote the proportions.* +This initiates the API server, by default, on your local machine (http://localhost:3838), making it accessible for interaction through various HTTP clients, including curl, Postman, or R's `httr` package. -### Minimization Method -The minimization method considers existing participant assignments to minimize bias. New participants are allocated based on an imbalance score, calculated using specified weights for each covariate. This method dynamically adjusts to maintain balance across treatment groups. +# Getting started with **unbiased** -```R -# Treatment group assignment considering previous participants' data -treatment_group <- randomize_minimisation_pocock( - arms = c("treatment", "placebo"), - current_state = previous_data, - weights = c( - "sex" = 1, - "age" = 1 - ), - ratio = c(1, 1), # Ensure ratio is defined correctly - method = "var", - p = 0.85 -) -``` +The **unbiased** package offers functions for randomizing participants in clinical trials, ensuring a fair and transparent process. + +Complete documentation for the implemented methodology and examples of how to use them are available on our GitHub Pages, providing all the information you need to integrate **unbiased** into your trial management workflow. Below, we present the basic steps for using **unbiased** through the API. ## API Endpoints -The **unbiased** API facilitates randomization and clinical trial management via HTTP clients. +The **unbiased** API is designed to facilitate clinical trial management through a set of endpoints: + +- **Study Management**: Create and configure new studies, including specifying randomization parameters and treatment arms. +- **Participant Randomization**: Dynamically randomize participants to treatment groups based on the study's configuration and existing participant data. + ### Study Creation From 2b3c6c8ed3c402e54656e91da226bcb7eaced097 Mon Sep 17 00:00:00 2001 From: Ola Date: Tue, 13 Feb 2024 13:19:50 +0000 Subject: [PATCH 190/240] changes in part of purpose and scope delate comparative part --- README.md | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8ba83c4..0f93ed4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ By choosing **unbiased**, you're adopting a sophisticated approach to trial rand 1. [Background](#background) - [Purpose and Scope for Clinical Trial Randomization](#purpose-and-scope-for-clinical-trial-randomization) - - [Comparative Analysis of Randomization Methods](#comparative-analysis-of-randomization-methods) - [Comparison with Other Solutions](#comparison-with-other-solutions) 2. [Quickstart Guide](#quickstart-guide) - [Quick Setup with Docker](#quick-setup-with-docker) @@ -42,36 +41,14 @@ By choosing **unbiased**, you're adopting a sophisticated approach to trial rand ## Purpose and Scope for Clinical Trial Randomization -Randomization is the gold standard for conducting clinical trials and a fundamental aspect of clinical trials, in studies comparing two or more arms. Although there are sometimes ethical constraints preventing the use of randomization, in most cases randomization is a desirable technique that will ensure that patients are randomly allocated to defined groups. +Randomization is the gold standard for conducting clinical trials and a fundamental aspect of clinical trials, in studies comparing two or more arms. In most cases randomization is a desirable technique that will ensure that patients are randomly allocated to defined groups. This is essential for maintaining the integrity of the trial and ensuring that the results are reliable, and blinding of research personnel. However, there are situations where it is desirable for studies to balance patients in terms of numbers in each group or, in addition, to achieve balance with respect to other relevant factors, such as sex or diabetes type. Adequate selection of randomization methods allows the intended randomization goals to be realized. -Randomization then ensures that the predictability of the allocation of consecutive patients to groups is blinded, allowing the study participants overseeing the clinical trial to be appropriately blinded. This is essential for maintaining the integrity of the trial and ensuring that the results are reliable. +**Unbiased** compared to standard and most commonly used randomization methods, e.g. the simple method or the block method, apart from these methods, additionally offers enhanced features of more flexible adaptive methods, which are based on current information about the allocation of patients in the trial. Compared to, for example, block randomization, adaptive randomization not only ensures relatively equal allocation to patient groups, but also allows the groups to be balanced on the basis of certain important covariates, which is its key advantage. This randomization requires predefined criteria, such as the probability with which a given patient will be assigned to a group based on minimizing the total imbalance, or weights that can be assigned personally for each individual covariate. Its advanced algorithmic approach sets it apart from others by minimizing selection bias and improving the overall efficiency of the randomization process in clinical trials. -However, there are situations where it is desirable for studies to balance patients in terms of numbers in each group or, in addition, to achieve balance with respect to other relevant factors, such as sex or diabetes type. - -Adequate selection of randomization methods allows the intended randomization goals to be realized; however, in the case of balance between groups in terms of patient characteristics, more adaptive methods of patient allocation are required, e.g. by verifying the overall imbalance on the basis of current allocations to the study groups. This is ensured, for example, by using the minimization method. - -**Unbiased** specifically caters to the needs of clinical trial randomization. It streamlines the randomization process, ensuring a balanced and impartial allocation of participants across different trial groups, which is vital for minimizing bias and ensuring the reliability of trial outcomes. Unbiased allows the use of simple, block and advanced randomization methods relevant to the conduct of clinical trials. Consequently, it addresses the needs arising from the need to balance against key variables, ensuring that the population in each treatment group is as comparable as possible. - -## Comparative Analysis of Randomization Methods - -**Unbiased** compared to standard and most commonly used randomization methods, e.g. the simple method or the block method, additionally offers enhanced features of more flexible adaptive methods, which are based on current information about the allocation of patients in the trial. Compared to, for example, block randomization, adaptive randomization not only ensures relatively equal allocation to patient groups, but also allows the groups to be balanced on the basis of certain important covariates, which is its key advantage. This randomization requires predefined criteria, such as the probability with which a given patient will be assigned to a group based on minimizing the total imbalance, or weights that can be assigned personally for each individual covariate. Its advanced algorithmic approach sets it apart from others by minimizing selection bias and improving the overall efficiency of the randomization process in clinical trials. - -The **unbiased** package offers the use of different randomization methods, each with its own strengths and limitations. The choice of randomization method will depend on the specific requirements of the trial, including the number of treatment groups, the size of the trial, and the need for stratification or covariate balance. - -The **unbiased** package includes the following randomization methods: - -- **Simple Randomization**: This is the most basic form of randomization, in which participants are assigned to treatment groups with equal probability. This method is simple and easy to implement. Since this is simple randomization, it does not take into account the initial covariates, and treatment assignment occurs randomly (flip coin method). - -- **Minimization Method**: This method is designed to minimize imbalances in baseline characteristics between treatment groups. It uses an adaptive algorithm to assign participants to treatment groups based on their baseline characteristics, with the goal of achieving balance across treatment groups. - -- **Block Randomization**: This method involves dividing participants into blocks and then randomly assigning them to treatment groups within each block. This ensures that the number of participants in each treatment group is balanced over time, but it does not account for any potential imbalances in baseline characteristics between treatment groups. - -Depending on the aims and objectives of the randomised trial, the **unbiased** approach allows a choice of alternative methods to effectively implement appropriate algorithms for the randomized patient allocation process in a clinical trial. A comparison of these methods is shown in a boxplot, where a lower threshold value of the SMD index indicates a greater balance in covariates retained by the method. - -![Comparison of covariate balances.](vignettes/boxplot.png) +**Unbiased** allows the use of simple, block and adaptive minimization randomization methods relevant to the conduct of clinical trials, so package caters to the needs of clinical trial randomization. ... -To find out more, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/articles/minimization_randomization_comparison.Rmd). +To find out more on differences in randomization methods, read our vignette on [Comparative Analysis of Randomization Methods](vignettes/articles/minimization_randomization_comparison.Rmd). ## Comparison with other solutions From ddcfcf6191d9d6aee642721a8035924c15364690 Mon Sep 17 00:00:00 2001 From: kamilsi Date: Thu, 15 Feb 2024 10:52:03 +0000 Subject: [PATCH 191/240] Update documentation --- man/unbiased-package.Rd | 1 + 1 file changed, 1 insertion(+) diff --git a/man/unbiased-package.Rd b/man/unbiased-package.Rd index 1ef7b04..3b046fa 100644 --- a/man/unbiased-package.Rd +++ b/man/unbiased-package.Rd @@ -23,6 +23,7 @@ Authors: \item Kinga Sałata \email{kinga.salata@ttsi.com.pl} \item Aleksandra Duda \email{aleksandra.duda@ttsi.com.pl} \item Łukasz Wałejko \email{lukasz.walejko@ttsi.com.pl} + \item Jagoda jagoda.glowacka-walas@ttsi.com.pl Głowacka-Walas (\href{https://orcid.org/0000-0002-7628-8691}{ORCID}) } Other contributors: From 7faabd3126650ad522bd9e6aa7626b29adaf4138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Thu, 15 Feb 2024 18:12:32 +0000 Subject: [PATCH 192/240] audit log first poc --- R/api_create_study.R | 4 + R/api_randomize.R | 6 ++ R/audit-trail.R | 150 ++++++++++++++++++++++++++++ inst/plumber/unbiased_api/meta.R | 1 + inst/plumber/unbiased_api/plumber.R | 18 +++- 5 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 R/audit-trail.R diff --git a/R/api_create_study.R b/R/api_create_study.R index 0ee2513..479936e 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -1,6 +1,8 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. identifier, name, method, arms, covariates, p, req, res) { + audit_log_event_type("study_create", req) + collection <- checkmate::makeAssertCollection() checkmate::assert( @@ -145,6 +147,8 @@ api__minimization_pocock <- function( )) } + audit_log_study_id(r$study$id, req) + response <- list( study = r$study ) diff --git a/R/api_randomize.R b/R/api_randomize.R index 855b3e6..8545bcb 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -50,6 +50,7 @@ parse_pocock_parameters <- } api__randomize_patient <- function(study_id, current_state, req, res) { + audit_log_event_type("randomize_patient", req) collection <- checkmate::makeAssertCollection() db_connection_pool <- get("db_connection_pool") @@ -66,6 +67,11 @@ api__randomize_patient <- function(study_id, current_state, req, res) { add = collection ) + # TODO: previous check should fail entire request with 404 if failed! + if (study_id |> is.numeric()) { + audit_log_study_id(study_id, req) + } + # Retrieve study details, especially the ones about randomization method_randomization <- dplyr::tbl(db_connection_pool, "study") |> diff --git a/R/audit-trail.R b/R/audit-trail.R new file mode 100644 index 0000000..f4b2463 --- /dev/null +++ b/R/audit-trail.R @@ -0,0 +1,150 @@ +#' AuditLog Class +#' +#' This class is used internally to store audit logs for each request. +AuditLog <- R6::R6Class( # nolint: object_name_linter. + "AuditLog", + public = list( + initialize = function(request_method, endpoint_url, request_body) { + private$request_id <- uuid::UUIDgenerate() + private$request_method <- request_method + private$endpoint_url <- endpoint_url + private$request_body <- request_body + }, + disable = function() { + private$disabled <- TRUE + }, + enable = function() { + private$disabled <- FALSE + }, + is_enabled = function() { + !private$disabled + }, + set_request_body = function(request_body) { + private$request_body <- request_body + }, + set_response_body = function(response_body) { + private$response_body <- response_body + }, + set_event_type = function(event_type) { + private$event_type <- event_type + }, + set_study_id = function(study_id) { + private$study_id <- study_id + }, + validate_log = function() { + if (private$disabled) { + stop("Audit log is disabled") + } + if (is.null(private$event_type)) { + stop("Event type not set for audit log. Please set the event type using `audit_log_event_type`") + } + }, + persist = function() { + if (private$disabled) { + return() + } + print("[Audit log begins here]") + print(glue::glue("Request ID: {private$request_id}")) + print(glue::glue("Event type: {private$event_type}")) + print(glue::glue("Study ID: {private$study_id}")) + print(glue::glue("Endpoint URL: {private$endpoint_url}")) + print(glue::glue("Request Method: {private$request_method}")) + print(glue::glue("Request Body: {private$request_body}")) + print(glue::glue("Response Body: {private$response_body}")) + print("[Audit log ends here]") + } + ), + private = list( + disabled = FALSE, + request_id = NULL, + event_type = NULL, + study_id = NULL, + endpoint_url = NULL, + request_method = NULL, + request_body = NULL, + response_body = NULL + ) +) + + +#' Set up audit trail +#' +#' This function sets up an audit trail for a given process. It uses plumber's hooks to log +#' information before routing (preroute) and after serializing the response (postserialize). +#' +#' This function modifies the plumber router in place and returns the updated router. +#' +#' The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is set to "true". +#' +#' @param pr A plumber router for which the audit trail is to be set up. +#' @return Returns the updated plumber router with the audit trail hooks. +#' @examples +#' pr <- plumber::plumb("your-api-definition.R") |> +#' setup_audit_trail() +setup_audit_trail <- function(pr) { + audit_log_enabled <- Sys.getenv("AUDIT_LOG_ENABLED", "true") |> as.logical() + if (!audit_log_enabled) { + print("Audit log is disabled") + return(pr) + } + print("Audit log is enabled") + pr |> + plumber::pr_hooks(list( + preroute = function(req, res) { + audit_log <- AuditLog$new( + request_method = req$REQUEST_METHOD, + endpoint_url = req$PATH_INFO, + request_body = req$body + ) + req$.internal.audit_log <- audit_log + }, + postserialize = function(req, res) { + audit_log <- req$.internal.audit_log + if (!audit_log$is_enabled()) { + return() + } + audit_log$validate_log() + audit_log$set_request_body(req$body) + audit_log$set_response_body(res$body) + audit_log$persist() + } + )) +} + +#' Set Audit Log Event Type +#' +#' This function sets the event type for an audit log. It retrieves the audit log from the request's +#' internal data, and then calls the audit log's set_event_type method with the provided event type. +#' +#' @param event_type The event type to be set for the audit log. +#' @param req The request object, which should contain an audit log in its internal data. +#' @return Returns nothing as it modifies the audit log in-place. +audit_log_event_type <- function(event_type, req) { + audit_log <- req$.internal.audit_log + if (!is.null(audit_log)) { + audit_log$set_event_type(event_type) + } +} + +#' Set Audit Log Study ID +#' +#' This function sets the study ID for an audit log. It retrieves the audit log from the request's +#' internal data, and then calls the audit log's set_study_id method with the provided study ID. +#' +#' @param study_id The study ID to be set for the audit log. +#' @param req The request object, which should contain an audit log in its internal data. +#' @return Returns nothing as it modifies the audit log in-place. +audit_log_study_id <- function(study_id, req) { + assert(!is.null(study_id) || is.numeric(study_id), "Study ID must be a number") + audit_log <- req$.internal.audit_log + if (!is.null(audit_log)) { + audit_log$set_study_id(study_id) + } +} + +audit_log_disable_for_request <- function(req) { + audit_log <- req$.internal.audit_log + if (!is.null(audit_log)) { + audit_log$disable() + } +} \ No newline at end of file diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 09622bf..0dc87eb 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -7,6 +7,7 @@ #* @get /sha #* @serializer unboxedJSON sentryR::with_captured_calls(function(req, res) { + audit_log_disable_for_request(req) sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") if (sha == "NULL") { res$status <- 404 diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 06add32..91026cb 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -19,14 +19,24 @@ #* #* @plumber function(api) { - meta <- plumber::pr("meta.R") |> - plumber::pr_set_error(sentryR::sentry_error_handler) - study <- plumber::pr("study.R") |> - plumber::pr_set_error(sentryR::sentry_error_handler) + meta <- plumber::pr("meta.R") + study <- plumber::pr("study.R") + + if (sentryR::is_sentry_configured()) { + meta |> + plumber::pr_set_error(sentryR::sentry_error_handler) + + study |> + plumber::pr_set_error(sentryR::sentry_error_handler) + + api |> + plumber::pr_set_error(sentryR::sentry_error_handler) + } api |> plumber::pr_mount("/meta", meta) |> plumber::pr_mount("/study", study) |> + setup_audit_trail() |> plumber::pr_set_api_spec(function(spec) { spec$ paths$ From 94943df1ea7dc8c304854babff3994726b0bc9f6 Mon Sep 17 00:00:00 2001 From: lwalejko Date: Thu, 15 Feb 2024 18:16:37 +0000 Subject: [PATCH 193/240] Update documentation --- man/AuditLog.Rd | 132 ++++++++++++++++++++++++++++++++++++ man/audit_log_event_type.Rd | 20 ++++++ man/audit_log_study_id.Rd | 20 ++++++ man/setup_audit_trail.Rd | 27 ++++++++ 4 files changed, 199 insertions(+) create mode 100644 man/AuditLog.Rd create mode 100644 man/audit_log_event_type.Rd create mode 100644 man/audit_log_study_id.Rd create mode 100644 man/setup_audit_trail.Rd diff --git a/man/AuditLog.Rd b/man/AuditLog.Rd new file mode 100644 index 0000000..3ce17ad --- /dev/null +++ b/man/AuditLog.Rd @@ -0,0 +1,132 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/audit-trail.R +\name{AuditLog} +\alias{AuditLog} +\title{AuditLog Class} +\description{ +This class is used internally to store audit logs for each request. +} +\section{Methods}{ +\subsection{Public methods}{ +\itemize{ +\item \href{#method-AuditLog-new}{\code{AuditLog$new()}} +\item \href{#method-AuditLog-disable}{\code{AuditLog$disable()}} +\item \href{#method-AuditLog-enable}{\code{AuditLog$enable()}} +\item \href{#method-AuditLog-is_enabled}{\code{AuditLog$is_enabled()}} +\item \href{#method-AuditLog-set_request_body}{\code{AuditLog$set_request_body()}} +\item \href{#method-AuditLog-set_response_body}{\code{AuditLog$set_response_body()}} +\item \href{#method-AuditLog-set_event_type}{\code{AuditLog$set_event_type()}} +\item \href{#method-AuditLog-set_study_id}{\code{AuditLog$set_study_id()}} +\item \href{#method-AuditLog-validate_log}{\code{AuditLog$validate_log()}} +\item \href{#method-AuditLog-persist}{\code{AuditLog$persist()}} +\item \href{#method-AuditLog-clone}{\code{AuditLog$clone()}} +} +} +\if{html}{\out{


}} +\if{html}{\out{
}} +\if{latex}{\out{\hypertarget{method-AuditLog-new}{}}} +\subsection{Method \code{new()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$new(request_method, endpoint_url, request_body)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-disable}{}}} +\subsection{Method \code{disable()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$disable()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-enable}{}}} +\subsection{Method \code{enable()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$enable()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-is_enabled}{}}} +\subsection{Method \code{is_enabled()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$is_enabled()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_request_body}{}}} +\subsection{Method \code{set_request_body()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_request_body(request_body)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_response_body}{}}} +\subsection{Method \code{set_response_body()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_response_body(response_body)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_event_type}{}}} +\subsection{Method \code{set_event_type()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_event_type(event_type)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_study_id}{}}} +\subsection{Method \code{set_study_id()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_study_id(study_id)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-validate_log}{}}} +\subsection{Method \code{validate_log()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$validate_log()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-persist}{}}} +\subsection{Method \code{persist()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$persist()}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-clone}{}}} +\subsection{Method \code{clone()}}{ +The objects of this class are cloneable with this method. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$clone(deep = FALSE)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{deep}}{Whether to make a deep clone.} +} +\if{html}{\out{
}} +} +} +} diff --git a/man/audit_log_event_type.Rd b/man/audit_log_event_type.Rd new file mode 100644 index 0000000..9a8adcc --- /dev/null +++ b/man/audit_log_event_type.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/audit-trail.R +\name{audit_log_event_type} +\alias{audit_log_event_type} +\title{Set Audit Log Event Type} +\usage{ +audit_log_event_type(event_type, req) +} +\arguments{ +\item{event_type}{The event type to be set for the audit log.} + +\item{req}{The request object, which should contain an audit log in its internal data.} +} +\value{ +Returns nothing as it modifies the audit log in-place. +} +\description{ +This function sets the event type for an audit log. It retrieves the audit log from the request's +internal data, and then calls the audit log's set_event_type method with the provided event type. +} diff --git a/man/audit_log_study_id.Rd b/man/audit_log_study_id.Rd new file mode 100644 index 0000000..636ea90 --- /dev/null +++ b/man/audit_log_study_id.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/audit-trail.R +\name{audit_log_study_id} +\alias{audit_log_study_id} +\title{Set Audit Log Study ID} +\usage{ +audit_log_study_id(study_id, req) +} +\arguments{ +\item{study_id}{The study ID to be set for the audit log.} + +\item{req}{The request object, which should contain an audit log in its internal data.} +} +\value{ +Returns nothing as it modifies the audit log in-place. +} +\description{ +This function sets the study ID for an audit log. It retrieves the audit log from the request's +internal data, and then calls the audit log's set_study_id method with the provided study ID. +} diff --git a/man/setup_audit_trail.Rd b/man/setup_audit_trail.Rd new file mode 100644 index 0000000..ac77f3a --- /dev/null +++ b/man/setup_audit_trail.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/audit-trail.R +\name{setup_audit_trail} +\alias{setup_audit_trail} +\title{Set up audit trail} +\usage{ +setup_audit_trail(pr) +} +\arguments{ +\item{pr}{A plumber router for which the audit trail is to be set up.} +} +\value{ +Returns the updated plumber router with the audit trail hooks. +} +\description{ +This function sets up an audit trail for a given process. It uses plumber's hooks to log +information before routing (preroute) and after serializing the response (postserialize). +} +\details{ +This function modifies the plumber router in place and returns the updated router. + +The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is set to "true". +} +\examples{ +pr <- plumber::plumb("your-api-definition.R") |> + setup_audit_trail() +} From d2ba21feb26a444671718afa443384c4cdd089e2 Mon Sep 17 00:00:00 2001 From: Kinga Date: Mon, 19 Feb 2024 10:27:12 +0000 Subject: [PATCH 194/240] Adding the GET/study and GET/study/{study_id} endpoints along with tests. Adding new tag: `read` for reading data from the database. --- R/api_get_study.R | 94 ++++++++++++++- inst/plumber/unbiased_api/plumber.R | 1 + inst/plumber/unbiased_api/study.R | 17 ++- tests/testthat/test-E2E-get-study.R | 114 ++++++++++++++++++ .../test-E2E-study-minimisation-pocock.R | 2 - 5 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 tests/testthat/test-E2E-get-study.R diff --git a/R/api_get_study.R b/R/api_get_study.R index 14d767a..7d8c5fd 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -1,12 +1,102 @@ -api_get_study <- function(res, req){ +api_get_study <- function(res, req) { db_connection_pool <- get("db_connection_pool") study_list <- dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(study_id = id, name, method, timestamp) |> + dplyr::select(study_id = id, identifier, name, method, last_edited = timestamp) |> dplyr::collect() |> tibble::as_tibble() return(study_list) } + +api_get_study_records <- function(study_id, req, res) { + db_connection_pool <- get("db_connection_pool") + + is_study <- + checkmate::test_subset( + x = req$args$study_id, + choices = dplyr::tbl(db_connection_pool, "study") |> + dplyr::select(id) |> + dplyr::pull() + ) + + if (!is_study) { + res$status <- 404 + return(list( + error = "Study not found", + details = r$error + )) + } + + study <- + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == !!study_id) |> + dplyr::select( + study_id = id, name, randomization_method = method, + last_edited = timestamp, parameters + ) |> + dplyr::collect() |> + tibble::remove_rownames() + + strata <- + dplyr::tbl(db_connection_pool, "stratum") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::select(stratum_id = id, stratum_name = name, value_type) |> + collect() |> + left_join( + bind_rows( + dplyr::tbl(db_connection_pool, "factor_constraint") |> + dplyr::collect(), + dplyr::tbl(db_connection_pool, "numeric_constraint") |> + dplyr::collect() + ), + by = "stratum_id" + ) |> + tidyr::unite("value_num", c("min_value", "max_value"), + sep = " - ", na.rm = TRUE + ) |> + dplyr::mutate(value = ifelse(is.na(value), value_num, value)) |> + dplyr::select(stratum_name, value_type, value) |> + left_join( + study$parameters |> + jsonlite::fromJSON() |> + purrr::flatten_dfr() |> + select(-c(p, method)) |> + tidyr::pivot_longer( + cols = everything(), + names_to = "stratum_name", + values_to = "weight" + ), + by = "stratum_name" + ) |> + group_by(stratum_name, value_type, weight) |> + summarise(levels = list(value)) + + arms <- + dplyr::tbl(db_connection_pool, "arm") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::select(arm_name = name, ratio) |> + dplyr::collect() |> + tidyr::pivot_wider(names_from = arm_name, values_from = ratio) |> + as.list() + + study_list <- + list( + strata = strata, + arms = arms + ) + + study_list <- c( + study |> + dplyr::select(-parameters), + study$parameters |> + jsonlite::fromJSON() |> + purrr::flatten_dfr() |> + dplyr::select(p, method), + study_list + ) + + return(study_list) +} diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 3f2b07d..3a34650 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -15,6 +15,7 @@ #* randomization method and parameters. #* @apiTag randomize Endpoints that randomize individual patients after the #* study was created. +#* @apiTag read Endpoints that read created records #* @apiTag other Other endpoints (helpers etc.). #* #* @plumber diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 4e8bd59..efe0764 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -47,7 +47,7 @@ function(study_id, current_state, req, res) { #' #' @return tibble with study_id, identifier, name and method #' -#' @tag other +#' @tag read #' @get / #' #' @serializer unboxedJSON @@ -57,3 +57,18 @@ function(req, res){ unbiased:::api_get_study(req, res) ) } + +#' Get all records for chosen study +#' +#' @param study_id:int Study identifier +#' +#' @tag read +#' @get / +#' +#' @serializer unboxedJSON +#' +function(study_id, req, res){ + return( + unbiased:::api_get_study_records(study_id, req, res) + ) +} diff --git a/tests/testthat/test-E2E-get-study.R b/tests/testthat/test-E2E-get-study.R new file mode 100644 index 0000000..12f76f8 --- /dev/null +++ b/tests/testthat/test-E2E-get-study.R @@ -0,0 +1,114 @@ +test_that("correct request to reads studies with the structure of the returned result", { + source("./test-helpers.R") + + conn <- pool::localCheckout( + get("db_connection_pool", envir = globalenv()) + ) + with_db_fixtures("fixtures/example_study.yml") + + response <- request(api_url) |> + req_url_path("study", "") |> + req_method("GET") |> + req_perform() + + response_body <- + response |> + resp_body_json() + + testthat::expect_equal(response$status_code, 200) + + checkmate::expect_names( + names(response_body[[1]]), + identical.to = c("study_id", "identifier", "name", "method", "last_edited") + ) + + checkmate::expect_list( + response_body[[1]], + any.missing = TRUE, + null.ok = FALSE, + len = 5, + type = c("numeric", "character", "character", "character", "character") + ) + + # Compliance of the number of tests + + n_studies <- + dplyr::tbl(db_connection_pool, "study") |> + collect() |> + nrow() + + testthat::expect_equal(length(response_body), n_studies) +}) + +test_that("correct request to reads records for chosen study_id with the structure of the returned result", { + response <- request(api_url) |> + req_url_path("study", "minimisation_pocock") |> + req_method("POST") |> + req_body_json( + data = list( + identifier = "ABC-X", + name = "Study ABC-X", + method = "var", + p = 0.85, + arms = list( + "placebo" = 1, + "active" = 1 + ), + covariates = list( + sex = list( + weight = 1, + levels = c("female", "male") + ), + weight = list( + weight = 1, + levels = c("up to 60kg", "61-80 kg", "81 kg or more") + ) + ) + ) + ) |> + req_perform() + + response_body <- + response |> + resp_body_json() + + response_study <- + request(api_url) |> + req_url_path("study", response_body$study$id) |> + req_method("GET") |> + req_perform() + + response_study_body <- + response_study |> + resp_body_json() + + testthat::expect_equal(response$status_code, 200) + + checkmate::expect_names( + names(response_study_body), + identical.to = c("study_id", "name", "randomization_method", "last_edited", "p", "method", "strata", "arms") + ) + + checkmate::expect_list( + response_study_body, + any.missing = TRUE, + null.ok = TRUE, + len = 8, + type = c("numeric", "character", "character", "character", "numeric", "character", "list", "character") + ) + + # Request with non-existent study_id + # trycatch i 404 + response_study_id <- + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id + 1) |> + req_method("GET") |> + req_perform() + }, + error = function(e) e + ) + + testthat::expect_equal(response_study_id$status, 404) +}) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 3ba3e19..aa4c6c6 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -1,5 +1,3 @@ -pool <- get("db_connection_pool", envir = globalenv()) - test_that("correct request with the structure of the returned result", { response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> From 7358b3c10b94470e35268ea7a43157b651c3b437 Mon Sep 17 00:00:00 2001 From: Kinga Date: Mon, 19 Feb 2024 11:44:27 +0000 Subject: [PATCH 195/240] Added sentry to endpoints --- inst/plumber/unbiased_api/study.R | 51 ++++++++++++++--------------- tests/testthat/test-E2E-get-study.R | 2 -- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index d4e5e6e..93fa34e 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -19,8 +19,7 @@ #* @serializer unboxedJSON #* sentryR::with_captured_calls(function( - identifier, name, method, arms, covariates, p, req, res -) { + identifier, name, method, arms, covariates, p, req, res) { return( unbiased:::api__minimization_pocock( identifier, name, method, arms, covariates, p, req, res @@ -43,35 +42,35 @@ sentryR::with_captured_calls(function(study_id, current_state, req, res) { return( unbiased:::api__randomize_patient(study_id, current_state, req, res) ) -} +}) + +#* Get all available studies +#* +#* @return tibble with study_id, identifier, name and method +#* +#* @tag read +#* @get / +#* @serializer unboxedJSON +#* -#' Get all available studies -#' -#' @return tibble with study_id, identifier, name and method -#' -#' @tag read -#' @get / -#' -#' @serializer unboxedJSON -#' -function(req, res){ +sentryR::with_captured_calls(function(req, res) { return( unbiased:::api_get_study(req, res) ) -} +}) + +#* Get all records for chosen study +#* +#* @param study_id:int Study identifier +#* +#* @tag read +#* @get / +#* +#* @serializer unboxedJSON +#* -#' Get all records for chosen study -#' -#' @param study_id:int Study identifier -#' -#' @tag read -#' @get / -#' -#' @serializer unboxedJSON -#' -function(study_id, req, res){ +sentryR::with_captured_calls(function(study_id, req, res) { return( unbiased:::api_get_study_records(study_id, req, res) ) -} - +}) diff --git a/tests/testthat/test-E2E-get-study.R b/tests/testthat/test-E2E-get-study.R index 12f76f8..038c111 100644 --- a/tests/testthat/test-E2E-get-study.R +++ b/tests/testthat/test-E2E-get-study.R @@ -97,8 +97,6 @@ test_that("correct request to reads records for chosen study_id with the structu type = c("numeric", "character", "character", "character", "numeric", "character", "list", "character") ) - # Request with non-existent study_id - # trycatch i 404 response_study_id <- tryCatch( { From d76250a7f2b1983274a2a791ee3917bae1ba465c Mon Sep 17 00:00:00 2001 From: Kinga Date: Mon, 19 Feb 2024 13:37:37 +0000 Subject: [PATCH 196/240] Changes resulting from code review + fix for catching errors --- R/api_get_study.R | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/R/api_get_study.R b/R/api_get_study.R index 7d8c5fd..16b4a4a 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -1,7 +1,6 @@ api_get_study <- function(res, req) { db_connection_pool <- get("db_connection_pool") - study_list <- dplyr::tbl(db_connection_pool, "study") |> dplyr::select(study_id = id, identifier, name, method, last_edited = timestamp) |> @@ -25,8 +24,7 @@ api_get_study_records <- function(study_id, req, res) { if (!is_study) { res$status <- 404 return(list( - error = "Study not found", - details = r$error + error = "Study not found" )) } @@ -82,7 +80,7 @@ api_get_study_records <- function(study_id, req, res) { tidyr::pivot_wider(names_from = arm_name, values_from = ratio) |> as.list() - study_list <- + study_elements <- list( strata = strata, arms = arms @@ -95,7 +93,7 @@ api_get_study_records <- function(study_id, req, res) { jsonlite::fromJSON() |> purrr::flatten_dfr() |> dplyr::select(p, method), - study_list + study_elements ) return(study_list) From 939ae5e4a2002781cdd1fc201843386548c9db85 Mon Sep 17 00:00:00 2001 From: Kinga Date: Mon, 19 Feb 2024 14:29:18 +0000 Subject: [PATCH 197/240] Issue #59: study id validation with 404 status code --- R/api_randomize.R | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 855b3e6..60bc41f 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -11,14 +11,11 @@ parse_pocock_parameters <- if (!checkmate::test_list(parameters, null.ok = FALSE)) { message <- checkmate::test_list(parameters, null.ok = FALSE) res$status <- 400 - res$body <- - list( - error = glue::glue( - "Parse validation failed. 'Parameters' must be a list: {message}" - ) + return(list( + error = glue::glue( + "Parse validation failed. 'Parameters' must be a list: {message}" ) - - return(res) + )) } ratio_arms <- @@ -39,11 +36,11 @@ parse_pocock_parameters <- if (!checkmate::test_list(params, null.ok = FALSE)) { message <- checkmate::test_list(params, null.ok = FALSE) res$status <- 400 - res$body <- - list(error = glue::glue( + return(list( + error = glue::glue( "Parse validation failed. Input parameters must be a list: {message}" - )) - return(res) + ) + )) } return(params) @@ -55,16 +52,20 @@ api__randomize_patient <- function(study_id, current_state, req, res) { db_connection_pool <- get("db_connection_pool") # Check whether study with study_id exists - checkmate::assert( - checkmate::check_subset( + is_study <- + checkmate::test_subset( x = req$args$study_id, choices = dplyr::tbl(db_connection_pool, "study") |> dplyr::select(id) |> dplyr::pull() - ), - .var.name = "study_id", - add = collection - ) + ) + + if (!is_study) { + res$status <- 404 + return(list( + error = "Study not found" + )) + } # Retrieve study details, especially the ones about randomization method_randomization <- From 22772835c9219111d509be51664cb7fd5a1efcc2 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 20 Feb 2024 08:39:20 +0000 Subject: [PATCH 198/240] Changed the study id search in the database: looked for a specific id, not all ids in the database --- R/api_randomize.R | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 60bc41f..8838081 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -51,13 +51,14 @@ api__randomize_patient <- function(study_id, current_state, req, res) { db_connection_pool <- get("db_connection_pool") - # Check whether study with study_id exists + study_id <- req$args$study_id + is_study <- - checkmate::test_subset( - x = req$args$study_id, - choices = dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(id) |> - dplyr::pull() + checkmate::test_true( + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::collect() |> + nrow() > 0 ) if (!is_study) { From 5334404d1567642aac781e375c507db4f48948f5 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 20 Feb 2024 08:58:17 +0000 Subject: [PATCH 199/240] Changed status code from 400 to 404 when input incorrect study_id --- tests/testthat/test-E2E-study-minimisation-pocock.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 3ba3e19..aa353fa 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -157,7 +157,7 @@ test_that("request with incorrect study id", { error = function(e) e ) - testthat::expect_equal(response_study$status, 400, label = "HTTP status code") + testthat::expect_equal(response_study$status, 404, label = "HTTP status code") }) test_that("request with patient that is assigned an arm at entry", { From db29edf3cc47548c465ecff01309ef7a1bc48778 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 20 Feb 2024 09:18:43 +0000 Subject: [PATCH 200/240] Removal of unnecessary data parsing tests --- R/api_randomize.R | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 8838081..68e561f 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -8,16 +8,6 @@ parse_pocock_parameters <- parameters <- jsonlite::fromJSON(parameters) - if (!checkmate::test_list(parameters, null.ok = FALSE)) { - message <- checkmate::test_list(parameters, null.ok = FALSE) - res$status <- 400 - return(list( - error = glue::glue( - "Parse validation failed. 'Parameters' must be a list: {message}" - ) - )) - } - ratio_arms <- dplyr::tbl(db_connetion_pool, "arm") |> dplyr::filter(study_id == !!study_id) |> @@ -33,16 +23,6 @@ parse_pocock_parameters <- weights = parameters$weights |> unlist() ) - if (!checkmate::test_list(params, null.ok = FALSE)) { - message <- checkmate::test_list(params, null.ok = FALSE) - res$status <- 400 - return(list( - error = glue::glue( - "Parse validation failed. Input parameters must be a list: {message}" - ) - )) - } - return(params) } From f6b70cc63df8a85826028760de8067fae5485c60 Mon Sep 17 00:00:00 2001 From: Kinga Date: Tue, 20 Feb 2024 10:27:42 +0000 Subject: [PATCH 201/240] Changed the study id search in the database: looked for a specific id, not all ids in the database --- R/api_get_study.R | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/R/api_get_study.R b/R/api_get_study.R index 16b4a4a..e19b313 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -13,12 +13,14 @@ api_get_study <- function(res, req) { api_get_study_records <- function(study_id, req, res) { db_connection_pool <- get("db_connection_pool") + study_id <- req$args$study_id + is_study <- - checkmate::test_subset( - x = req$args$study_id, - choices = dplyr::tbl(db_connection_pool, "study") |> - dplyr::select(id) |> - dplyr::pull() + checkmate::test_true( + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::collect() |> + nrow() > 0 ) if (!is_study) { From 20a06288ad98a8f4b5ebf2fead86e25df245ae3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 14:18:50 +0000 Subject: [PATCH 202/240] saving audit logs and db and endpoint for geting study audit log --- R/api-audit-log.R | 26 ++++++ R/audit-trail.R | 87 ++++++++++++++----- R/db.R | 9 ++ R/run-api.R | 50 +++++++++-- .../20240216102753_audit_trail.down.SQL | 2 + .../20240216102753_audit_trail.up.SQL | 17 ++++ inst/plumber/unbiased_api/meta.R | 2 +- inst/plumber/unbiased_api/plumber.R | 18 ++-- inst/plumber/unbiased_api/study.R | 25 +++++- migrate_db.sh | 0 10 files changed, 194 insertions(+), 42 deletions(-) create mode 100644 R/api-audit-log.R create mode 100644 inst/db/migrations/20240216102753_audit_trail.down.SQL create mode 100644 inst/db/migrations/20240216102753_audit_trail.up.SQL mode change 100644 => 100755 migrate_db.sh diff --git a/R/api-audit-log.R b/R/api-audit-log.R new file mode 100644 index 0000000..efaba68 --- /dev/null +++ b/R/api-audit-log.R @@ -0,0 +1,26 @@ +api_get_audit_log <- function(study_id, req, res) { + audit_log_disable_for_request(req) + + if (!check_study_exist(study_id = study_id)) { + res$status <- 404 + return( + list(error = "Study not found") + ) + } + + # Get audit trial + audit_trail <- dplyr::tbl(db_connection_pool, "audit_log") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::collect() + + audit_trail$request_body <- purrr::map( + audit_trail$request_body, + \(x) jsonlite::fromJSON(x) + ) + audit_trail$response_body <- purrr::map( + audit_trail$response_body, + \(x) jsonlite::fromJSON(x) + ) + + return(audit_trail) +} diff --git a/R/audit-trail.R b/R/audit-trail.R index f4b2463..1f2c637 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -20,9 +20,15 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. !private$disabled }, set_request_body = function(request_body) { + if (typeof(request_body) == "list") { + request_body <- jsonlite::toJSON(request_body, auto_unbox = TRUE) |> as.character() + } private$request_body <- request_body }, set_response_body = function(response_body) { + if (typeof(response_body) == "list") { + response_body <- jsonlite::toJSON(response_body, auto_unbox = TRUE) |> as.character() + } private$response_body <- response_body }, set_event_type = function(event_type) { @@ -31,6 +37,9 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. set_study_id = function(study_id) { private$study_id <- study_id }, + set_response_code = function(response_code) { + private$response_code <- response_code + }, validate_log = function() { if (private$disabled) { stop("Audit log is disabled") @@ -43,15 +52,35 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. if (private$disabled) { return() } - print("[Audit log begins here]") - print(glue::glue("Request ID: {private$request_id}")) - print(glue::glue("Event type: {private$event_type}")) - print(glue::glue("Study ID: {private$study_id}")) - print(glue::glue("Endpoint URL: {private$endpoint_url}")) - print(glue::glue("Request Method: {private$request_method}")) - print(glue::glue("Request Body: {private$request_body}")) - print(glue::glue("Response Body: {private$response_body}")) - print("[Audit log ends here]") + db_conn <- pool::localCheckout(db_connection_pool) + values <- list( + private$request_id, + private$event_type, + private$study_id, + private$endpoint_url, + private$request_method, + private$request_body, + private$response_code, + private$response_body + ) + + values <- purrr::map(values, \(x) ifelse(is.null(x), NA, x)) + + DBI::dbGetQuery( + db_conn, + "INSERT INTO audit_log ( + request_id, + event_type, + study_id, + endpoint_url, + request_method, + request_body, + response_code, + response_body + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + values + ) } ), private = list( @@ -61,6 +90,7 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. study_id = NULL, endpoint_url = NULL, request_method = NULL, + response_code = NULL, request_body = NULL, response_body = NULL ) @@ -77,38 +107,55 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. #' The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is set to "true". #' #' @param pr A plumber router for which the audit trail is to be set up. +#' @param endpoints A list of regex patterns for which the audit trail should be enabled. #' @return Returns the updated plumber router with the audit trail hooks. #' @examples #' pr <- plumber::plumb("your-api-definition.R") |> -#' setup_audit_trail() -setup_audit_trail <- function(pr) { +#' setup_audit_trail() +setup_audit_trail <- function(pr, endpoints) { audit_log_enabled <- Sys.getenv("AUDIT_LOG_ENABLED", "true") |> as.logical() if (!audit_log_enabled) { print("Audit log is disabled") return(pr) } print("Audit log is enabled") - pr |> - plumber::pr_hooks(list( - preroute = function(req, res) { + is_enabled_for_request <- function(req) { + if (is.null(endpoints)) { + return(TRUE) + } + any(sapply(endpoints, \(endpoint) grepl(endpoint, req$PATH_INFO))) + } + + hooks <- list( + preroute = function(req, res) { + with_err_handler({ + if (!is_enabled_for_request(req)) { + return() + } audit_log <- AuditLog$new( request_method = req$REQUEST_METHOD, endpoint_url = req$PATH_INFO, request_body = req$body ) req$.internal.audit_log <- audit_log - }, - postserialize = function(req, res) { + }) + }, + postserialize = function(req, res) { + with_err_handler({ audit_log <- req$.internal.audit_log - if (!audit_log$is_enabled()) { + if (is.null(audit_log) || !audit_log$is_enabled()) { return() } audit_log$validate_log() + audit_log$set_response_code(res$status) audit_log$set_request_body(req$body) audit_log$set_response_body(res$body) audit_log$persist() - } - )) + }) + } + ) + pr |> + plumber::pr_hooks(hooks) } #' Set Audit Log Event Type @@ -147,4 +194,4 @@ audit_log_disable_for_request <- function(req) { if (!is.null(audit_log)) { audit_log$disable() } -} \ No newline at end of file +} diff --git a/R/db.R b/R/db.R index e169868..6c7f1e8 100644 --- a/R/db.R +++ b/R/db.R @@ -44,6 +44,15 @@ get_similar_studies <- function(name, identifier) { similar } +check_study_exist <- function(study_id) { + db_connection_pool <- get("db_connection_pool") + study_exists <- dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == !!study_id) |> + dplyr::collect() |> + nrow() > 0 + study_exists +} + create_study <- function( name, identifier, method, parameters, arms, strata) { db_connection_pool <- get("db_connection_pool", envir = .GlobalEnv) diff --git a/R/run-api.R b/R/run-api.R index 32bf0a8..1d1bfdb 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -12,14 +12,6 @@ #' #' @export run_unbiased <- function() { - tryCatch( - { - rlang::global_entrace() - }, - error = function(e) { - message("Error setting up global_entrace, it is expected in testing environment: ", e$message) - } - ) setup_sentry() host <- Sys.getenv("UNBIASED_HOST", "0.0.0.0") port <- as.integer(Sys.getenv("UNBIASED_PORT", "3838")) @@ -106,3 +98,45 @@ global_calling_handler <- function(error) { sentryR::capture_exception(error) signalCondition(error) } + +wrap_endpoint <- function(z) { + f <- function(...) { + return(withCallingHandlers(z(...), error = rlang::entrace)) + } + return(f) +} + +default_error_handler <- function(req, res, error) { + print(error, simplify = "branch") + + if (sentryR::is_sentry_configured()) { + error$function_calls <- error$trace$call + sentryR::capture_exception(error) + } + + + res$status <- 500 + + jsonlite::toJSON(list( + error = "500 - Internal server error" + ), auto_unbox = TRUE) +} + +with_err_handler <- function(expr) { + withCallingHandlers( + expr = expr, + error = rlang::entrace, bottom = rlang::caller_env() + ) +} + +test_f <- function() { + stop("test") +} + +test_entrace <- function() { + with_err_handler(test_f()) +} + +test_sentryr_calling_handler <- function() { + sentryR::with_captured_calls(test_f)() +} diff --git a/inst/db/migrations/20240216102753_audit_trail.down.SQL b/inst/db/migrations/20240216102753_audit_trail.down.SQL new file mode 100644 index 0000000..4a15498 --- /dev/null +++ b/inst/db/migrations/20240216102753_audit_trail.down.SQL @@ -0,0 +1,2 @@ +DROP INDEX audit_log_study_id_idx; +DROP TABLE audit_log; diff --git a/inst/db/migrations/20240216102753_audit_trail.up.SQL b/inst/db/migrations/20240216102753_audit_trail.up.SQL new file mode 100644 index 0000000..c267f59 --- /dev/null +++ b/inst/db/migrations/20240216102753_audit_trail.up.SQL @@ -0,0 +1,17 @@ +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + event_type TEXT NOT NULL, + request_id UUID NOT NULL, + study_id integer, + endpoint_url TEXT NOT NULL, + request_method TEXT NOT NULL, + request_body JSONB, + response_code integer NOT NULL, + response_body JSONB, + CONSTRAINT audit_log_study_id_fk + FOREIGN KEY (study_id) + REFERENCES study (id) +); + +CREATE INDEX audit_log_study_id_idx ON audit_log (study_id); diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 0dc87eb..1687898 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -6,7 +6,7 @@ #* @tag other #* @get /sha #* @serializer unboxedJSON -sentryR::with_captured_calls(function(req, res) { +wrap_endpoint(function(req, res) { audit_log_disable_for_request(req) sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") if (sha == "NULL") { diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 91026cb..37bdaa3 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -22,21 +22,21 @@ function(api) { meta <- plumber::pr("meta.R") study <- plumber::pr("study.R") - if (sentryR::is_sentry_configured()) { - meta |> - plumber::pr_set_error(sentryR::sentry_error_handler) + meta |> + plumber::pr_set_error(default_error_handler) - study |> - plumber::pr_set_error(sentryR::sentry_error_handler) + study |> + plumber::pr_set_error(default_error_handler) - api |> - plumber::pr_set_error(sentryR::sentry_error_handler) - } + api |> + plumber::pr_set_error(default_error_handler) api |> plumber::pr_mount("/meta", meta) |> plumber::pr_mount("/study", study) |> - setup_audit_trail() |> + setup_audit_trail(endpoints = list( + "^/study/.*" + )) |> plumber::pr_set_api_spec(function(spec) { spec$ paths$ diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 07e7f95..4fbbaef 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -18,9 +18,8 @@ #* @post /minimisation_pocock #* @serializer unboxedJSON #* -sentryR::with_captured_calls(function( - identifier, name, method, arms, covariates, p, req, res -) { +wrap_endpoint(function( + identifier, name, method, arms, covariates, p, req, res) { return( unbiased:::api__minimization_pocock( identifier, name, method, arms, covariates, p, req, res @@ -39,8 +38,26 @@ sentryR::with_captured_calls(function( #* @serializer unboxedJSON #* -sentryR::with_captured_calls(function(study_id, current_state, req, res) { +wrap_endpoint(function(study_id, current_state, req, res) { return( unbiased:::api__randomize_patient(study_id, current_state, req, res) ) }) + + +#* Get study audit log +#* +#* Get the audit log for a study +#* +#* +#* @param study_id:int Study identifier +#* +#* @tag audit +#* @get //audit +#* @serializer unboxedJSON +#* +wrap_endpoint(function(study_id, req, res) { + return( + unbiased:::api_get_audit_log(study_id, req, res) + ) +}) diff --git a/migrate_db.sh b/migrate_db.sh old mode 100644 new mode 100755 From d4983af72688b54cd055ed082226301c30da37b8 Mon Sep 17 00:00:00 2001 From: lwalejko Date: Tue, 20 Feb 2024 14:22:57 +0000 Subject: [PATCH 203/240] Update documentation --- man/AuditLog.Rd | 10 ++++++++++ man/setup_audit_trail.Rd | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/man/AuditLog.Rd b/man/AuditLog.Rd index 3ce17ad..1618a48 100644 --- a/man/AuditLog.Rd +++ b/man/AuditLog.Rd @@ -17,6 +17,7 @@ This class is used internally to store audit logs for each request. \item \href{#method-AuditLog-set_response_body}{\code{AuditLog$set_response_body()}} \item \href{#method-AuditLog-set_event_type}{\code{AuditLog$set_event_type()}} \item \href{#method-AuditLog-set_study_id}{\code{AuditLog$set_study_id()}} +\item \href{#method-AuditLog-set_response_code}{\code{AuditLog$set_response_code()}} \item \href{#method-AuditLog-validate_log}{\code{AuditLog$validate_log()}} \item \href{#method-AuditLog-persist}{\code{AuditLog$persist()}} \item \href{#method-AuditLog-clone}{\code{AuditLog$clone()}} @@ -93,6 +94,15 @@ This class is used internally to store audit logs for each request. \if{html}{\out{
}}\preformatted{AuditLog$set_study_id(study_id)}\if{html}{\out{
}} } +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_response_code}{}}} +\subsection{Method \code{set_response_code()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_response_code(response_code)}\if{html}{\out{
}} +} + } \if{html}{\out{
}} \if{html}{\out{}} diff --git a/man/setup_audit_trail.Rd b/man/setup_audit_trail.Rd index ac77f3a..d33091e 100644 --- a/man/setup_audit_trail.Rd +++ b/man/setup_audit_trail.Rd @@ -4,10 +4,12 @@ \alias{setup_audit_trail} \title{Set up audit trail} \usage{ -setup_audit_trail(pr) +setup_audit_trail(pr, endpoints) } \arguments{ \item{pr}{A plumber router for which the audit trail is to be set up.} + +\item{endpoints}{A list of regex patterns for which the audit trail should be enabled.} } \value{ Returns the updated plumber router with the audit trail hooks. @@ -23,5 +25,5 @@ The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is } \examples{ pr <- plumber::plumb("your-api-definition.R") |> - setup_audit_trail() + setup_audit_trail() } From 20546cd2cf6b4e7956750270bb19d484d434f180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:16:38 +0000 Subject: [PATCH 204/240] implement audit log collection handling for all endpoints --- R/api_get_study.R | 5 ++++- R/api_randomize.R | 5 +---- inst/plumber/unbiased_api/meta.R | 1 - inst/plumber/unbiased_api/plumber.R | 6 ------ inst/plumber/unbiased_api/study.R | 32 +++++++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/R/api_get_study.R b/R/api_get_study.R index e19b313..b1d8b9c 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -1,4 +1,5 @@ -api_get_study <- function(res, req) { +api_get_study <- function(req, res) { + audit_log_disable_for_request(req) db_connection_pool <- get("db_connection_pool") study_list <- @@ -11,6 +12,7 @@ api_get_study <- function(res, req) { } api_get_study_records <- function(study_id, req, res) { + audit_log_event_type("get_study_record", req) db_connection_pool <- get("db_connection_pool") study_id <- req$args$study_id @@ -29,6 +31,7 @@ api_get_study_records <- function(study_id, req, res) { error = "Study not found" )) } + audit_log_study_id(study_id, req) study <- dplyr::tbl(db_connection_pool, "study") |> diff --git a/R/api_randomize.R b/R/api_randomize.R index 103943f..7795cbb 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -49,10 +49,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { )) } - # TODO: previous check should fail entire request with 404 if failed! - if (study_id |> is.numeric()) { - audit_log_study_id(study_id, req) - } + audit_log_study_id(study_id, req) # Retrieve study details, especially the ones about randomization method_randomization <- diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 1687898..82108b8 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -7,7 +7,6 @@ #* @get /sha #* @serializer unboxedJSON wrap_endpoint(function(req, res) { - audit_log_disable_for_request(req) sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") if (sha == "NULL") { res$status <- 404 diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 2984d23..8ac1309 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -109,12 +109,6 @@ function(req) { req$REQUEST_METHOD, req$PATH_INFO, "@", req$REMOTE_ADDR, "\n" ) - purrr::imap(req$args, function(arg, arg_name) { - cat("[ARG]", arg_name, "=", as.character(arg), "\n") - }) - if (req$postBody != "") { - cat("[BODY]", req$postBody, "\n") - } plumber::forward() } diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 4fbbaef..b9c5462 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -61,3 +61,35 @@ wrap_endpoint(function(study_id, req, res) { unbiased:::api_get_audit_log(study_id, req, res) ) }) + + +#* Get all available studies +#* +#* @return tibble with study_id, identifier, name and method +#* +#* @tag read +#* @get / +#* @serializer unboxedJSON +#* + +wrap_endpoint(function(req, res) { + return( + unbiased:::api_get_study(req, res) + ) +}) + +#* Get all records for chosen study +#* +#* @param study_id:int Study identifier +#* +#* @tag read +#* @get / +#* +#* @serializer unboxedJSON +#* + +wrap_endpoint(function(study_id, req, res) { + return( + unbiased:::api_get_study_records(study_id, req, res) + ) +}) \ No newline at end of file From bec215cfa59a294ceb0678ded07d291e8aefacbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:17:09 +0000 Subject: [PATCH 205/240] remove unused functions --- R/run-api.R | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/R/run-api.R b/R/run-api.R index 1d1bfdb..d4a186a 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -128,15 +128,3 @@ with_err_handler <- function(expr) { error = rlang::entrace, bottom = rlang::caller_env() ) } - -test_f <- function() { - stop("test") -} - -test_entrace <- function() { - with_err_handler(test_f()) -} - -test_sentryr_calling_handler <- function() { - sentryR::with_captured_calls(test_f)() -} From 720a69d4269d74cfcf3e2ff46d8b70df6cbc3d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:19:12 +0000 Subject: [PATCH 206/240] move error handling code to separate file --- R/error-handling.R | 89 +++++++++++++++++++ R/run-api.R | 88 ------------------ .../{test-run-api.R => test-error-handling.R} | 0 3 files changed, 89 insertions(+), 88 deletions(-) create mode 100644 R/error-handling.R rename tests/testthat/{test-run-api.R => test-error-handling.R} (100%) diff --git a/R/error-handling.R b/R/error-handling.R new file mode 100644 index 0000000..ca4457e --- /dev/null +++ b/R/error-handling.R @@ -0,0 +1,89 @@ + +# hack to make sure we can mock the globalCallingHandlers +# this method needs to be present in the package environment for mocking to work +# linter disabled intentionally since this is internal method and cannot be renamed +globalCallingHandlers <- NULL # nolint + +#' setup_sentry function +#' +#' This function is used to configure Sentry, a service for real-time error tracking. +#' It uses the sentryR package to set up Sentry based on environment variables. +#' +#' @param None +#' +#' @return None. If the SENTRY_DSN environment variable is not set, the function will +#' return a message and stop execution. +#' +#' @examples +#' setup_sentry() +#' +#' @details +#' The function first checks if the SENTRY_DSN environment variable is set. If not, it +#' returns a message and stops execution. +#' If SENTRY_DSN is set, it uses the sentryR::configure_sentry function to set up Sentry with +#' the following parameters: +#' - dsn: The Data Source Name (DSN) is retrieved from the SENTRY_DSN environment variable. +#' - app_name: The application name is set to "unbiased". +#' - app_version: The application version is retrieved from the GITHUB_SHA environment variable. +#' If not set, it defaults to "unspecified". +#' - environment: The environment is retrieved from the SENTRY_ENVIRONMENT environment variable. +#' If not set, it defaults to "development". +#' - release: The release is retrieved from the SENTRY_RELEASE environment variable. +#' If not set, it defaults to "unspecified". +#' +#' @seealso \url{https://docs.sentry.io/} +setup_sentry <- function() { + sentry_dsn <- Sys.getenv("SENTRY_DSN") + if (sentry_dsn == "") { + message("SENTRY_DSN not set, skipping Sentry setup") + return() + } + + sentryR::configure_sentry( + dsn = sentry_dsn, + app_name = "unbiased", + app_version = Sys.getenv("GITHUB_SHA", "unspecified"), + environment = Sys.getenv("SENTRY_ENVIRONMENT", "development"), + release = Sys.getenv("SENTRY_RELEASE", "unspecified") + ) + + globalCallingHandlers( + error = global_calling_handler + ) +} + +global_calling_handler <- function(error) { + error$function_calls <- sys.calls() + sentryR::capture_exception(error) + signalCondition(error) +} + +wrap_endpoint <- function(z) { + f <- function(...) { + return(withCallingHandlers(z(...), error = rlang::entrace)) + } + return(f) +} + +default_error_handler <- function(req, res, error) { + print(error, simplify = "branch") + + if (sentryR::is_sentry_configured()) { + error$function_calls <- error$trace$call + sentryR::capture_exception(error) + } + + + res$status <- 500 + + jsonlite::toJSON(list( + error = "500 - Internal server error" + ), auto_unbox = TRUE) +} + +with_err_handler <- function(expr) { + withCallingHandlers( + expr = expr, + error = rlang::entrace, bottom = rlang::caller_env() + ) +} \ No newline at end of file diff --git a/R/run-api.R b/R/run-api.R index d4a186a..5ed6fb0 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -40,91 +40,3 @@ run_unbiased <- function() { } } -# hack to make sure we can mock the globalCallingHandlers -# this method needs to be present in the package environment for mocking to work -# linter disabled intentionally since this is internal method and cannot be renamed -globalCallingHandlers <- NULL # nolint - -#' setup_sentry function -#' -#' This function is used to configure Sentry, a service for real-time error tracking. -#' It uses the sentryR package to set up Sentry based on environment variables. -#' -#' @param None -#' -#' @return None. If the SENTRY_DSN environment variable is not set, the function will -#' return a message and stop execution. -#' -#' @examples -#' setup_sentry() -#' -#' @details -#' The function first checks if the SENTRY_DSN environment variable is set. If not, it -#' returns a message and stops execution. -#' If SENTRY_DSN is set, it uses the sentryR::configure_sentry function to set up Sentry with -#' the following parameters: -#' - dsn: The Data Source Name (DSN) is retrieved from the SENTRY_DSN environment variable. -#' - app_name: The application name is set to "unbiased". -#' - app_version: The application version is retrieved from the GITHUB_SHA environment variable. -#' If not set, it defaults to "unspecified". -#' - environment: The environment is retrieved from the SENTRY_ENVIRONMENT environment variable. -#' If not set, it defaults to "development". -#' - release: The release is retrieved from the SENTRY_RELEASE environment variable. -#' If not set, it defaults to "unspecified". -#' -#' @seealso \url{https://docs.sentry.io/} -setup_sentry <- function() { - sentry_dsn <- Sys.getenv("SENTRY_DSN") - if (sentry_dsn == "") { - message("SENTRY_DSN not set, skipping Sentry setup") - return() - } - - sentryR::configure_sentry( - dsn = sentry_dsn, - app_name = "unbiased", - app_version = Sys.getenv("GITHUB_SHA", "unspecified"), - environment = Sys.getenv("SENTRY_ENVIRONMENT", "development"), - release = Sys.getenv("SENTRY_RELEASE", "unspecified") - ) - - globalCallingHandlers( - error = global_calling_handler - ) -} - -global_calling_handler <- function(error) { - error$function_calls <- sys.calls() - sentryR::capture_exception(error) - signalCondition(error) -} - -wrap_endpoint <- function(z) { - f <- function(...) { - return(withCallingHandlers(z(...), error = rlang::entrace)) - } - return(f) -} - -default_error_handler <- function(req, res, error) { - print(error, simplify = "branch") - - if (sentryR::is_sentry_configured()) { - error$function_calls <- error$trace$call - sentryR::capture_exception(error) - } - - - res$status <- 500 - - jsonlite::toJSON(list( - error = "500 - Internal server error" - ), auto_unbox = TRUE) -} - -with_err_handler <- function(expr) { - withCallingHandlers( - expr = expr, - error = rlang::entrace, bottom = rlang::caller_env() - ) -} diff --git a/tests/testthat/test-run-api.R b/tests/testthat/test-error-handling.R similarity index 100% rename from tests/testthat/test-run-api.R rename to tests/testthat/test-error-handling.R From db8901badf925a4b1f958a92fab2f10c468fa523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:25:27 +0000 Subject: [PATCH 207/240] fix function scopes for coverage run --- inst/plumber/unbiased_api/meta.R | 2 +- inst/plumber/unbiased_api/plumber.R | 8 ++++---- inst/plumber/unbiased_api/study.R | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/inst/plumber/unbiased_api/meta.R b/inst/plumber/unbiased_api/meta.R index 82108b8..ec157a3 100644 --- a/inst/plumber/unbiased_api/meta.R +++ b/inst/plumber/unbiased_api/meta.R @@ -6,7 +6,7 @@ #* @tag other #* @get /sha #* @serializer unboxedJSON -wrap_endpoint(function(req, res) { +unbiased:::wrap_endpoint(function(req, res) { sha <- Sys.getenv("GITHUB_SHA", unset = "NULL") if (sha == "NULL") { res$status <- 404 diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 8ac1309..084bd2c 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -24,18 +24,18 @@ function(api) { study <- plumber::pr("study.R") meta |> - plumber::pr_set_error(default_error_handler) + plumber::pr_set_error(unbiased:::default_error_handler) study |> - plumber::pr_set_error(default_error_handler) + plumber::pr_set_error(unbiased:::default_error_handler) api |> - plumber::pr_set_error(default_error_handler) + plumber::pr_set_error(unbiased:::default_error_handler) api |> plumber::pr_mount("/meta", meta) |> plumber::pr_mount("/study", study) |> - setup_audit_trail(endpoints = list( + unbiased:::setup_audit_trail(endpoints = list( "^/study/.*" )) |> plumber::pr_set_api_spec(function(spec) { diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index b9c5462..10a6733 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -18,7 +18,7 @@ #* @post /minimisation_pocock #* @serializer unboxedJSON #* -wrap_endpoint(function( +unbiased:::wrap_endpoint(function( identifier, name, method, arms, covariates, p, req, res) { return( unbiased:::api__minimization_pocock( @@ -38,7 +38,7 @@ wrap_endpoint(function( #* @serializer unboxedJSON #* -wrap_endpoint(function(study_id, current_state, req, res) { +unbiased:::wrap_endpoint(function(study_id, current_state, req, res) { return( unbiased:::api__randomize_patient(study_id, current_state, req, res) ) @@ -56,7 +56,7 @@ wrap_endpoint(function(study_id, current_state, req, res) { #* @get //audit #* @serializer unboxedJSON #* -wrap_endpoint(function(study_id, req, res) { +unbiased:::wrap_endpoint(function(study_id, req, res) { return( unbiased:::api_get_audit_log(study_id, req, res) ) @@ -72,7 +72,7 @@ wrap_endpoint(function(study_id, req, res) { #* @serializer unboxedJSON #* -wrap_endpoint(function(req, res) { +unbiased:::wrap_endpoint(function(req, res) { return( unbiased:::api_get_study(req, res) ) @@ -88,7 +88,7 @@ wrap_endpoint(function(req, res) { #* @serializer unboxedJSON #* -wrap_endpoint(function(study_id, req, res) { +unbiased:::wrap_endpoint(function(study_id, req, res) { return( unbiased:::api_get_study_records(study_id, req, res) ) From ab8d6a68cd1718b30d18e530373b65087db8a886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:34:16 +0000 Subject: [PATCH 208/240] simplify transaction handling for study creation --- R/api_create_study.R | 10 ---------- R/db.R | 12 ++---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/R/api_create_study.R b/R/api_create_study.R index 479936e..bdbaade 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -137,16 +137,6 @@ api__minimization_pocock <- function( strata = strata ) - # Response ---------------------------------------------------------------- - - if (!is.null(r$error)) { - res$status <- 503 - return(list( - error = "There was a problem saving created study to the database", - details = r$error - )) - } - audit_log_study_id(r$study$id, req) response <- list( diff --git a/R/db.R b/R/db.R index 6c7f1e8..342fdce 100644 --- a/R/db.R +++ b/R/db.R @@ -58,9 +58,9 @@ create_study <- function( db_connection_pool <- get("db_connection_pool", envir = .GlobalEnv) connection <- pool::localCheckout(db_connection_pool) - r <- tryCatch( + DBI::dbWithTransaction( + connection, { - DBI::dbBegin(connection) study_record <- list( name = name, identifier = identifier, @@ -143,17 +143,9 @@ create_study <- function( row.names = FALSE ) - DBI::dbCommit(connection) list(study = study) - }, - error = function(cond) { - logger::log_error("Error creating study: {cond}", cond = cond) - DBI::dbRollback(connection) - list(error = conditionMessage(cond)) } ) - - r } save_patient <- function(study_id, arm_id) { From 32d6c8f88f4532177f87118eaffd1094ab43d416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:35:54 +0000 Subject: [PATCH 209/240] use check_study_exist method for checking if study exist --- R/api_get_study.R | 10 +--------- R/api_randomize.R | 10 +--------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/R/api_get_study.R b/R/api_get_study.R index b1d8b9c..e3bb48e 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -17,15 +17,7 @@ api_get_study_records <- function(study_id, req, res) { study_id <- req$args$study_id - is_study <- - checkmate::test_true( - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::collect() |> - nrow() > 0 - ) - - if (!is_study) { + if (!check_study_exist(study_id)) { res$status <- 404 return(list( error = "Study not found" diff --git a/R/api_randomize.R b/R/api_randomize.R index 7795cbb..2cb9a52 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -34,15 +34,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { study_id <- req$args$study_id - is_study <- - checkmate::test_true( - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::collect() |> - nrow() > 0 - ) - - if (!is_study) { + if (!check_study_exist(study_id)) { res$status <- 404 return(list( error = "Study not found" From 8fa350836b02568b8e988f8ef6c22e9bdca70bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:41:16 +0000 Subject: [PATCH 210/240] remove redundant tryCatch block during saving randomized patient --- R/api_randomize.R | 22 +++++++--------------- R/db.R | 22 ++++++---------------- inst/plumber/unbiased_api/study.R | 2 +- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 2cb9a52..50b334c 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -86,19 +86,11 @@ api__randomize_patient <- function(study_id, current_state, req, res) { randomized_patient <- unbiased:::save_patient(study_id, arm$arm_id) - if (!is.null(randomized_patient$error)) { - res$status <- 503 - return(list( - error = "There was a problem saving randomized patient to the database", - details = randomized_patient$error - )) - } else { - randomized_patient <- - randomized_patient |> - dplyr::mutate(arm_name = arm$name) |> - dplyr::rename(patient_id = id) |> - as.list() - - return(randomized_patient) - } + randomized_patient <- + randomized_patient |> + dplyr::mutate(arm_name = arm$name) |> + dplyr::rename(patient_id = id) |> + as.list() + + return(randomized_patient) } diff --git a/R/db.R b/R/db.R index 342fdce..9f6b2b0 100644 --- a/R/db.R +++ b/R/db.R @@ -149,21 +149,11 @@ create_study <- function( } save_patient <- function(study_id, arm_id) { - r <- tryCatch( - { - randomized_patient <- DBI::dbGetQuery( - db_connection_pool, - "INSERT INTO patient (arm_id, study_id) - VALUES ($1, $2) - RETURNING id, arm_id", - list(arm_id, study_id) - ) - }, - error = function(cond) { - logger::log_error("Error randomizing patient: {cond}", cond = cond) - list(error = conditionMessage(cond)) - } + DBI::dbGetQuery( + db_connection_pool, + "INSERT INTO patient (arm_id, study_id) + VALUES ($1, $2) + RETURNING id, arm_id", + list(arm_id, study_id) ) - - return(r) } diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 10a6733..8f0ba7e 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -92,4 +92,4 @@ unbiased:::wrap_endpoint(function(study_id, req, res) { return( unbiased:::api_get_study_records(study_id, req, res) ) -}) \ No newline at end of file +}) From b02a8cd78f8d82e42cb4b280bb42a5eeba80587a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:45:47 +0000 Subject: [PATCH 211/240] ignore coverage for 2 lines that can be executed only in local development --- R/run-api.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/R/run-api.R b/R/run-api.R index 5ed6fb0..59abbc5 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -35,8 +35,12 @@ run_unbiased <- function() { } else { # otherwise we assume that we are in the root directory of the repository # and we can use plumb method to run the API from the plumber.R file - plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> - plumber::pr_run(host = host, port = port) + + # Following line is excluded from code coverage because it is not possible to + # run the API from the plumber.R file in the test environment + # This branch is only used for local development + plumber::plumb("./inst/plumber/unbiased_api/plumber.R") |> # nocov start + plumber::pr_run(host = host, port = port) # nocov end } } From 88d42a9ff9a0106dac7d401110d0253fb1a6bf0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 20 Feb 2024 18:46:25 +0000 Subject: [PATCH 212/240] remove validation utils that are no longer used --- R/validation-utils.R | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 R/validation-utils.R diff --git a/R/validation-utils.R b/R/validation-utils.R deleted file mode 100644 index 752c38c..0000000 --- a/R/validation-utils.R +++ /dev/null @@ -1,10 +0,0 @@ -#' Utility functions for validation - -append_error <- function(validation_errors, field, error) { - if (field %in% names(validation_errors)) { - validation_errors[[field]] <- c(validation_errors[[field]], error) - } else { - validation_errors[[field]] <- list(error) - } - return(validation_errors) -} From db140ccb0bcaf9032ff6938b17afa757f1b1475d Mon Sep 17 00:00:00 2001 From: lwalejko Date: Tue, 20 Feb 2024 18:54:13 +0000 Subject: [PATCH 213/240] Update documentation --- man/append_error.Rd | 11 ----------- man/setup_sentry.Rd | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 man/append_error.Rd diff --git a/man/append_error.Rd b/man/append_error.Rd deleted file mode 100644 index b443d1c..0000000 --- a/man/append_error.Rd +++ /dev/null @@ -1,11 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/validation-utils.R -\name{append_error} -\alias{append_error} -\title{Utility functions for validation} -\usage{ -append_error(validation_errors, field, error) -} -\description{ -Utility functions for validation -} diff --git a/man/setup_sentry.Rd b/man/setup_sentry.Rd index 8de319a..911f563 100644 --- a/man/setup_sentry.Rd +++ b/man/setup_sentry.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/run-api.R +% Please edit documentation in R/error-handling.R \name{setup_sentry} \alias{setup_sentry} \title{setup_sentry function} From 567d911217bfa43927c50e26fb3852f736340b0d Mon Sep 17 00:00:00 2001 From: Kinga Date: Wed, 21 Feb 2024 14:08:12 +0000 Subject: [PATCH 214/240] #51 Issue: get randomization list + tests --- R/api_get_randomization_list.R | 37 +++++++++++++++++++++ inst/plumber/unbiased_api/study.R | 15 +++++++++ tests/testthat/test-E2E-get-study.R | 50 ++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 R/api_get_randomization_list.R diff --git a/R/api_get_randomization_list.R b/R/api_get_randomization_list.R new file mode 100644 index 0000000..2babe95 --- /dev/null +++ b/R/api_get_randomization_list.R @@ -0,0 +1,37 @@ +api_get_rand_list <- function(study_id, req, res) { + db_connection_pool <- get("db_connection_pool") + + study_id <- req$args$study_id + + is_study <- + checkmate::test_true( + dplyr::tbl(db_connection_pool, "study") |> + dplyr::filter(id == study_id) |> + dplyr::collect() |> + nrow() > 0 + ) + + if (!is_study) { + res$status <- 404 + return(list( + error = "Study not found" + )) + } + + patients <- + dplyr::tbl(db_connection_pool, "patient") |> + dplyr::filter(study_id == !!study_id) |> + dplyr::left_join( + dplyr::tbl(db_connection_pool, "arm") |> + dplyr::select(arm_id = id, arm = name), + by = "arm_id" + ) |> + dplyr::select( + patient_id = id, arm, used, sys_period + ) |> + dplyr::collect() |> + dplyr::mutate(sys_period = as.character(gsub("\\[\"|\\+00\",\\)", "", sys_period))) |> + dplyr::mutate(sys_period = as.POSIXct(sys_period)) + + return(patients) +} diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 93fa34e..dad162e 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -74,3 +74,18 @@ sentryR::with_captured_calls(function(study_id, req, res) { unbiased:::api_get_study_records(study_id, req, res) ) }) + +#* Get randomization list +#* +#* @param study_id:int Study identifier +#* +#* @tag read +#* @get //randomization_list +#* @serializer unboxedJSON +#* + +sentryR::with_captured_calls(function(study_id, req, res) { + return( + unbiased:::api_get_rand_list(study_id, req, res) + ) +}) diff --git a/tests/testthat/test-E2E-get-study.R b/tests/testthat/test-E2E-get-study.R index 038c111..191dc3f 100644 --- a/tests/testthat/test-E2E-get-study.R +++ b/tests/testthat/test-E2E-get-study.R @@ -40,7 +40,7 @@ test_that("correct request to reads studies with the structure of the returned r testthat::expect_equal(length(response_body), n_studies) }) -test_that("correct request to reads records for chosen study_id with the structure of the returned result", { +test_that("requests to reads records for chosen study_id with the structure of the returned result", { response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> @@ -110,3 +110,51 @@ test_that("correct request to reads records for chosen study_id with the structu testthat::expect_equal(response_study_id$status, 404) }) + +test_that("correct request to reads randomization list with the structure of the returned result", { + source("./test-helpers.R") + + conn <- pool::localCheckout( + get("db_connection_pool", envir = globalenv()) + ) + with_db_fixtures("fixtures/example_study.yml") + + response <- + request(api_url) |> + req_url_path("/study/1/randomization_list") |> + req_method("GET") |> + req_perform() + + response_body <- + response |> + resp_body_json() + + testthat::expect_equal(response$status_code, 200) + + checkmate::expect_names( + names(response_body[[1]]), + identical.to = c("patient_id", "arm", "used", "sys_period") + ) +}) + +test_that("incorrect input study_id to reads randomization list", { + source("./test-helpers.R") + + conn <- pool::localCheckout( + get("db_connection_pool", envir = globalenv()) + ) + with_db_fixtures("fixtures/example_study.yml") + + response <- + tryCatch( + { + request(api_url) |> + req_url_path("study/100/randomization_list") |> + req_method("GET") |> + req_perform() + }, + error = function(e) e + ) + + testthat::expect_equal(response$status, 404) +}) From d27236f1e32222e90dbb459e6c4b0287d295951a Mon Sep 17 00:00:00 2001 From: Kinga Date: Thu, 22 Feb 2024 12:31:52 +0000 Subject: [PATCH 215/240] #51 Issue: added test to endpoint -> get randomization list Renamed example_study.yml to example_db.yml. Added one more study_id with two patients and completed information. Added to study_id = 1 two more patients. --- tests/testthat/fixtures/example_db.yml | 99 +++++++++++++++++++++++ tests/testthat/fixtures/example_study.yml | 42 ---------- tests/testthat/test-DB-0.R | 4 +- tests/testthat/test-DB-study.R | 12 +-- tests/testthat/test-E2E-get-study.R | 14 +++- 5 files changed, 118 insertions(+), 53 deletions(-) create mode 100644 tests/testthat/fixtures/example_db.yml delete mode 100644 tests/testthat/fixtures/example_study.yml diff --git a/tests/testthat/fixtures/example_db.yml b/tests/testthat/fixtures/example_db.yml new file mode 100644 index 0000000..45b054e --- /dev/null +++ b/tests/testthat/fixtures/example_db.yml @@ -0,0 +1,99 @@ +study: + - identifier: 'TEST' + name: 'Test Study' + method: 'minimisation_pocock' + parameters: '{"method": "var", "p": 0.85, "weights": {"gender": 1}}' + # Waring: id is set automatically by the database + # do not set it manually because sequences will be out of sync + # and you will get errors + # id: 1 + - identifier: 'TEST2' + name: 'Test Study 2' + method: 'minimisation_pocock' + parameters: '{"method": "var", "p": 0.85, "weights": {"gender": 1}}' + # id: 2 + +arm: + - study_id: 1 + name: 'placebo' + ratio: 2 + # id: 1 + - study_id: 1 + name: 'active' + ratio: 1 + # id: 2 + - study_id: 2 + name: 'placebo' + ratio: 2 + # id: 3 + - study_id: 2 + name: 'active' + ratio: 1 + # id: 4 + +stratum: + - study_id: 1 + name: 'gender' + value_type: 'factor' + # id: 1 + - study_id: 2 + name: 'gender' + value_type: 'factor' + # id: 2 + +factor_constraint: + - stratum_id: 1 + value: 'F' + - stratum_id: 1 + value: 'M' + - stratum_id: 2 + value: 'F' + - stratum_id: 2 + value: 'M' + +patient: + - study_id: 1 + arm_id: 1 + used: true + # id: 1 + - study_id: 1 + arm_id: 2 + used: true + # id: 2 + - study_id: 1 + arm_id: 2 + used: true + # id: 3 + - study_id: 1 + arm_id: 1 + used: true + # id: 4 + - study_id: 2 + arm_id: 3 + used: true + # id: 5 + - study_id: 2 + arm_id: 4 + used: true + # id: 6 + +patient_stratum: + - patient_id: 1 + stratum_id: 1 + fct_value: 'F' + - patient_id: 2 + stratum_id: 1 + fct_value: 'M' + - patient_id: 3 + stratum_id: 1 + fct_value: 'F' + - patient_id: 4 + stratum_id: 1 + fct_value: 'M' + - patient_id: 5 + stratum_id: 2 + fct_value: 'M' + - patient_id: 6 + stratum_id: 2 + fct_value: 'F' + diff --git a/tests/testthat/fixtures/example_study.yml b/tests/testthat/fixtures/example_study.yml deleted file mode 100644 index 083c9f6..0000000 --- a/tests/testthat/fixtures/example_study.yml +++ /dev/null @@ -1,42 +0,0 @@ -study: - - identifier: 'TEST' - name: 'Test Study' - method: 'minimisation_pocock' - parameters: '{"method": "var", "p": 0.85, "weights": {"gender": 1}}' - # Waring: id is set automatically by the database - # do not set it manually because sequences will be out of sync - # and you will get errors - # id: 1 - -arm: - - study_id: 1 - name: 'placebo' - ratio: 2 - # id: 1 - - study_id: 1 - name: 'active' - ratio: 1 - # id: 2 - -stratum: - - study_id: 1 - name: 'gender' - value_type: 'factor' - # id: 1 - -factor_constraint: - - stratum_id: 1 - value: 'F' - - stratum_id: 1 - value: 'M' - -patient: - - study_id: 1 - arm_id: 1 - used: true - # id: 1 - -patient_stratum: - - patient_id: 1 - stratum_id: 1 - fct_value: 'F' diff --git a/tests/testthat/test-DB-0.R b/tests/testthat/test-DB-0.R index ffa510d..1d81bee 100644 --- a/tests/testthat/test-DB-0.R +++ b/tests/testthat/test-DB-0.R @@ -11,7 +11,7 @@ test_that("database contains base tables", { conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") expect_contains( DBI::dbListTables(conn), c(versioned_tables, nonversioned_tables) @@ -22,7 +22,7 @@ test_that("database contains history tables", { conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") expect_contains( DBI::dbListTables(conn), glue::glue("{versioned_tables}_history") diff --git a/tests/testthat/test-DB-study.R b/tests/testthat/test-DB-study.R index 54c05a5..9e4ee15 100644 --- a/tests/testthat/test-DB-study.R +++ b/tests/testthat/test-DB-study.R @@ -4,7 +4,7 @@ pool <- get("db_connection_pool", envir = globalenv()) test_that("it is enough to provide a name, an identifier, and a method id", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") testthat::expect_no_error({ dplyr::tbl(conn, "study") |> dplyr::rows_append( @@ -23,7 +23,7 @@ new_study_id <- as.integer(1) test_that("deleting archivizes a study", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") testthat::expect_no_error({ dplyr::tbl(conn, "study") |> dplyr::rows_delete( @@ -48,7 +48,7 @@ test_that("deleting archivizes a study", { test_that("can't push arm with negative ratio", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") testthat::expect_error( { dplyr::tbl(conn, "arm") |> @@ -67,7 +67,7 @@ test_that("can't push arm with negative ratio", { test_that("can't push stratum other than factor or numeric", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") testthat::expect_error( { tbl(conn, "stratum") |> @@ -86,7 +86,7 @@ test_that("can't push stratum other than factor or numeric", { test_that("can't push stratum level outside of defined levels", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") # create a new patient return <- testthat::expect_no_error({ @@ -135,7 +135,7 @@ test_that("can't push stratum level outside of defined levels", { test_that("numerical constraints are enforced", { conn <- pool::localCheckout(pool) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") added_patient_id <- as.integer(1) return <- testthat::expect_no_error({ diff --git a/tests/testthat/test-E2E-get-study.R b/tests/testthat/test-E2E-get-study.R index 191dc3f..d88e63c 100644 --- a/tests/testthat/test-E2E-get-study.R +++ b/tests/testthat/test-E2E-get-study.R @@ -4,7 +4,7 @@ test_that("correct request to reads studies with the structure of the returned r conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") response <- request(api_url) |> req_url_path("study", "") |> @@ -117,7 +117,8 @@ test_that("correct request to reads randomization list with the structure of the conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) - with_db_fixtures("fixtures/example_study.yml") + + with_db_fixtures("fixtures/example_db.yml") response <- request(api_url) |> @@ -135,6 +136,13 @@ test_that("correct request to reads randomization list with the structure of the names(response_body[[1]]), identical.to = c("patient_id", "arm", "used", "sys_period") ) + + checkmate::expect_set_equal( + x = response_body |> + dplyr::bind_rows() |> + dplyr::pull(patient_id), + y = c(1, 2, 3, 4) + ) }) test_that("incorrect input study_id to reads randomization list", { @@ -143,7 +151,7 @@ test_that("incorrect input study_id to reads randomization list", { conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") response <- tryCatch( From 0d6cdf42af887b282d7109ab670e34898ff0e7a8 Mon Sep 17 00:00:00 2001 From: Kinga Date: Thu, 22 Feb 2024 13:58:04 +0000 Subject: [PATCH 216/240] Issue #67: used = TRUE after randomization --- R/api_randomize.R | 4 +++- R/db.R | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 68e561f..b4884ef 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -89,7 +89,9 @@ api__randomize_patient <- function(study_id, current_state, req, res) { dplyr::select("arm_id" = "id", "name", "ratio") |> dplyr::collect() - randomized_patient <- unbiased:::save_patient(study_id, arm$arm_id) + randomized_patient <- + unbiased:::save_patient(study_id, arm$arm_id, used = TRUE) |> + select(-used) if (!is.null(randomized_patient$error)) { res$status <- 503 diff --git a/R/db.R b/R/db.R index e169868..9c5da33 100644 --- a/R/db.R +++ b/R/db.R @@ -147,15 +147,15 @@ create_study <- function( r } -save_patient <- function(study_id, arm_id) { +save_patient <- function(study_id, arm_id, used) { r <- tryCatch( { randomized_patient <- DBI::dbGetQuery( db_connection_pool, - "INSERT INTO patient (arm_id, study_id) - VALUES ($1, $2) - RETURNING id, arm_id", - list(arm_id, study_id) + "INSERT INTO patient (arm_id, study_id, used) + VALUES ($1, $2, $3) + RETURNING id, arm_id, used", + list(arm_id, study_id, used) ) }, error = function(cond) { From a37ebf89f96e06b1bdf48221fc53fa19ee977dc4 Mon Sep 17 00:00:00 2001 From: Kinga Date: Fri, 23 Feb 2024 07:59:26 +0000 Subject: [PATCH 217/240] Issue #67: used = TRUE after randomization + test --- tests/testthat/test-E2E-study-minimisation-pocock.R | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 556481c..a821a7a 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -68,6 +68,14 @@ test_that("correct request with the structure of the returned result", { len = 3, type = c("numeric", "numeric", "character") ) + + checkmate::test_true( + dplyr::tbl(db_connection_pool, "patient") |> + dplyr::slice_max(id) |> + dplyr::collect() |> + dplyr::pull(used), + TRUE + ) }) test_that("request with one covariate at two levels", { From 9c85adb5b9a82584b314272f57df83a3eeb0955b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 23 Feb 2024 20:27:25 +0000 Subject: [PATCH 218/240] tests --- R/audit-trail.R | 49 ++++++------ R/error-handling.R | 3 +- R/run-api.R | 1 - renv.lock | 10 --- tests/testthat/audit-log-test-helpers.R | 64 ++++++++++++++++ .../testthat/fixtures/example_audit_logs.yml | 75 ++++++++++++++++++ tests/testthat/setup-testing-environment.R | 4 +- tests/testthat/test-E2E-get-study.R | 4 + .../test-E2E-study-minimisation-pocock.R | 7 ++ tests/testthat/test-api-audit-log.R | 76 +++++++++++++++++++ 10 files changed, 254 insertions(+), 39 deletions(-) create mode 100644 tests/testthat/audit-log-test-helpers.R create mode 100644 tests/testthat/fixtures/example_audit_logs.yml create mode 100644 tests/testthat/test-api-audit-log.R diff --git a/R/audit-trail.R b/R/audit-trail.R index 1f2c637..b31015c 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -13,9 +13,6 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. disable = function() { private$disabled <- TRUE }, - enable = function() { - private$disabled <- FALSE - }, is_enabled = function() { !private$disabled }, @@ -41,17 +38,25 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. private$response_code <- response_code }, validate_log = function() { - if (private$disabled) { - stop("Audit log is disabled") - } + checkmate::assert( + !private$disabled + ) if (is.null(private$event_type)) { - stop("Event type not set for audit log. Please set the event type using `audit_log_event_type`") + if (private$response_code == 404) { + # "soft" validation failure for 404 errors + # it might be just invalid endpoint + # so we don't want to fail the request + return(FALSE) + } else { + stop("Event type not set for audit log. Please set the event type using `audit_log_event_type`") + } } + return(TRUE) }, persist = function() { - if (private$disabled) { - return() - } + checkmate::assert( + !private$disabled + ) db_conn <- pool::localCheckout(db_connection_pool) values <- list( private$request_id, @@ -104,25 +109,19 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. #' #' This function modifies the plumber router in place and returns the updated router. #' -#' The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is set to "true". -#' #' @param pr A plumber router for which the audit trail is to be set up. #' @param endpoints A list of regex patterns for which the audit trail should be enabled. #' @return Returns the updated plumber router with the audit trail hooks. #' @examples #' pr <- plumber::plumb("your-api-definition.R") |> #' setup_audit_trail() -setup_audit_trail <- function(pr, endpoints) { - audit_log_enabled <- Sys.getenv("AUDIT_LOG_ENABLED", "true") |> as.logical() - if (!audit_log_enabled) { - print("Audit log is disabled") - return(pr) - } - print("Audit log is enabled") +setup_audit_trail <- function(pr, endpoints = list()) { + checkmate::assert( + is.list(endpoints), + all(sapply(endpoints, is.character)), + "endpoints must be a list of strings" + ) is_enabled_for_request <- function(req) { - if (is.null(endpoints)) { - return(TRUE) - } any(sapply(endpoints, \(endpoint) grepl(endpoint, req$PATH_INFO))) } @@ -146,11 +145,13 @@ setup_audit_trail <- function(pr, endpoints) { if (is.null(audit_log) || !audit_log$is_enabled()) { return() } - audit_log$validate_log() audit_log$set_response_code(res$status) audit_log$set_request_body(req$body) audit_log$set_response_body(res$body) - audit_log$persist() + + if (audit_log$validate_log()) { + audit_log$persist() + } }) } ) diff --git a/R/error-handling.R b/R/error-handling.R index ca4457e..48a6a7d 100644 --- a/R/error-handling.R +++ b/R/error-handling.R @@ -1,4 +1,3 @@ - # hack to make sure we can mock the globalCallingHandlers # this method needs to be present in the package environment for mocking to work # linter disabled intentionally since this is internal method and cannot be renamed @@ -86,4 +85,4 @@ with_err_handler <- function(expr) { expr = expr, error = rlang::entrace, bottom = rlang::caller_env() ) -} \ No newline at end of file +} diff --git a/R/run-api.R b/R/run-api.R index 59abbc5..94d1993 100644 --- a/R/run-api.R +++ b/R/run-api.R @@ -43,4 +43,3 @@ run_unbiased <- function() { plumber::pr_run(host = host, port = port) # nocov end } } - diff --git a/renv.lock b/renv.lock index c4ecca1..87a7c19 100644 --- a/renv.lock +++ b/renv.lock @@ -1305,16 +1305,6 @@ ], "Hash": "001cecbeac1cff9301bdc3775ee46a86" }, - "logger": { - "Package": "logger", - "Version": "0.2.2", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "utils" - ], - "Hash": "c269b06beb2bbadb0d058c0e6fa4ec3d" - }, "lubridate": { "Package": "lubridate", "Version": "1.9.3", diff --git a/tests/testthat/audit-log-test-helpers.R b/tests/testthat/audit-log-test-helpers.R new file mode 100644 index 0000000..b4cb974 --- /dev/null +++ b/tests/testthat/audit-log-test-helpers.R @@ -0,0 +1,64 @@ +#' Assert Events Logged in Audit Trail +#' +#' This function checks if the expected events have been logged in the 'audit_log' table in the database. +#' This function should be used at the beginning of a test to ensure that the expected events are logged. +#' @param events A vector of expected event types that should be logged, in order +#' +#' @return This function does not return a value. It throws an error if the assertions fail. +#' +#' @examples +#' \dontrun{ +#' assert_events_logged(c("event1", "event2")) +#' } +assert_audit_trail_for_test <- function(events = list(), env = parent.frame()) { + # Get count of events logged from audit_log table in database + pool <- get("db_connection_pool", envir = .GlobalEnv) + conn <- pool::localCheckout(pool) + + event_count <- DBI::dbGetQuery( + conn, + "SELECT COUNT(*) FROM audit_log" + )$count + + withr::defer( + { + # gen new count + new_event_count <- DBI::dbGetQuery( + conn, + "SELECT COUNT(*) FROM audit_log" + )$count + + n <- length(events) + + # assert that the count has increased by number of events + testthat::expect_equal( + new_event_count, + event_count + n, + info = "Expected events to be logged" + ) + + if (n > 0) { + # get the last n events + last_n_events <- DBI::dbGetQuery( + conn, + glue::glue_sql( + "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT {n};", + .con = conn + ) + ) + + event_types <- last_n_events |> + dplyr::pull("event_type") |> + rev() + + # assert that the last n events are the expected events + testthat::expect_equal( + event_types, + events, + info = "Expected events to be logged" + ) + } + }, + env + ) +} diff --git a/tests/testthat/fixtures/example_audit_logs.yml b/tests/testthat/fixtures/example_audit_logs.yml new file mode 100644 index 0000000..607dfe5 --- /dev/null +++ b/tests/testthat/fixtures/example_audit_logs.yml @@ -0,0 +1,75 @@ +study: + - identifier: 'TEST' + name: 'Test Study' + method: 'minimisation_pocock' + parameters: '{}' + - identifier: 'TEST2' + name: 'Test Study 2' + method: 'minimisation_pocock' + parameters: '{}' + - identifier: 'TEST3' + name: 'Test Study 3' + method: 'minimisation_pocock' + parameters: '{}' + +audit_log: + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70001" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0001" + study_id: 1 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70002" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0002" + study_id: 2 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70003" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0003" + study_id: 2 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70004" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0004" + study_id: 2 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70005" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0004" + study_id: 2 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' + - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70006" + created_at: "2022-02-16T10:27:53Z" + event_type: "example_event" + request_id: "427ac2db-166d-4236-b040-94213f1b0006" + study_id: 3 + endpoint_url: "/api/example" + request_method: "GET" + request_body: '{"key1": "value1", "key2": "value2"}' + response_code: 200 + response_body: '{"key1": "value1", "key2": "value2"}' diff --git a/tests/testthat/setup-testing-environment.R b/tests/testthat/setup-testing-environment.R index bc06c31..dd4d790 100644 --- a/tests/testthat/setup-testing-environment.R +++ b/tests/testthat/setup-testing-environment.R @@ -281,8 +281,8 @@ request(api_url) |> req_url_path("meta", "sha") |> req_method("GET") |> req_retry( - max_tries = 25, - backoff = \(x) 0.3 + max_seconds = 30, + backoff = \(x) 1 ) |> req_perform() print("API started, running tests...") diff --git a/tests/testthat/test-E2E-get-study.R b/tests/testthat/test-E2E-get-study.R index 038c111..261441c 100644 --- a/tests/testthat/test-E2E-get-study.R +++ b/tests/testthat/test-E2E-get-study.R @@ -1,11 +1,15 @@ test_that("correct request to reads studies with the structure of the returned result", { source("./test-helpers.R") + source("./audit-log-test-helpers.R") conn <- pool::localCheckout( get("db_connection_pool", envir = globalenv()) ) with_db_fixtures("fixtures/example_study.yml") + # this endpoint should not be logged + assert_audit_trail_for_test(c()) + response <- request(api_url) |> req_url_path("study", "") |> req_method("GET") |> diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 556481c..6487fcc 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -1,4 +1,11 @@ test_that("correct request with the structure of the returned result", { + source("./test-helpers.R") + source("./audit-log-test-helpers.R") + with_db_fixtures("fixtures/example_study.yml") + assert_audit_trail_for_test(c( + "study_create", + "randomize_patient" + )) response <- request(api_url) |> req_url_path("study", "minimisation_pocock") |> req_method("POST") |> diff --git a/tests/testthat/test-api-audit-log.R b/tests/testthat/test-api-audit-log.R new file mode 100644 index 0000000..0980eb8 --- /dev/null +++ b/tests/testthat/test-api-audit-log.R @@ -0,0 +1,76 @@ +source("./test-helpers.R") +source("./audit-log-test-helpers.R") + +testthat::test_that("audit logs for study are returned correctly from the database", { + with_db_fixtures("fixtures/example_audit_logs.yml") + studies <- c(1, 2, 3) + counts <- c(1, 4, 1) + for (i in 1:3) { + study_id <- studies[i] + count <- counts[i] + response <- request(api_url) |> + req_url_path("study", study_id, "audit") |> + req_method("GET") |> + req_perform() + + response_body <- + response |> + resp_body_json() + + testthat::expect_equal(response$status_code, 200) + testthat::expect_equal(length(response_body), count) + + if (count > 0) { + body <- response_body[[1]] + testthat::expect_equal(names(body), c( + "id", + "created_at", + "event_type", + "request_id", + "study_id", + "endpoint_url", + "request_method", + "request_body", + "response_code", + "response_body" + )) + testthat::expect_equal(body$study_id, study_id) + testthat::expect_equal(body$event_type, "example_event") + testthat::expect_equal(body$request_method, "GET") + testthat::expect_equal(body$endpoint_url, "/api/example") + testthat::expect_equal(body$response_code, 200) + testthat::expect_equal(body$request_body, list(key1 = "value1", key2 = "value2")) + testthat::expect_equal(body$response_body, list(key1 = "value1", key2 = "value2")) + } + } +}) + +testthat::test_that("should return 404 when study does not exist", { + response <- request(api_url) |> + req_url_path("study", 1111, "audit") |> + req_method("GET") |> + req_error(is_error = \(x) FALSE) |> + req_perform() + + response_body <- + response |> + resp_body_json() + + testthat::expect_equal(response$status_code, 404) + testthat::expect_equal(response_body$error, "Study not found") +}) + +testthat::test_that("should not log audit trail for non-existent endpoint", { + assert_audit_trail_for_test(events = c()) + response <- request(api_url) |> + req_url_path("study", 1, "non-existent-endpoint") |> + req_method("GET") |> + req_error(is_error = \(x) FALSE) |> + req_perform() + + response_body <- + response |> + resp_body_json() + + testthat::expect_equal(response$status_code, 404) +}) From 738f584a2a130de61f0ce6ad1a4737da15c9630d Mon Sep 17 00:00:00 2001 From: lwalejko Date: Fri, 23 Feb 2024 20:31:24 +0000 Subject: [PATCH 219/240] Update documentation --- man/AuditLog.Rd | 10 ---------- man/setup_audit_trail.Rd | 4 +--- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/man/AuditLog.Rd b/man/AuditLog.Rd index 1618a48..2bb79f0 100644 --- a/man/AuditLog.Rd +++ b/man/AuditLog.Rd @@ -11,7 +11,6 @@ This class is used internally to store audit logs for each request. \itemize{ \item \href{#method-AuditLog-new}{\code{AuditLog$new()}} \item \href{#method-AuditLog-disable}{\code{AuditLog$disable()}} -\item \href{#method-AuditLog-enable}{\code{AuditLog$enable()}} \item \href{#method-AuditLog-is_enabled}{\code{AuditLog$is_enabled()}} \item \href{#method-AuditLog-set_request_body}{\code{AuditLog$set_request_body()}} \item \href{#method-AuditLog-set_response_body}{\code{AuditLog$set_response_body()}} @@ -40,15 +39,6 @@ This class is used internally to store audit logs for each request. \if{html}{\out{
}}\preformatted{AuditLog$disable()}\if{html}{\out{
}} } -} -\if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-AuditLog-enable}{}}} -\subsection{Method \code{enable()}}{ -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{AuditLog$enable()}\if{html}{\out{
}} -} - } \if{html}{\out{
}} \if{html}{\out{}} diff --git a/man/setup_audit_trail.Rd b/man/setup_audit_trail.Rd index d33091e..129039f 100644 --- a/man/setup_audit_trail.Rd +++ b/man/setup_audit_trail.Rd @@ -4,7 +4,7 @@ \alias{setup_audit_trail} \title{Set up audit trail} \usage{ -setup_audit_trail(pr, endpoints) +setup_audit_trail(pr, endpoints = list()) } \arguments{ \item{pr}{A plumber router for which the audit trail is to be set up.} @@ -20,8 +20,6 @@ information before routing (preroute) and after serializing the response (postse } \details{ This function modifies the plumber router in place and returns the updated router. - -The audit trail is only enabled if the AUDIT_LOG_ENABLED environment variable is set to "true". } \examples{ pr <- plumber::plumb("your-api-definition.R") |> From 2f52bf39c353cddea5380f4f0c6a822b74ebf4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 26 Feb 2024 09:26:55 +0000 Subject: [PATCH 220/240] tests and upstream integration --- R/api_get_randomization_list.R | 10 +++------- R/audit-trail.R | 10 ++++++---- inst/plumber/unbiased_api/study.R | 2 +- tests/testthat/test-E2E-study-minimisation-pocock.R | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/R/api_get_randomization_list.R b/R/api_get_randomization_list.R index 2babe95..ab97ea8 100644 --- a/R/api_get_randomization_list.R +++ b/R/api_get_randomization_list.R @@ -1,15 +1,10 @@ api_get_rand_list <- function(study_id, req, res) { + audit_log_event_type("get_rand_list", req) db_connection_pool <- get("db_connection_pool") study_id <- req$args$study_id - is_study <- - checkmate::test_true( - dplyr::tbl(db_connection_pool, "study") |> - dplyr::filter(id == study_id) |> - dplyr::collect() |> - nrow() > 0 - ) + is_study <- check_study_exist(study_id = study_id) if (!is_study) { res$status <- 404 @@ -17,6 +12,7 @@ api_get_rand_list <- function(study_id, req, res) { error = "Study not found" )) } + audit_log_study_id(study_id, req) patients <- dplyr::tbl(db_connection_pool, "patient") |> diff --git a/R/audit-trail.R b/R/audit-trail.R index b31015c..a063ec5 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -23,9 +23,9 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. private$request_body <- request_body }, set_response_body = function(response_body) { - if (typeof(response_body) == "list") { - response_body <- jsonlite::toJSON(response_body, auto_unbox = TRUE) |> as.character() - } + checkmate::assert_false( + typeof(response_body) == "list" + ) private$response_body <- response_body }, set_event_type = function(event_type) { @@ -149,7 +149,9 @@ setup_audit_trail <- function(pr, endpoints = list()) { audit_log$set_request_body(req$body) audit_log$set_response_body(res$body) - if (audit_log$validate_log()) { + log_valid <- audit_log$validate_log() + + if (log_valid) { audit_log$persist() } }) diff --git a/inst/plumber/unbiased_api/study.R b/inst/plumber/unbiased_api/study.R index 4518030..bc5c3a3 100644 --- a/inst/plumber/unbiased_api/study.R +++ b/inst/plumber/unbiased_api/study.R @@ -103,7 +103,7 @@ unbiased:::wrap_endpoint(function(study_id, req, res) { #* @serializer unboxedJSON #* -sentryR::with_captured_calls(function(study_id, req, res) { +unbiased:::wrap_endpoint(function(study_id, req, res) { return( unbiased:::api_get_rand_list(study_id, req, res) ) diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index 13ef083..31394e3 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -1,7 +1,7 @@ test_that("correct request with the structure of the returned result", { source("./test-helpers.R") source("./audit-log-test-helpers.R") - with_db_fixtures("fixtures/example_study.yml") + with_db_fixtures("fixtures/example_db.yml") assert_audit_trail_for_test(c( "study_create", "randomize_patient" From 694affea16aea2bc5dba155f0f04f5505d4f1328 Mon Sep 17 00:00:00 2001 From: Kinga Date: Wed, 28 Feb 2024 07:35:07 +0000 Subject: [PATCH 221/240] Issue #70: Status code changed from 500 to 400. Added current_status tests. --- R/api_randomize.R | 38 +++++++++++++ .../test-E2E-study-minimisation-pocock.R | 55 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index b4884ef..9b75817 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -61,6 +61,44 @@ api__randomize_patient <- function(study_id, current_state, req, res) { add = collection ) + browser() + + checkmate::assert( + checkmate::check_data_frame(current_state, + any.missing = TRUE, + all.missing = FALSE, nrows = 2, ncols = 3 + ), + .var.name = "current_state", + add = collection + ) + + checkmate::assert( + checkmate::check_names( + colnames(current_state), + must.include = "arm" + ), + .var.name = "current_state", + add = collection + ) + + + check_arm <- function(x) { + res <- checkmate::check_character( + current_state$arm[nrow(current_state)], + max.chars = 0 + ) + if (!isTRUE(res)) { + res <- ("Last value should be empty") + } + return(res) + } + + checkmate::assert( + check_arm(), + .var.name = "current_state[arm]", + add = collection + ) + if (length(collection$getMessages()) > 0) { res$status <- 400 return(list( diff --git a/tests/testthat/test-E2E-study-minimisation-pocock.R b/tests/testthat/test-E2E-study-minimisation-pocock.R index a821a7a..ef22b59 100644 --- a/tests/testthat/test-E2E-study-minimisation-pocock.R +++ b/tests/testthat/test-E2E-study-minimisation-pocock.R @@ -198,7 +198,7 @@ test_that("request with patient that is assigned an arm at entry", { response |> resp_body_json() - response_current_state <- + response_cs_arm <- tryCatch( { request(api_url) |> @@ -220,7 +220,58 @@ test_that("request with patient that is assigned an arm at entry", { ) testthat::expect_equal( - response_current_state$status, 500, + response_cs_arm$status, 400, + label = "HTTP status code" + ) + + response_cs_records <- + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female"), + "weight" = c("61-80 kg"), + "arm" = c("placebo") + ) + ) + ) |> + req_perform() + }, + error = function(e) e + ) + + testthat::expect_equal( + response_cs_records$status, 400, + label = "HTTP status code" + ) + + response_current_state <- + tryCatch( + { + request(api_url) |> + req_url_path("study", response_body$study$id, "patient") |> + req_method("POST") |> + req_body_json( + data = list( + current_state = + tibble::tibble( + "sex" = c("female", "male"), + "weight" = c("61-80 kg", "81 kg or more") + ) + ) + ) |> + req_perform() + }, + error = function(e) e + ) + + testthat::expect_equal( + response_current_state$status, 400, label = "HTTP status code" ) }) From c950c2da62d73035b5b846a7c09a46375e5ec59b Mon Sep 17 00:00:00 2001 From: Kinga Date: Wed, 28 Feb 2024 07:57:22 +0000 Subject: [PATCH 222/240] Small correction in the code --- R/api_randomize.R | 2 -- 1 file changed, 2 deletions(-) diff --git a/R/api_randomize.R b/R/api_randomize.R index 9b75817..3500251 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -61,8 +61,6 @@ api__randomize_patient <- function(study_id, current_state, req, res) { add = collection ) - browser() - checkmate::assert( checkmate::check_data_frame(current_state, any.missing = TRUE, From 29ecce6211662dc02e21f67142258e4f54892c52 Mon Sep 17 00:00:00 2001 From: Kinga Date: Wed, 28 Feb 2024 14:43:35 +0000 Subject: [PATCH 223/240] Issue #71 Added range method tests in randomization funcion --- .../test-randomize-minimisation-pocock.R | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/testthat/test-randomize-minimisation-pocock.R b/tests/testthat/test-randomize-minimisation-pocock.R index 7d98125..cce2b06 100644 --- a/tests/testthat/test-randomize-minimisation-pocock.R +++ b/tests/testthat/test-randomize-minimisation-pocock.R @@ -168,3 +168,21 @@ test_that("Setting proportion of randomness works", { expect_gt(test$p.value, 0.05) }) + +test_that("Method 'range' works properly", { + arms <- c("A", "B", "C") + situation <- tibble::tibble( + sex = c("F", "M", "F"), + diabetes_type = c("type2", "type2", "type2"), + arm = c("A", "B", "") + ) + randomized <- + randomize_minimisation_pocock( + arms = arms, + current_state = situation, + p = 1, + method = "range" + ) + + testthat::expect_equal(randomized, "C") +}) From 593658ad839905868e331286b88fca834d95a696 Mon Sep 17 00:00:00 2001 From: Kinga Date: Fri, 1 Mar 2024 14:31:37 +0000 Subject: [PATCH 224/240] Changed in age: >55 instead >50 Renamed variable: count to count_n Fixes problem with N variable --- .../articles/minimization_randomization_comparison.Rmd | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/vignettes/articles/minimization_randomization_comparison.Rmd b/vignettes/articles/minimization_randomization_comparison.Rmd index 70758e1..9f09e44 100644 --- a/vignettes/articles/minimization_randomization_comparison.Rmd +++ b/vignettes/articles/minimization_randomization_comparison.Rmd @@ -156,7 +156,8 @@ def <- simstudy::defData(def, varname = "hba1c", formula = "0.888", dist = "bina def <- simstudy::defData(def, varname = "tpo2", formula = "0.354", dist = "binary") # correlation with diabetes type def <- simstudy::defData( - def, varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary" + def, + varname = "age", formula = "(diabetes_type == 0) * (-0.95)", link = "logit", dist = "binary" ) # <= 2 - 0.302 def <- simstudy::defData(def, varname = "wound_size", formula = "0.302", dist = "binary") @@ -290,7 +291,7 @@ statistics_table <- diabetes_type = ifelse(diabetes_type == "1", "type1", "type2"), hba1c = ifelse(hba1c == "1", "<=9", "(9,11>"), tpo2 = ifelse(tpo2 == "1", "<=50", ">50"), - age = ifelse(age == "1", "<=55", ">50"), + age = ifelse(age == "1", "<=55", ">55"), wound_size = ifelse(wound_size == "1", "<=2", ">2") ) |> tbl_summary( @@ -392,12 +393,13 @@ block_rand <- ) getRandList(genSeq(rand))[1, ] }) + df_list <- tibble::tibble() for (i in seq_len(strata_n)) { local_df <- strata_grid |> dplyr::slice(i) |> - dplyr::mutate(count = N) |> - tidyr::uncount(count) |> + dplyr::mutate(count_n = n) |> + tidyr::uncount(count_n) |> tibble::add_column(rand_arm = gen_seq_list[[i]]) df_list <- rbind(local_df, df_list) } From 976e86faa2967eaed9588f0d6e55e99545c7abfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Fri, 1 Mar 2024 15:17:58 +0000 Subject: [PATCH 225/240] Add invalid JSON handling to error-handling.R --- R/error-handling.R | 38 ++++++++++++++++++++++++++--- inst/plumber/unbiased_api/plumber.R | 3 ++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/R/error-handling.R b/R/error-handling.R index 48a6a7d..cc1b88d 100644 --- a/R/error-handling.R +++ b/R/error-handling.R @@ -64,20 +64,50 @@ wrap_endpoint <- function(z) { return(f) } +setup_invalid_json_handler <- function(api) { + api |> + plumber::pr_filter("validate_input_json", \(req, res) { + if (length(req$bodyRaw) > 0) { + request_body <- req$bodyRaw |> rawToChar() + e <- tryCatch( + { + jsonlite::fromJSON(request_body) + NULL + }, + error = \(e) e + ) + if (!is.null(e)) { + audit_log_set_event_type("malformed_request", req) + res$status <- 400 + return(list( + error = jsonlite::unbox("Invalid JSON"), + details = e$message |> strsplit("\n") |> unlist() + )) + } + } + + plumber::forward() + }) +} + default_error_handler <- function(req, res, error) { print(error, simplify = "branch") if (sentryR::is_sentry_configured()) { - error$function_calls <- error$trace$call + if ("trace" %in% names(error)) { + error$function_calls <- error$trace$call + } else if (!("function_calls" %in% names(error))) { + error$function_calls <- sys.calls() + } + sentryR::capture_exception(error) } - res$status <- 500 - jsonlite::toJSON(list( + list( error = "500 - Internal server error" - ), auto_unbox = TRUE) + ) } with_err_handler <- function(expr) { diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 084bd2c..75e4660 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -30,7 +30,8 @@ function(api) { plumber::pr_set_error(unbiased:::default_error_handler) api |> - plumber::pr_set_error(unbiased:::default_error_handler) + plumber::pr_set_error(unbiased:::default_error_handler) |> + unbiased:::setup_invalid_json_handler() api |> plumber::pr_mount("/meta", meta) |> From fc1b497aca2d0e12f4b7deaf3933b801d62ce09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 09:11:00 +0000 Subject: [PATCH 226/240] simplyfy JSON rendering in audit trail get endpoint --- R/api-audit-log.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/api-audit-log.R b/R/api-audit-log.R index efaba68..4403870 100644 --- a/R/api-audit-log.R +++ b/R/api-audit-log.R @@ -15,11 +15,11 @@ api_get_audit_log <- function(study_id, req, res) { audit_trail$request_body <- purrr::map( audit_trail$request_body, - \(x) jsonlite::fromJSON(x) + jsonlite::fromJSON ) audit_trail$response_body <- purrr::map( audit_trail$response_body, - \(x) jsonlite::fromJSON(x) + jsonlite::fromJSON ) return(audit_trail) From 377e4ce6291507dd604aad022f738e00c4941a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 09:12:07 +0000 Subject: [PATCH 227/240] rename methods and simplify checks --- R/api_create_study.R | 4 ++-- R/api_get_randomization_list.R | 4 ++-- R/api_get_study.R | 4 ++-- R/api_randomize.R | 4 ++-- R/audit-trail.R | 22 ++++++++++------------ 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/R/api_create_study.R b/R/api_create_study.R index bdbaade..f225e88 100644 --- a/R/api_create_study.R +++ b/R/api_create_study.R @@ -1,7 +1,7 @@ api__minimization_pocock <- function( # nolint: cyclocomp_linter. identifier, name, method, arms, covariates, p, req, res) { - audit_log_event_type("study_create", req) + audit_log_set_event_type("study_create", req) collection <- checkmate::makeAssertCollection() @@ -137,7 +137,7 @@ api__minimization_pocock <- function( strata = strata ) - audit_log_study_id(r$study$id, req) + audit_log_set_study_id(r$study$id, req) response <- list( study = r$study diff --git a/R/api_get_randomization_list.R b/R/api_get_randomization_list.R index ab97ea8..e2434b1 100644 --- a/R/api_get_randomization_list.R +++ b/R/api_get_randomization_list.R @@ -1,5 +1,5 @@ api_get_rand_list <- function(study_id, req, res) { - audit_log_event_type("get_rand_list", req) + audit_log_set_event_type("get_rand_list", req) db_connection_pool <- get("db_connection_pool") study_id <- req$args$study_id @@ -12,7 +12,7 @@ api_get_rand_list <- function(study_id, req, res) { error = "Study not found" )) } - audit_log_study_id(study_id, req) + audit_log_set_study_id(study_id, req) patients <- dplyr::tbl(db_connection_pool, "patient") |> diff --git a/R/api_get_study.R b/R/api_get_study.R index e3bb48e..2317db2 100644 --- a/R/api_get_study.R +++ b/R/api_get_study.R @@ -12,7 +12,7 @@ api_get_study <- function(req, res) { } api_get_study_records <- function(study_id, req, res) { - audit_log_event_type("get_study_record", req) + audit_log_set_event_type("get_study_record", req) db_connection_pool <- get("db_connection_pool") study_id <- req$args$study_id @@ -23,7 +23,7 @@ api_get_study_records <- function(study_id, req, res) { error = "Study not found" )) } - audit_log_study_id(study_id, req) + audit_log_set_study_id(study_id, req) study <- dplyr::tbl(db_connection_pool, "study") |> diff --git a/R/api_randomize.R b/R/api_randomize.R index 17fd36c..28f39e2 100644 --- a/R/api_randomize.R +++ b/R/api_randomize.R @@ -27,7 +27,7 @@ parse_pocock_parameters <- } api__randomize_patient <- function(study_id, current_state, req, res) { - audit_log_event_type("randomize_patient", req) + audit_log_set_event_type("randomize_patient", req) collection <- checkmate::makeAssertCollection() db_connection_pool <- get("db_connection_pool") @@ -41,7 +41,7 @@ api__randomize_patient <- function(study_id, current_state, req, res) { )) } - audit_log_study_id(study_id, req) + audit_log_set_study_id(study_id, req) # Retrieve study details, especially the ones about randomization method_randomization <- diff --git a/R/audit-trail.R b/R/audit-trail.R index a063ec5..b8457d6 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -4,11 +4,10 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. "AuditLog", public = list( - initialize = function(request_method, endpoint_url, request_body) { + initialize = function(request_method, endpoint_url) { private$request_id <- uuid::UUIDgenerate() private$request_method <- request_method private$endpoint_url <- endpoint_url - private$request_body <- request_body }, disable = function() { private$disabled <- TRUE @@ -19,6 +18,10 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. set_request_body = function(request_body) { if (typeof(request_body) == "list") { request_body <- jsonlite::toJSON(request_body, auto_unbox = TRUE) |> as.character() + } else if (!is.character(request_body)) { + request_body <- NA + } else if (!jsonlite::validate(request_body)) { + request_body <- NA } private$request_body <- request_body }, @@ -116,11 +119,7 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. #' pr <- plumber::plumb("your-api-definition.R") |> #' setup_audit_trail() setup_audit_trail <- function(pr, endpoints = list()) { - checkmate::assert( - is.list(endpoints), - all(sapply(endpoints, is.character)), - "endpoints must be a list of strings" - ) + checkmate::assert_list(endpoints, types = "character") is_enabled_for_request <- function(req) { any(sapply(endpoints, \(endpoint) grepl(endpoint, req$PATH_INFO))) } @@ -133,8 +132,7 @@ setup_audit_trail <- function(pr, endpoints = list()) { } audit_log <- AuditLog$new( request_method = req$REQUEST_METHOD, - endpoint_url = req$PATH_INFO, - request_body = req$body + endpoint_url = req$PATH_INFO ) req$.internal.audit_log <- audit_log }) @@ -169,7 +167,7 @@ setup_audit_trail <- function(pr, endpoints = list()) { #' @param event_type The event type to be set for the audit log. #' @param req The request object, which should contain an audit log in its internal data. #' @return Returns nothing as it modifies the audit log in-place. -audit_log_event_type <- function(event_type, req) { +audit_log_set_event_type <- function(event_type, req) { audit_log <- req$.internal.audit_log if (!is.null(audit_log)) { audit_log$set_event_type(event_type) @@ -184,8 +182,8 @@ audit_log_event_type <- function(event_type, req) { #' @param study_id The study ID to be set for the audit log. #' @param req The request object, which should contain an audit log in its internal data. #' @return Returns nothing as it modifies the audit log in-place. -audit_log_study_id <- function(study_id, req) { - assert(!is.null(study_id) || is.numeric(study_id), "Study ID must be a number") +audit_log_set_study_id <- function(study_id, req) { + checkmate::assert(!is.null(study_id) && is.numeric(study_id), "Study ID must be a number") audit_log <- req$.internal.audit_log if (!is.null(audit_log)) { audit_log$set_study_id(study_id) From b82633abd4e3a384cc0fbd9c026323d323805621 Mon Sep 17 00:00:00 2001 From: lwalejko Date: Mon, 4 Mar 2024 09:23:53 +0000 Subject: [PATCH 228/240] Update documentation --- man/AuditLog.Rd | 2 +- ...{audit_log_event_type.Rd => audit_log_set_event_type.Rd} | 6 +++--- man/{audit_log_study_id.Rd => audit_log_set_study_id.Rd} | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) rename man/{audit_log_event_type.Rd => audit_log_set_event_type.Rd} (84%) rename man/{audit_log_study_id.Rd => audit_log_set_study_id.Rd} (85%) diff --git a/man/AuditLog.Rd b/man/AuditLog.Rd index 2bb79f0..aa4fc97 100644 --- a/man/AuditLog.Rd +++ b/man/AuditLog.Rd @@ -27,7 +27,7 @@ This class is used internally to store audit logs for each request. \if{latex}{\out{\hypertarget{method-AuditLog-new}{}}} \subsection{Method \code{new()}}{ \subsection{Usage}{ -\if{html}{\out{
}}\preformatted{AuditLog$new(request_method, endpoint_url, request_body)}\if{html}{\out{
}} +\if{html}{\out{
}}\preformatted{AuditLog$new(request_method, endpoint_url)}\if{html}{\out{
}} } } diff --git a/man/audit_log_event_type.Rd b/man/audit_log_set_event_type.Rd similarity index 84% rename from man/audit_log_event_type.Rd rename to man/audit_log_set_event_type.Rd index 9a8adcc..40d7f85 100644 --- a/man/audit_log_event_type.Rd +++ b/man/audit_log_set_event_type.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/audit-trail.R -\name{audit_log_event_type} -\alias{audit_log_event_type} +\name{audit_log_set_event_type} +\alias{audit_log_set_event_type} \title{Set Audit Log Event Type} \usage{ -audit_log_event_type(event_type, req) +audit_log_set_event_type(event_type, req) } \arguments{ \item{event_type}{The event type to be set for the audit log.} diff --git a/man/audit_log_study_id.Rd b/man/audit_log_set_study_id.Rd similarity index 85% rename from man/audit_log_study_id.Rd rename to man/audit_log_set_study_id.Rd index 636ea90..6fe9076 100644 --- a/man/audit_log_study_id.Rd +++ b/man/audit_log_set_study_id.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/audit-trail.R -\name{audit_log_study_id} -\alias{audit_log_study_id} +\name{audit_log_set_study_id} +\alias{audit_log_set_study_id} \title{Set Audit Log Study ID} \usage{ -audit_log_study_id(study_id, req) +audit_log_set_study_id(study_id, req) } \arguments{ \item{study_id}{The study ID to be set for the audit log.} From 712b54f697421efbe1dc3a0740089ed29316198e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 11:12:06 +0000 Subject: [PATCH 229/240] Add IP address and user agent to audit log --- R/audit-trail.R | 22 +++++++++++++++---- ...dress_and_user_agent_to_audit_log.down.sql | 2 ++ ...address_and_user_agent_to_audit_log.up.sql | 2 ++ 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.down.sql create mode 100644 inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.up.sql diff --git a/R/audit-trail.R b/R/audit-trail.R index b8457d6..fd80f67 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -31,6 +31,12 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. ) private$response_body <- response_body }, + set_ip_address = function(ip_address) { + private$ip_address <- ip_address + }, + set_user_agent = function(user_agent) { + private$user_agent <- user_agent + }, set_event_type = function(event_type) { private$event_type <- event_type }, @@ -69,7 +75,9 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. private$request_method, private$request_body, private$response_code, - private$response_body + private$response_body, + private$ip_address, + private$user_agent ) values <- purrr::map(values, \(x) ifelse(is.null(x), NA, x)) @@ -84,9 +92,11 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. request_method, request_body, response_code, - response_body + response_body, + ip_address, + user_agent ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", values ) } @@ -100,7 +110,9 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. request_method = NULL, response_code = NULL, request_body = NULL, - response_body = NULL + response_body = NULL, + ip_address = NULL, + user_agent = NULL ) ) @@ -146,6 +158,8 @@ setup_audit_trail <- function(pr, endpoints = list()) { audit_log$set_response_code(res$status) audit_log$set_request_body(req$body) audit_log$set_response_body(res$body) + audit_log$set_ip_address(req$REMOTE_ADDR) + audit_log$set_user_agent(req$HTTP_USER_AGENT) log_valid <- audit_log$validate_log() diff --git a/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.down.sql b/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.down.sql new file mode 100644 index 0000000..d4baee8 --- /dev/null +++ b/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE audit_log DROP COLUMN ip_address; +ALTER TABLE audit_log DROP COLUMN user_agent; \ No newline at end of file diff --git a/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.up.sql b/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.up.sql new file mode 100644 index 0000000..aa15654 --- /dev/null +++ b/inst/db/migrations/20240304105844_add_ip_address_and_user_agent_to_audit_log.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE audit_log ADD COLUMN ip_address VARCHAR(255); +ALTER TABLE audit_log ADD COLUMN user_agent TEXT; \ No newline at end of file From 4a171c7bef6e2039178477bce75cdf3c5c7f347d Mon Sep 17 00:00:00 2001 From: lwalejko Date: Mon, 4 Mar 2024 11:16:23 +0000 Subject: [PATCH 230/240] Update documentation --- man/AuditLog.Rd | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/man/AuditLog.Rd b/man/AuditLog.Rd index aa4fc97..dd413d9 100644 --- a/man/AuditLog.Rd +++ b/man/AuditLog.Rd @@ -14,6 +14,8 @@ This class is used internally to store audit logs for each request. \item \href{#method-AuditLog-is_enabled}{\code{AuditLog$is_enabled()}} \item \href{#method-AuditLog-set_request_body}{\code{AuditLog$set_request_body()}} \item \href{#method-AuditLog-set_response_body}{\code{AuditLog$set_response_body()}} +\item \href{#method-AuditLog-set_ip_address}{\code{AuditLog$set_ip_address()}} +\item \href{#method-AuditLog-set_user_agent}{\code{AuditLog$set_user_agent()}} \item \href{#method-AuditLog-set_event_type}{\code{AuditLog$set_event_type()}} \item \href{#method-AuditLog-set_study_id}{\code{AuditLog$set_study_id()}} \item \href{#method-AuditLog-set_response_code}{\code{AuditLog$set_response_code()}} @@ -66,6 +68,24 @@ This class is used internally to store audit logs for each request. \if{html}{\out{
}}\preformatted{AuditLog$set_response_body(response_body)}\if{html}{\out{
}} } +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_ip_address}{}}} +\subsection{Method \code{set_ip_address()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_ip_address(ip_address)}\if{html}{\out{
}} +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-AuditLog-set_user_agent}{}}} +\subsection{Method \code{set_user_agent()}}{ +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{AuditLog$set_user_agent(user_agent)}\if{html}{\out{
}} +} + } \if{html}{\out{
}} \if{html}{\out{}} From 604d4192ef935f882aa1bcf424d9e236d2ab197a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 11:17:37 +0000 Subject: [PATCH 231/240] Remove unnecessary JSON validation --- R/audit-trail.R | 2 -- 1 file changed, 2 deletions(-) diff --git a/R/audit-trail.R b/R/audit-trail.R index fd80f67..e6932a4 100644 --- a/R/audit-trail.R +++ b/R/audit-trail.R @@ -20,8 +20,6 @@ AuditLog <- R6::R6Class( # nolint: object_name_linter. request_body <- jsonlite::toJSON(request_body, auto_unbox = TRUE) |> as.character() } else if (!is.character(request_body)) { request_body <- NA - } else if (!jsonlite::validate(request_body)) { - request_body <- NA } private$request_body <- request_body }, From 0c88cc608220c28404bb2637344935a48883775f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 14:39:34 +0000 Subject: [PATCH 232/240] test for malformed request --- R/error-handling.R | 1 + inst/plumber/unbiased_api/plumber.R | 2 +- tests/testthat/audit-log-test-helpers.R | 4 ++-- tests/testthat/test-api-audit-log.R | 2 ++ tests/testthat/test-malformed-requests.R | 17 +++++++++++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/testthat/test-malformed-requests.R diff --git a/R/error-handling.R b/R/error-handling.R index cc1b88d..9bb0e85 100644 --- a/R/error-handling.R +++ b/R/error-handling.R @@ -77,6 +77,7 @@ setup_invalid_json_handler <- function(api) { error = \(e) e ) if (!is.null(e)) { + print(glue::glue("Invalid JSON; requested endpoint: {req$PATH_INFO}")) audit_log_set_event_type("malformed_request", req) res$status <- 400 return(list( diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 75e4660..7a9a5d7 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -37,7 +37,7 @@ function(api) { plumber::pr_mount("/meta", meta) |> plumber::pr_mount("/study", study) |> unbiased:::setup_audit_trail(endpoints = list( - "^/study/.*" + "^/study.*" )) |> plumber::pr_set_api_spec(function(spec) { spec$ diff --git a/tests/testthat/audit-log-test-helpers.R b/tests/testthat/audit-log-test-helpers.R index b4cb974..f7b2e54 100644 --- a/tests/testthat/audit-log-test-helpers.R +++ b/tests/testthat/audit-log-test-helpers.R @@ -31,10 +31,10 @@ assert_audit_trail_for_test <- function(events = list(), env = parent.frame()) { n <- length(events) # assert that the count has increased by number of events - testthat::expect_equal( + testthat::expect_identical( new_event_count, event_count + n, - info = "Expected events to be logged" + info = glue::glue("Expected {n} events to be logged") ) if (n > 0) { diff --git a/tests/testthat/test-api-audit-log.R b/tests/testthat/test-api-audit-log.R index 0980eb8..471d7a4 100644 --- a/tests/testthat/test-api-audit-log.R +++ b/tests/testthat/test-api-audit-log.R @@ -46,6 +46,7 @@ testthat::test_that("audit logs for study are returned correctly from the databa }) testthat::test_that("should return 404 when study does not exist", { + with_db_fixtures("fixtures/example_audit_logs.yml") response <- request(api_url) |> req_url_path("study", 1111, "audit") |> req_method("GET") |> @@ -61,6 +62,7 @@ testthat::test_that("should return 404 when study does not exist", { }) testthat::test_that("should not log audit trail for non-existent endpoint", { + with_db_fixtures("fixtures/example_audit_logs.yml") assert_audit_trail_for_test(events = c()) response <- request(api_url) |> req_url_path("study", 1, "non-existent-endpoint") |> diff --git a/tests/testthat/test-malformed-requests.R b/tests/testthat/test-malformed-requests.R new file mode 100644 index 0000000..22e279c --- /dev/null +++ b/tests/testthat/test-malformed-requests.R @@ -0,0 +1,17 @@ +source("./test-helpers.R") +source("./audit-log-test-helpers.R") + +testthat::test_that("should handle malformed request correctly", { + with_db_fixtures("fixtures/example_audit_logs.yml") + assert_audit_trail_for_test(events = c("malformed_request")) + malformed_json <- "test { test }" + response <- + request(api_url) |> + req_url_path("study") |> + req_method("POST") |> + req_error(is_error = \(x) FALSE) |> + req_body_raw(malformed_json) |> # <--- Malformed request + req_perform() + + testthat::expect_equal(response$status_code, 400) +}) From 72c0a3a468212989b781f8908e3ba3efcbb8df05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 16:50:00 +0000 Subject: [PATCH 233/240] Add IP address and user agent to audit logs api --- R/api-audit-log.R | 1 + .../testthat/fixtures/example_audit_logs.yml | 16 +++++++++++++-- tests/testthat/test-api-audit-log.R | 20 ++++++++++++++----- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/R/api-audit-log.R b/R/api-audit-log.R index 4403870..cba00fd 100644 --- a/R/api-audit-log.R +++ b/R/api-audit-log.R @@ -11,6 +11,7 @@ api_get_audit_log <- function(study_id, req, res) { # Get audit trial audit_trail <- dplyr::tbl(db_connection_pool, "audit_log") |> dplyr::filter(study_id == !!study_id) |> + dplyr::arrange(created_at) |> dplyr::collect() audit_trail$request_body <- purrr::map( diff --git a/tests/testthat/fixtures/example_audit_logs.yml b/tests/testthat/fixtures/example_audit_logs.yml index 607dfe5..5b28b3c 100644 --- a/tests/testthat/fixtures/example_audit_logs.yml +++ b/tests/testthat/fixtures/example_audit_logs.yml @@ -23,6 +23,8 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70002" created_at: "2022-02-16T10:27:53Z" event_type: "example_event" @@ -33,6 +35,8 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70003" created_at: "2022-02-16T10:27:53Z" event_type: "example_event" @@ -43,8 +47,10 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70004" - created_at: "2022-02-16T10:27:53Z" + created_at: "2023-02-16T10:27:53Z" event_type: "example_event" request_id: "427ac2db-166d-4236-b040-94213f1b0004" study_id: 2 @@ -53,8 +59,10 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70005" - created_at: "2022-02-16T10:27:53Z" + created_at: "2022-02-16T10:27:54Z" event_type: "example_event" request_id: "427ac2db-166d-4236-b040-94213f1b0004" study_id: 2 @@ -63,6 +71,8 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" - id: "c12d29e7-1b44-4cb6-a9c1-1f427fe70006" created_at: "2022-02-16T10:27:53Z" event_type: "example_event" @@ -73,3 +83,5 @@ audit_log: request_body: '{"key1": "value1", "key2": "value2"}' response_code: 200 response_body: '{"key1": "value1", "key2": "value2"}' + ip_address: "8.8.8.8" + user_agent: "Mozilla" diff --git a/tests/testthat/test-api-audit-log.R b/tests/testthat/test-api-audit-log.R index 471d7a4..2ae767f 100644 --- a/tests/testthat/test-api-audit-log.R +++ b/tests/testthat/test-api-audit-log.R @@ -7,7 +7,8 @@ testthat::test_that("audit logs for study are returned correctly from the databa counts <- c(1, 4, 1) for (i in 1:3) { study_id <- studies[i] - count <- counts[i] + count <- counts[i] |> + as.integer() response <- request(api_url) |> req_url_path("study", study_id, "audit") |> req_method("GET") |> @@ -17,12 +18,18 @@ testthat::test_that("audit logs for study are returned correctly from the databa response |> resp_body_json() - testthat::expect_equal(response$status_code, 200) - testthat::expect_equal(length(response_body), count) + testthat::expect_identical(response$status_code, 200L) + testthat::expect_identical(length(response_body), count) + + created_at <- response_body |> dplyr::bind_rows() |> dplyr::pull("created_at") + testthat::expect_equal( + created_at, + created_at |> sort() + ) if (count > 0) { body <- response_body[[1]] - testthat::expect_equal(names(body), c( + testthat::expect_setequal(names(body), c( "id", "created_at", "event_type", @@ -32,8 +39,11 @@ testthat::test_that("audit logs for study are returned correctly from the databa "request_method", "request_body", "response_code", - "response_body" + "response_body", + "user_agent", + "ip_address" )) + testthat::expect_equal(body$study_id, study_id) testthat::expect_equal(body$event_type, "example_event") testthat::expect_equal(body$request_method, "GET") From dc0a5c1a59901d82720fcdfa105e613e71e1f2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 17:32:49 +0000 Subject: [PATCH 234/240] Update pkgdown build_site_github_pages install parameter --- .github/workflows/pkgdown.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index ed7650c..ba70743 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -36,7 +36,7 @@ jobs: needs: website - name: Build site - run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) + run: pkgdown::build_site_github_pages(new_process = FALSE, install = TRUE) shell: Rscript {0} - name: Deploy to GitHub pages 🚀 From 4856c17fba1bba641fd09d981be6a1ecdd9e3764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Mon, 4 Mar 2024 17:57:08 +0000 Subject: [PATCH 235/240] Add setup-renv action for package dependencies --- .github/workflows/pkgdown.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index ba70743..0c39b7c 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -34,6 +34,8 @@ jobs: with: extra-packages: any::pkgdown, local::. needs: website + + - uses: r-lib/actions/setup-renv@v2 - name: Build site run: pkgdown::build_site_github_pages(new_process = FALSE, install = TRUE) From 44c626f20a0759af5e6eabf35d309400f9134c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 5 Mar 2024 12:25:22 +0000 Subject: [PATCH 236/240] update packages and restore renv in pkgdown workflow --- .github/workflows/pkgdown.yaml | 9 +- .github/workflows/test-coverage.yaml | 2 +- renv.lock | 274 +++++++++++++-------------- renv/activate.R | 106 ++++++----- 4 files changed, 204 insertions(+), 187 deletions(-) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 0c39b7c..8396a34 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -22,19 +22,20 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v3 - - - uses: r-lib/actions/setup-pandoc@v2 + - uses: actions/checkout@v4 - uses: r-lib/actions/setup-r@v2 with: use-public-rspm: true + install-pandoc: true - uses: r-lib/actions/setup-r-dependencies@v2 with: extra-packages: any::pkgdown, local::. needs: website - + + # - uses: r-lib/actions/setup-pandoc@v2 + - uses: r-lib/actions/setup-renv@v2 - name: Build site diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 9efc03f..7cb2dd5 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -33,7 +33,7 @@ jobs: - 5432:5432 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/renv.lock b/renv.lock index c4ecca1..985d2c8 100644 --- a/renv.lock +++ b/renv.lock @@ -11,25 +11,25 @@ "Packages": { "BH": { "Package": "BH", - "Version": "1.81.0-1", + "Version": "1.84.0-0", "Source": "Repository", "Repository": "RSPM", - "Hash": "68122010f01c4dcfbe58ce7112f2433d" + "Hash": "a8235afbcd6316e6e91433ea47661013" }, "DBI": { "Package": "DBI", - "Version": "1.2.0", + "Version": "1.2.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "3e0051431dff9acfe66c23765e55c556" + "Hash": "164809cd72e1d5160b4cb3aa57f510fe" }, "MASS": { "Package": "MASS", - "Version": "7.3-57", + "Version": "7.3-60.0.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -40,15 +40,16 @@ "stats", "utils" ], - "Hash": "71476c1d88d1ebdf31580e5a257d5d31" + "Hash": "b765b28387acc8ec9e9c1530713cb19c" }, "Matrix": { "Package": "Matrix", - "Version": "1.4-1", + "Version": "1.6-5", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", + "grDevices", "graphics", "grid", "lattice", @@ -56,7 +57,7 @@ "stats", "utils" ], - "Hash": "699c47c606293bdfbc9fd78a93c9c8fe" + "Hash": "8c7115cd3a0e048bda2a7cd110549f7a" }, "PwrGSD": { "Package": "PwrGSD", @@ -109,18 +110,18 @@ }, "Rcpp": { "Package": "Rcpp", - "Version": "1.0.11", + "Version": "1.0.12", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "methods", "utils" ], - "Hash": "ae6cbbe1492f4de79c45fce06f967ce8" + "Hash": "5ea2700d21e038ace58269ecdbeb9ec0" }, "RcppArmadillo": { "Package": "RcppArmadillo", - "Version": "0.12.6.6.1", + "Version": "0.12.8.1.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -130,11 +131,11 @@ "stats", "utils" ], - "Hash": "d2b60e0a15d73182a3a766ff0a7d0d7f" + "Hash": "e78bbbb81a5dcd71a4bd3268d6ede0b1" }, "RcppEigen": { "Package": "RcppEigen", - "Version": "0.3.3.9.4", + "Version": "0.3.4.0.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -143,7 +144,7 @@ "stats", "utils" ], - "Hash": "acb0a5bf38490f26ab8661b467f4f53a" + "Hash": "df49e3306f232ec28f1604e36a202847" }, "TH.data": { "Package": "TH.data", @@ -159,7 +160,7 @@ }, "V8": { "Package": "V8", - "Version": "4.4.1", + "Version": "4.4.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -168,7 +169,7 @@ "jsonlite", "utils" ], - "Hash": "435359b59b8a9b8f9235135da471ea3c" + "Hash": "ca98390ad1cef2a5a609597b49d3d042" }, "askpass": { "Package": "askpass", @@ -212,14 +213,9 @@ }, "bigmemory": { "Package": "bigmemory", - "Version": "4.6.2", - "Source": "GitHub", - "RemoteType": "github", - "RemoteHost": "api.github.com", - "RemoteRepo": "bigmemory", - "RemoteUsername": "kaneplusplus", - "RemoteRef": "HEAD", - "RemoteSha": "3064277f4a83b74490464ea4ac5a43f76e426ada", + "Version": "4.6.4", + "Source": "Repository", + "Repository": "RSPM", "Requirements": [ "BH", "R", @@ -229,17 +225,17 @@ "utils", "uuid" ], - "Hash": "65fe01c6e8e22c8bd0c6f5b5e3ccf19e" + "Hash": "96b3f1272c36f003f6c6c34171a57e05" }, "bigmemory.sri": { "Package": "bigmemory.sri", - "Version": "0.1.6", + "Version": "0.1.8", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "methods" ], - "Hash": "cd3e474a907284c598e60417a5edeb79" + "Hash": "ca3079c10ffaf7c18b783f13d3a0bc2f" }, "bit": { "Package": "bit", @@ -293,10 +289,13 @@ }, "brio": { "Package": "brio", - "Version": "1.1.3", + "Version": "1.1.4", "Source": "Repository", "Repository": "RSPM", - "Hash": "976cf154dfb043c012d87cddd8bca363" + "Requirements": [ + "R" + ], + "Hash": "68bd2b066e1fe780bbf62fc8bcc36de3" }, "broom": { "Package": "broom", @@ -374,7 +373,7 @@ }, "callr": { "Package": "callr", - "Version": "3.7.3", + "Version": "3.7.5", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -383,23 +382,23 @@ "processx", "utils" ], - "Hash": "9b2191ede20fa29828139b9900922e51" + "Hash": "9f0e4fae4963ba775a5e5c520838c87b" }, "checkmate": { "Package": "checkmate", - "Version": "2.2.0", + "Version": "2.3.1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "backports", "utils" ], - "Hash": "ca9c113196136f4a9ca9ce6079c2c99e" + "Hash": "c01cab1cb0f9125211a6fc99d540e315" }, "class": { "Package": "class", - "Version": "7.3-20", + "Version": "7.3-22", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -408,18 +407,18 @@ "stats", "utils" ], - "Hash": "da09d82223e669d270e47ed24ac8686e" + "Hash": "f91f6b29f38b8c280f2b9477787d4bb2" }, "cli": { "Package": "cli", - "Version": "3.6.1", + "Version": "3.6.2", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "89e6d8219950eac806ae0c489052048a" + "Hash": "1216ac65ac55ec0058a6f75d7ca0fd52" }, "clipr": { "Package": "clipr", @@ -478,20 +477,20 @@ }, "commonmark": { "Package": "commonmark", - "Version": "1.9.0", + "Version": "1.9.1", "Source": "Repository", "Repository": "RSPM", - "Hash": "d691c61bff84bd63c383874d2d0c3307" + "Hash": "5d8225445acb167abf7797de48b2ee3c" }, "cpp11": { "Package": "cpp11", - "Version": "0.4.6", + "Version": "0.4.7", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R" ], - "Hash": "707fae4bbf73697ec8d85f9d7076c061" + "Hash": "5a295d7d963cc5035284dcdbaf334f4e" }, "crayon": { "Package": "crayon", @@ -521,24 +520,24 @@ }, "curl": { "Package": "curl", - "Version": "5.1.0", + "Version": "5.2.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R" ], - "Hash": "9123f3ef96a2c1a93927d828b2fe7d4c" + "Hash": "411ca2c03b1ce5f548345d2fc2685f7a" }, "data.table": { "Package": "data.table", - "Version": "1.14.10", + "Version": "1.15.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "6ea17a32294d8ca00455825ab0cf71b9" + "Hash": "536dfe4ac4093b5d115caed7a1a7223b" }, "dbplyr": { "Package": "dbplyr", @@ -570,17 +569,16 @@ }, "desc": { "Package": "desc", - "Version": "1.4.2", + "Version": "1.4.3", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "R6", "cli", - "rprojroot", "utils" ], - "Hash": "6b9602c7ebbe87101a9c8edb6e8b6d21" + "Hash": "99b79fcbd6c4d1ce087f5c5c758b384f" }, "devtools": { "Package": "devtools", @@ -633,14 +631,14 @@ }, "digest": { "Package": "digest", - "Version": "0.6.33", + "Version": "0.6.34", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "b18a9cf3c003977b0cc49d5e76ebe48d" + "Hash": "7ede2ee9ea8d3edbf1ca84c1e333ad1a" }, "downlit": { "Package": "downlit", @@ -664,7 +662,7 @@ }, "dplyr": { "Package": "dplyr", - "Version": "1.1.3", + "Version": "1.1.4", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -683,7 +681,7 @@ "utils", "vctrs" ], - "Hash": "e85ffbebaad5f70e1a2e2ef4302b4949" + "Hash": "fedd9d00c2944ff00a0e2696ccf048ec" }, "e1071": { "Package": "e1071", @@ -714,18 +712,18 @@ }, "evaluate": { "Package": "evaluate", - "Version": "0.22", + "Version": "0.23", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "methods" ], - "Hash": "66f39c7a21e03c4dcb2c2d21d738d603" + "Hash": "daf4a1246be12c1fa8c7705a0935c1a0" }, "fansi": { "Package": "fansi", - "Version": "1.0.5", + "Version": "1.0.6", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -733,7 +731,7 @@ "grDevices", "utils" ], - "Hash": "3e8583a60163b4bc1a80016e63b9959e" + "Hash": "962174cf2aeb5b9eea581522286a911f" }, "farver": { "Package": "farver", @@ -843,7 +841,7 @@ }, "ggplot2": { "Package": "ggplot2", - "Version": "3.4.4", + "Version": "3.5.0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -864,7 +862,7 @@ "vctrs", "withr" ], - "Hash": "313d31eff2274ecf4c1d3581db7241f9" + "Hash": "52ef83f93f74833007f193b2d4c159a2" }, "gh": { "Package": "gh", @@ -917,7 +915,7 @@ }, "gsDesign": { "Package": "gsDesign", - "Version": "3.6.0", + "Version": "3.6.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -936,11 +934,11 @@ "tools", "xtable" ], - "Hash": "496b38bfc6524e1a1fc04220da550892" + "Hash": "b3490a78ab0a4cd22d19e732b20225da" }, "gt": { "Package": "gt", - "Version": "0.10.0", + "Version": "0.10.1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -962,11 +960,11 @@ "rlang", "sass", "scales", - "tibble", "tidyselect", + "vctrs", "xml2" ], - "Hash": "21737c74811cccac01b5097bcb0f8b4c" + "Hash": "03009c105dfae79460b8eb9d8cf791e4" }, "gtable": { "Package": "gtable", @@ -1100,9 +1098,9 @@ }, "httpuv": { "Package": "httpuv", - "Version": "1.6.11", + "Version": "1.6.14", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "R6", @@ -1111,7 +1109,7 @@ "promises", "utils" ], - "Hash": "838602f54e32c1a0f8cc80708cefcefa" + "Hash": "16abeb167dbf511f8cc0552efaf05bab" }, "httr": { "Package": "httr", @@ -1256,20 +1254,20 @@ }, "later": { "Package": "later", - "Version": "1.3.1", + "Version": "1.3.2", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "Rcpp", "rlang" ], - "Hash": "40401c9cf2bc2259dfe83311c9384710" + "Hash": "a3e051d405326b8b0012377434c62b37" }, "lattice": { "Package": "lattice", - "Version": "0.20-45", + "Version": "0.22-5", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "grDevices", @@ -1278,7 +1276,7 @@ "stats", "utils" ], - "Hash": "b64cdbb2b340437c4ee047a1f4c4377b" + "Hash": "7c5e89f04e72d6611c77451f6331a091" }, "libcoin": { "Package": "libcoin", @@ -1294,16 +1292,16 @@ }, "lifecycle": { "Package": "lifecycle", - "Version": "1.0.3", + "Version": "1.0.4", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "cli", "glue", "rlang" ], - "Hash": "001cecbeac1cff9301bdc3775ee46a86" + "Hash": "b8552d117e1b808b09a832f589b79035" }, "logger": { "Package": "logger", @@ -1381,7 +1379,7 @@ }, "mgcv": { "Package": "mgcv", - "Version": "1.8-40", + "Version": "1.9-1", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1394,7 +1392,7 @@ "stats", "utils" ], - "Hash": "c6b2fdb18cf68ab613bd564363e1ba0d" + "Hash": "110ee9d83b496279960e162ac97764ce" }, "mime": { "Package": "mime", @@ -1519,9 +1517,9 @@ }, "nlme": { "Package": "nlme", - "Version": "3.1-162", + "Version": "3.1-164", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "graphics", @@ -1529,7 +1527,7 @@ "stats", "utils" ], - "Hash": "0984ce8da8da9ead8643c5cbbb60f83e" + "Hash": "a623a2239e642806158bc4dc3f51565d" }, "numDeriv": { "Package": "numDeriv", @@ -1582,7 +1580,7 @@ }, "pkgbuild": { "Package": "pkgbuild", - "Version": "1.4.2", + "Version": "1.4.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1590,13 +1588,10 @@ "R6", "callr", "cli", - "crayon", "desc", - "prettyunits", - "processx", - "rprojroot" + "processx" ], - "Hash": "beb25b32a957a22a5c301a9e441190b3" + "Hash": "c0143443203205e6a2760ce553dafc24" }, "pkgconfig": { "Package": "pkgconfig", @@ -1640,7 +1635,7 @@ }, "pkgload": { "Package": "pkgload", - "Version": "1.3.3", + "Version": "1.3.4", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1657,7 +1652,7 @@ "utils", "withr" ], - "Hash": "903d68319ae9923fb2e2ee7fa8230b91" + "Hash": "876c618df5ae610be84356d5d7a5d124" }, "plogr": { "Package": "plogr", @@ -1717,7 +1712,7 @@ }, "pool": { "Package": "pool", - "Version": "1.0.1", + "Version": "1.0.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1726,10 +1721,9 @@ "R6", "later", "methods", - "rlang", - "withr" + "rlang" ], - "Hash": "52d086ff1a2ccccbae6d462cb0773835" + "Hash": "b336b9f1b3cc72033258c70dc17edbf1" }, "praise": { "Package": "praise", @@ -1750,7 +1744,7 @@ }, "processx": { "Package": "processx", - "Version": "3.8.2", + "Version": "3.8.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1759,7 +1753,7 @@ "ps", "utils" ], - "Hash": "3efbd8ac1be0296a46c55387aeace0f3" + "Hash": "82d48b1aec56084d9438dbf98087a7e9" }, "profvis": { "Package": "profvis", @@ -1820,14 +1814,14 @@ }, "ps": { "Package": "ps", - "Version": "1.7.5", + "Version": "1.7.6", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R", "utils" ], - "Hash": "709d852d33178db54b17c722e5b1e594" + "Hash": "dd2b9319ee0656c8acf45c7f40c59de7" }, "purrr": { "Package": "purrr", @@ -1951,7 +1945,7 @@ }, "readr": { "Package": "readr", - "Version": "2.1.4", + "Version": "2.1.5", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1970,7 +1964,7 @@ "utils", "vroom" ], - "Hash": "b5047343b3825f37ad9d3b5d89aa1078" + "Hash": "9de96463d2117f6ac49980577939dfb3" }, "rematch2": { "Package": "rematch2", @@ -1998,13 +1992,13 @@ }, "renv": { "Package": "renv", - "Version": "1.0.0", + "Version": "1.0.5", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "utils" ], - "Hash": "c321cd99d56443dbffd1c9e673c0c1a2" + "Hash": "32c3f93e8360f667ca5863272ec8ba6a" }, "reshape2": { "Package": "reshape2", @@ -2082,13 +2076,13 @@ }, "rprojroot": { "Package": "rprojroot", - "Version": "2.0.3", + "Version": "2.0.4", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R" ], - "Hash": "1de7ab598047a87bba48434ba35d497d" + "Hash": "4c8415e0ec1e29f3f4f6fc108bef0144" }, "rstudioapi": { "Package": "rstudioapi", @@ -2238,10 +2232,10 @@ }, "sodium": { "Package": "sodium", - "Version": "1.3.0", + "Version": "1.3.1", "Source": "Repository", "Repository": "RSPM", - "Hash": "bd436c1e48dc1982125e4d955017724e" + "Hash": "dd86d6fd2a01d4eb3777dfdee7076d56" }, "sourcetools": { "Package": "sourcetools", @@ -2255,22 +2249,22 @@ }, "stringi": { "Package": "stringi", - "Version": "1.7.12", + "Version": "1.8.3", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "stats", "tools", "utils" ], - "Hash": "ca8bd84263c77310739d2cf64d84d7c9" + "Hash": "058aebddea264f4c99401515182e656a" }, "stringr": { "Package": "stringr", - "Version": "1.5.0", + "Version": "1.5.1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "cli", @@ -2281,7 +2275,7 @@ "stringi", "vctrs" ], - "Hash": "671a4d384ae9d32fc47a14e98bfa3dc8" + "Hash": "960e2ae9e09656611e0b8214ad543207" }, "survey": { "Package": "survey", @@ -2306,7 +2300,7 @@ }, "survival": { "Package": "survival", - "Version": "3.3-1", + "Version": "3.5-8", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -2318,7 +2312,7 @@ "stats", "utils" ], - "Hash": "f6189c70451d3d68e0d571235576e833" + "Hash": "184d7799bca4ba8c3be72ea396f4b9a3" }, "swagger": { "Package": "swagger", @@ -2423,9 +2417,9 @@ }, "tidyr": { "Package": "tidyr", - "Version": "1.3.0", + "Version": "1.3.1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "cli", @@ -2442,7 +2436,7 @@ "utils", "vctrs" ], - "Hash": "e47debdc7ce599b070c8e78e8ac0cfcf" + "Hash": "915fb7ce036c22a6a33b5a8adb712eb1" }, "tidyselect": { "Package": "tidyselect", @@ -2462,14 +2456,14 @@ }, "timechange": { "Package": "timechange", - "Version": "0.2.0", + "Version": "0.3.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "R", "cpp11" ], - "Hash": "8548b44f79a35ba1791308b61e6012d7" + "Hash": "c5f3c201b931cd6474d17d8700ccb1c8" }, "tinytex": { "Package": "tinytex", @@ -2518,7 +2512,7 @@ }, "usethis": { "Package": "usethis", - "Version": "2.2.2", + "Version": "2.2.3", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -2545,31 +2539,31 @@ "withr", "yaml" ], - "Hash": "60e51f0b94d0324dc19e44110098fa9f" + "Hash": "d524fd42c517035027f866064417d7e6" }, "utf8": { "Package": "utf8", - "Version": "1.2.3", + "Version": "1.2.4", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R" ], - "Hash": "1fe17157424bb09c48a8b3b550c753bc" + "Hash": "62b65c52671e6665f803ff02954446e9" }, "uuid": { "Package": "uuid", - "Version": "1.1-1", + "Version": "1.2-0", "Source": "Repository", "Repository": "RSPM", "Requirements": [ "R" ], - "Hash": "3d78edfb977a69fc7a0341bee25e163f" + "Hash": "303c19bfd970bece872f93a824e323d9" }, "vctrs": { "Package": "vctrs", - "Version": "0.6.4", + "Version": "0.6.5", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -2579,7 +2573,7 @@ "lifecycle", "rlang" ], - "Hash": "266c1ca411266ba8f365fcc726444b87" + "Hash": "c03fa420630029418f7e6da3667aac4a" }, "viridisLite": { "Package": "viridisLite", @@ -2619,10 +2613,11 @@ }, "waldo": { "Package": "waldo", - "Version": "0.5.1", + "Version": "0.5.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ + "R", "cli", "diffobj", "fansi", @@ -2632,18 +2627,18 @@ "rlang", "tibble" ], - "Hash": "2c993415154cdb94649d99ae138ff5e5" + "Hash": "c7d3fd6d29ab077cbac8f0e2751449e6" }, "webutils": { "Package": "webutils", - "Version": "1.1", + "Version": "1.2.0", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "curl", "jsonlite" ], - "Hash": "75d8b5b05fe22659b54076563f83f26a" + "Hash": "6a7f2a3084c7249d2f1466d6e4cc2e84" }, "whisker": { "Package": "whisker", @@ -2666,14 +2661,15 @@ }, "xfun": { "Package": "xfun", - "Version": "0.41", + "Version": "0.42", "Source": "Repository", "Repository": "RSPM", "Requirements": [ + "grDevices", "stats", "tools" ], - "Hash": "460a5e0fe46a80ef87424ad216028014" + "Hash": "fd1349170df31f7a10bd98b0189e85af" }, "xml2": { "Package": "xml2", diff --git a/renv/activate.R b/renv/activate.R index cc742fc..9b2e7f1 100644 --- a/renv/activate.R +++ b/renv/activate.R @@ -2,12 +2,27 @@ local({ # the requested version of renv - version <- "1.0.0" + version <- "1.0.5" attr(version, "sha") <- NULL # the project directory project <- getwd() + # use start-up diagnostics if enabled + diagnostics <- Sys.getenv("RENV_STARTUP_DIAGNOSTICS", unset = "FALSE") + if (diagnostics) { + start <- Sys.time() + profile <- tempfile("renv-startup-", fileext = ".Rprof") + utils::Rprof(profile) + on.exit({ + utils::Rprof(NULL) + elapsed <- signif(difftime(Sys.time(), start, units = "auto"), digits = 2L) + writeLines(sprintf("- renv took %s to run the autoloader.", format(elapsed))) + writeLines(sprintf("- Profile: %s", profile)) + print(utils::summaryRprof(profile)) + }, add = TRUE) + } + # figure out whether the autoloader is enabled enabled <- local({ @@ -16,6 +31,14 @@ local({ if (!is.null(override)) return(override) + # if we're being run in a context where R_LIBS is already set, + # don't load -- presumably we're being run as a sub-process and + # the parent process has already set up library paths for us + rcmd <- Sys.getenv("R_CMD", unset = NA) + rlibs <- Sys.getenv("R_LIBS", unset = NA) + if (!is.na(rlibs) && !is.na(rcmd)) + return(FALSE) + # next, check environment variables # TODO: prefer using the configuration one in the future envvars <- c( @@ -35,9 +58,22 @@ local({ }) - if (!enabled) + # bail if we're not enabled + if (!enabled) { + + # if we're not enabled, we might still need to manually load + # the user profile here + profile <- Sys.getenv("R_PROFILE_USER", unset = "~/.Rprofile") + if (file.exists(profile)) { + cfg <- Sys.getenv("RENV_CONFIG_USER_PROFILE", unset = "TRUE") + if (tolower(cfg) %in% c("true", "t", "1")) + sys.source(profile, envir = globalenv()) + } + return(FALSE) + } + # avoid recursion if (identical(getOption("renv.autoloader.running"), TRUE)) { warning("ignoring recursive attempt to run renv autoloader") @@ -504,7 +540,7 @@ local({ # open the bundle for reading # We use gzcon for everything because (from ?gzcon) - # > Reading from a connection which does not supply a ‘gzip’ magic + # > Reading from a connection which does not supply a 'gzip' magic # > header is equivalent to reading from the original connection conn <- gzcon(file(bundle, open = "rb", raw = TRUE)) on.exit(close(conn)) @@ -767,10 +803,12 @@ local({ renv_bootstrap_validate_version <- function(version, description = NULL) { # resolve description file - description <- description %||% { - path <- getNamespaceInfo("renv", "path") - packageDescription("renv", lib.loc = dirname(path)) - } + # + # avoid passing lib.loc to `packageDescription()` below, since R will + # use the loaded version of the package by default anyhow. note that + # this function should only be called after 'renv' is loaded + # https://github.com/rstudio/renv/issues/1625 + description <- description %||% packageDescription("renv") # check whether requested version 'version' matches loaded version of renv sha <- attr(version, "sha", exact = TRUE) @@ -841,7 +879,7 @@ local({ hooks <- getHook("renv::autoload") for (hook in hooks) if (is.function(hook)) - tryCatch(hook(), error = warning) + tryCatch(hook(), error = warnify) # load the project renv::load(project) @@ -982,10 +1020,15 @@ local({ } - renv_bootstrap_version_friendly <- function(version, sha = NULL) { + renv_bootstrap_version_friendly <- function(version, shafmt = NULL, sha = NULL) { sha <- sha %||% attr(version, "sha", exact = TRUE) - parts <- c(version, sprintf("[sha: %s]", substring(sha, 1L, 7L))) - paste(parts, collapse = " ") + parts <- c(version, sprintf(shafmt %||% " [sha: %s]", substring(sha, 1L, 7L))) + paste(parts, collapse = "") + } + + renv_bootstrap_exec <- function(project, libpath, version) { + if (!renv_bootstrap_load(project, libpath, version)) + renv_bootstrap_run(version, libpath) } renv_bootstrap_run <- function(version, libpath) { @@ -1012,11 +1055,6 @@ local({ } - - renv_bootstrap_in_rstudio <- function() { - commandArgs()[[1]] == "RStudio" - } - renv_json_read <- function(file = NULL, text = NULL) { jlerr <- NULL @@ -1024,7 +1062,7 @@ local({ # if jsonlite is loaded, use that instead if ("jsonlite" %in% loadedNamespaces()) { - json <- catch(renv_json_read_jsonlite(file, text)) + json <- tryCatch(renv_json_read_jsonlite(file, text), error = identity) if (!inherits(json, "error")) return(json) @@ -1033,7 +1071,7 @@ local({ } # otherwise, fall back to the default JSON reader - json <- catch(renv_json_read_default(file, text)) + json <- tryCatch(renv_json_read_default(file, text), error = identity) if (!inherits(json, "error")) return(json) @@ -1046,14 +1084,14 @@ local({ } renv_json_read_jsonlite <- function(file = NULL, text = NULL) { - text <- paste(text %||% read(file), collapse = "\n") + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") jsonlite::fromJSON(txt = text, simplifyVector = FALSE) } renv_json_read_default <- function(file = NULL, text = NULL) { # find strings in the JSON - text <- paste(text %||% read(file), collapse = "\n") + text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n") pattern <- '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]' locs <- gregexpr(pattern, text, perl = TRUE)[[1]] @@ -1101,14 +1139,14 @@ local({ map <- as.list(map) # remap strings in object - remapped <- renv_json_remap(json, map) + remapped <- renv_json_read_remap(json, map) # evaluate eval(remapped, envir = baseenv()) } - renv_json_remap <- function(json, map) { + renv_json_read_remap <- function(json, map) { # fix names if (!is.null(names(json))) { @@ -1135,7 +1173,7 @@ local({ # recurse if (is.recursive(json)) { for (i in seq_along(json)) { - json[i] <- list(renv_json_remap(json[[i]], map)) + json[i] <- list(renv_json_read_remap(json[[i]], map)) } } @@ -1155,26 +1193,8 @@ local({ # construct full libpath libpath <- file.path(root, prefix) - # attempt to load - if (renv_bootstrap_load(project, libpath, version)) - return(TRUE) - - if (renv_bootstrap_in_rstudio()) { - setHook("rstudio.sessionInit", function(...) { - renv_bootstrap_run(version, libpath) - - # Work around buglet in RStudio if hook uses readline - tryCatch( - { - tools <- as.environment("tools:rstudio") - tools$.rs.api.sendToConsole("", echo = FALSE, focus = FALSE) - }, - error = function(cnd) {} - ) - }) - } else { - renv_bootstrap_run(version, libpath) - } + # run bootstrap code + renv_bootstrap_exec(project, libpath, version) invisible() From cd20816877f6544d0c30e08ab3ae6742140816a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 5 Mar 2024 12:33:40 +0000 Subject: [PATCH 237/240] Update pkgdown.yaml workflow --- .github/workflows/pkgdown.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 8396a34..e05694f 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -34,7 +34,7 @@ jobs: extra-packages: any::pkgdown, local::. needs: website - # - uses: r-lib/actions/setup-pandoc@v2 + - uses: r-lib/actions/setup-pandoc@v2 - uses: r-lib/actions/setup-renv@v2 From 3c7957eb4afacc2f6f0b47c6298334b63fef40dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 5 Mar 2024 13:14:43 +0000 Subject: [PATCH 238/240] Update Dockerfile to use devtools for package installation --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1c8d604..1284b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ COPY inst/ ./inst COPY R/ ./R COPY tests/ ./inst/tests -RUN R CMD INSTALL --no-multiarch . +RUN R -e "devtools::install('.')" EXPOSE 3838 From 8c347474d637b0b2340361ad7143f9006f0a1331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 5 Mar 2024 13:17:53 +0000 Subject: [PATCH 239/240] disable code coverage for default_error_handler --- R/error-handling.R | 2 ++ 1 file changed, 2 insertions(+) diff --git a/R/error-handling.R b/R/error-handling.R index 9bb0e85..3b8cea6 100644 --- a/R/error-handling.R +++ b/R/error-handling.R @@ -91,6 +91,7 @@ setup_invalid_json_handler <- function(api) { }) } +# nocov start default_error_handler <- function(req, res, error) { print(error, simplify = "branch") @@ -110,6 +111,7 @@ default_error_handler <- function(req, res, error) { error = "500 - Internal server error" ) } +# nocov end with_err_handler <- function(expr) { withCallingHandlers( From e8b448c44b000cc17b98c6c3274a676b8ebc5d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Wa=C5=82ejko?= Date: Tue, 5 Mar 2024 13:33:44 +0000 Subject: [PATCH 240/240] Update version number and API version --- DESCRIPTION | 2 +- inst/plumber/unbiased_api/plumber.R | 2 +- vignettes/articles/references.bib | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index fcab531..572c9ee 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: unbiased Title: Diverse Randomization Algorithms for Clinical Trials -Version: 0.0.0.9003 +Version: 1.0.0 Authors@R: c( person("Kamil", "Sijko", , "kamil.sijko@ttsi.com.pl", role = c("aut", "cre"), comment = c(ORCID = "0000-0002-2203-1065")), diff --git a/inst/plumber/unbiased_api/plumber.R b/inst/plumber/unbiased_api/plumber.R index 7a9a5d7..4b1243b 100644 --- a/inst/plumber/unbiased_api/plumber.R +++ b/inst/plumber/unbiased_api/plumber.R @@ -10,7 +10,7 @@ #* url = "https://ttscience.github.io/unbiased/") #* @apiLicense list(name = "MIT", #* url = "https://github.com/ttscience/unbiased/LICENSE.md") -#* @apiVersion 0.0.0.9003 +#* @apiVersion 1.0.0 #* @apiTag initialize Endpoints that initialize study with chosen #* randomization method and parameters. #* @apiTag randomize Endpoints that randomize individual patients after the diff --git a/vignettes/articles/references.bib b/vignettes/articles/references.bib index cdbe561..2a1fe58 100644 --- a/vignettes/articles/references.bib +++ b/vignettes/articles/references.bib @@ -217,6 +217,6 @@ @Manual{unbiased title = {unbiased: Diverse Randomization Algorithms for Clinical Trials}, author = {Kamil Sijko and Kinga Sałata and Aleksandra Duda and Łukasz Wałejko}, year = {2024}, - note = {R package version 0.0.0.9003}, + note = {R package version 1.0.0}, url = {https://ttscience.github.io/unbiased/}, }

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%0A-OIT;>oMkVOCd%TrKWAk@z&?;*PQe@ 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