From aef12a6853aa64d6b0a0a4e03e208e2a48558dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:58:13 +0100 Subject: [PATCH 01/20] fix: allow non-standard datanames in filter data --- R/FilteredData.R | 3 --- R/FilteredDataset.R | 1 - R/utils.R | 25 ------------------------- man/check_simple_name.Rd | 21 --------------------- 4 files changed, 50 deletions(-) delete mode 100644 man/check_simple_name.Rd diff --git a/R/FilteredData.R b/R/FilteredData.R index 9b56ee937..c36227c80 100644 --- a/R/FilteredData.R +++ b/R/FilteredData.R @@ -307,9 +307,6 @@ FilteredData <- R6::R6Class( # nolint set_dataset = function(data, dataname) { checkmate::assert_string(dataname) logger::log_debug("FilteredData$set_dataset setting dataset, name: { dataname }") - # to include it nicely in the Show R Code; - # the UI also uses `datanames` in ids, so no whitespaces allowed - check_simple_name(dataname) parent_dataname <- teal.data::parent(private$join_keys, dataname) keys <- private$join_keys[dataname, dataname] diff --git a/R/FilteredDataset.R b/R/FilteredDataset.R index 998f5f4d8..830d3ed14 100644 --- a/R/FilteredDataset.R +++ b/R/FilteredDataset.R @@ -30,7 +30,6 @@ FilteredDataset <- R6::R6Class( # nolint #' @return Object of class `FilteredDataset`, invisibly. #' initialize = function(dataset, dataname, keys = character(0), label = attr(dataset, "label", exact = TRUE)) { - check_simple_name(dataname) logger::log_debug("Instantiating { class(self)[1] }, dataname: { dataname }") checkmate::assert_character(keys, any.missing = FALSE) checkmate::assert_character(label, null.ok = TRUE) diff --git a/R/utils.R b/R/utils.R index 725f5a8c9..862d7f729 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,28 +1,3 @@ -#' Test whether variable name can be used within `Show R Code` -#' -#' Variable names containing spaces are problematic and must be wrapped in backticks. -#' Also, they should not start with a number as `R` may silently make it valid by changing it. -#' Therefore, we only allow alphanumeric characters with underscores. -#' The first character of the `name` must be an alphabetic character and can be followed by alphanumeric characters. -#' -#' @md -#' -#' @param name (`character`) vector of names to check -#' @return Returns `NULL` or raises error. -#' @keywords internal -#' -check_simple_name <- function(name) { - checkmate::assert_character(name, min.len = 1, any.missing = FALSE) - if (!grepl("^[[:alpha:]][a-zA-Z0-9_]*$", name, perl = TRUE)) { - stop( - "name '", - name, - "' must only contain alphanumeric characters (with underscores)", - " and the first character must be an alphabetic character" - ) - } -} - #' Include `JS` files from `/inst/js/` package directory to application header #' #' `system.file` should not be used to access files in other packages, it does diff --git a/man/check_simple_name.Rd b/man/check_simple_name.Rd deleted file mode 100644 index b8472b5fb..000000000 --- a/man/check_simple_name.Rd +++ /dev/null @@ -1,21 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R -\name{check_simple_name} -\alias{check_simple_name} -\title{Test whether variable name can be used within \verb{Show R Code}} -\usage{ -check_simple_name(name) -} -\arguments{ -\item{name}{(\code{character}) vector of names to check} -} -\value{ -Returns \code{NULL} or raises error. -} -\description{ -Variable names containing spaces are problematic and must be wrapped in backticks. -Also, they should not start with a number as \code{R} may silently make it valid by changing it. -Therefore, we only allow alphanumeric characters with underscores. -The first character of the \code{name} must be an alphabetic character and can be followed by alphanumeric characters. -} -\keyword{internal} From 43586a121ec8f6d510bf89ec3ae6563c862821be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Tue, 22 Oct 2024 11:51:45 +0100 Subject: [PATCH 02/20] chore: delete tests for function that was removed --- tests/testthat/test-utils.R | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 91cb3a6aa..314c90d2e 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -1,23 +1,3 @@ -# check_simple_name ---- -test_that("check_simple_name behaves as expected", { - testthat::expect_silent(check_simple_name("aas2df")) - testthat::expect_silent(check_simple_name("ADSL")) - testthat::expect_silent(check_simple_name("ADSLmodified")) - testthat::expect_silent(check_simple_name("a1")) - testthat::expect_silent(check_simple_name("ADSL_modified")) - testthat::expect_silent(check_simple_name("ADSL_filtered")) - testthat::expect_silent(check_simple_name("FILTERED_ADSL")) - testthat::expect_silent(check_simple_name("FILTERED")) - testthat::expect_silent(check_simple_name("ADSLFILTERED")) - testthat::expect_silent(check_simple_name("a_1_2_b_")) - - testthat::expect_error(check_simple_name("1a"), "name '.+' must only contain alphanumeric characters") - testthat::expect_error(check_simple_name("ADSL.modified"), "name '.+' must only contain alphanumeric characters") - testthat::expect_error(check_simple_name("a1..."), "name '.+' must only contain alphanumeric characters") - testthat::expect_error(check_simple_name("a a"), "name '.+' must only contain alphanumeric characters") - testthat::expect_error(check_simple_name("_A_b"), "name '.+' must only contain alphanumeric characters") -}) - # make_c_call ---- testthat::test_that("make_c_call", { testthat::expect_identical(make_c_call(1:3), quote(c(1L, 2L, 3L))) From 47257842fe68978063527634d5b58f4cfea556b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:07:35 +0100 Subject: [PATCH 03/20] fix: restore simple check on datanames, without regex --- R/FilteredData.R | 1 + R/FilteredDataset.R | 1 + tests/testthat/test-DataframeFilteredDataset.R | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/R/FilteredData.R b/R/FilteredData.R index c36227c80..226f8a0db 100644 --- a/R/FilteredData.R +++ b/R/FilteredData.R @@ -306,6 +306,7 @@ FilteredData <- R6::R6Class( # nolint #' set_dataset = function(data, dataname) { checkmate::assert_string(dataname) + checkmate::assert_character(dataname, min.len = 1, any.missing = FALSE) logger::log_debug("FilteredData$set_dataset setting dataset, name: { dataname }") parent_dataname <- teal.data::parent(private$join_keys, dataname) diff --git a/R/FilteredDataset.R b/R/FilteredDataset.R index 830d3ed14..b6637c69a 100644 --- a/R/FilteredDataset.R +++ b/R/FilteredDataset.R @@ -30,6 +30,7 @@ FilteredDataset <- R6::R6Class( # nolint #' @return Object of class `FilteredDataset`, invisibly. #' initialize = function(dataset, dataname, keys = character(0), label = attr(dataset, "label", exact = TRUE)) { + checkmate::assert_character(dataname, min.len = 1, any.missing = FALSE) logger::log_debug("Instantiating { class(self)[1] }, dataname: { dataname }") checkmate::assert_character(keys, any.missing = FALSE) checkmate::assert_character(label, null.ok = TRUE) diff --git a/tests/testthat/test-DataframeFilteredDataset.R b/tests/testthat/test-DataframeFilteredDataset.R index 7ab9f8b65..2b8d327f0 100644 --- a/tests/testthat/test-DataframeFilteredDataset.R +++ b/tests/testthat/test-DataframeFilteredDataset.R @@ -4,7 +4,7 @@ testthat::test_that("constructor accepts data.frame object with a dataname", { testthat::expect_error(DataframeFilteredDataset$new(dataset = head(iris)), "argument .+ missing, with no default") testthat::expect_error(DataframeFilteredDataset$new(dataname = "iris"), "argument .+ missing, with no default") testthat::expect_error(DataframeFilteredDataset$new(dataset = as.list(iris)), "Assertion on 'dataset' failed") - testthat::expect_error(DataframeFilteredDataset$new(dataset = iris, dataname = iris), "Assertion on 'name' failed") + testthat::expect_error(DataframeFilteredDataset$new(dataset = iris, dataname = iris), "Assertion on 'dataname' failed") }) testthat::test_that("filter_states list is initialized with single `FilterStates` element named filter", { From 31a732c85fbe521958ee965dcc717449f927e20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:41:29 +0100 Subject: [PATCH 04/20] fix: linter error with long line --- tests/testthat/test-DataframeFilteredDataset.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-DataframeFilteredDataset.R b/tests/testthat/test-DataframeFilteredDataset.R index 2b8d327f0..d90987aa9 100644 --- a/tests/testthat/test-DataframeFilteredDataset.R +++ b/tests/testthat/test-DataframeFilteredDataset.R @@ -4,7 +4,9 @@ testthat::test_that("constructor accepts data.frame object with a dataname", { testthat::expect_error(DataframeFilteredDataset$new(dataset = head(iris)), "argument .+ missing, with no default") testthat::expect_error(DataframeFilteredDataset$new(dataname = "iris"), "argument .+ missing, with no default") testthat::expect_error(DataframeFilteredDataset$new(dataset = as.list(iris)), "Assertion on 'dataset' failed") - testthat::expect_error(DataframeFilteredDataset$new(dataset = iris, dataname = iris), "Assertion on 'dataname' failed") + testthat::expect_error( + DataframeFilteredDataset$new(dataset = iris, dataname = iris), "Assertion on 'dataname' failed" + ) }) testthat::test_that("filter_states list is initialized with single `FilterStates` element named filter", { From 23c2bcfda03a44bd072f3cc144e11519c1a4d94b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:25:20 +0100 Subject: [PATCH 05/20] proposal: fixes problem in filter panel with special characters namespace --- R/FilterStates.R | 7 ++++++- R/utils.R | 46 ++++++++++++++++++++++++++++++++++++++++++++++ man/js_encode.Rd | 25 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 man/js_encode.Rd diff --git a/R/FilterStates.R b/R/FilterStates.R index 4fceca00d..6e7173668 100644 --- a/R/FilterStates.R +++ b/R/FilterStates.R @@ -165,7 +165,12 @@ FilterStates <- R6::R6Class( # nolint ) if (length(filter_items) > 0L) { filter_function <- private$fun - data_name <- str2lang(private$dataname_prefixed) + data_name <- tryCatch( + { + str2lang(private$dataname_prefixed) + }, + error = function(e) str2lang(paste0("`", private$dataname_prefixed, "`")) + ) substitute( env = list( lhs = data_name, diff --git a/R/utils.R b/R/utils.R index 862d7f729..5a213f15b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -56,3 +56,49 @@ make_c_call <- function(choices) { if (length(private$session_bindings) > 0) lapply(private$session_bindings, function(x) x$destroy()) invisible(NULL) } + + + +#' Encodes ids to be used in JavaScript and Shiny +#' +#' Replaces non-ASCII characters into a format that can be used in HTML, +#' JavaScript and Shiny. +#' +#' Typically, the `%` symbol is used in this format, but we it is not allowed +#' in this context.. +#' We replace `%` with `__html_XX__` where `XX` is the HTML representation of +#' the character. +#' +#' @param id (`character(1)`) The id string. +#' +#' @return Sanitized string that removes special characters and spaces. +#' +#' @keywords internal +js_encode <- function(id) { + gsub("%([0-9]{2})", "__html_\\1__", utils::URLencode(as.character(id))) +} + +#' `NS` wrapper to sanitize ids for shiny +#' +#' Special characters and spaces are not allowed in shiny ids (in JS) +#' +#' @noRd +NS <- function(namespace, id = NULL) { # nolint: object_name. + if (!missing(id)) { + return(shiny::NS(namespace, js_encode(id))) + } + + function(id) { + shiny::NS(namespace, js_encode(id)) + } +} + +#' `moduleServer` wrapper to sanitize ids for shiny +#' +#' Special characters and spaces are not allowed in shiny ids (in JS) +#' +#' @noRd +moduleServer <- function(id, module, session = getDefaultReactiveDomain()) { # nolint: object_name. + id <- js_encode(id) + shiny::moduleServer(id, module, session) +} diff --git a/man/js_encode.Rd b/man/js_encode.Rd new file mode 100644 index 000000000..8c02cb0ef --- /dev/null +++ b/man/js_encode.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{js_encode} +\alias{js_encode} +\title{Encodes ids to be used in JavaScript and Shiny} +\usage{ +js_encode(id) +} +\arguments{ +\item{id}{(\code{character(1)}) The id string.} +} +\value{ +Sanitized string that removes special characters and spaces. +} +\description{ +Replaces non-ASCII characters into a format that can be used in HTML, +JavaScript and Shiny. +} +\details{ +Typically, the \verb{\%} symbol is used in this format, but we it is not allowed +in this context.. +We replace \verb{\%} with \verb{__html_XX__} where \code{XX} is the HTML representation of +the character. +} +\keyword{internal} From 922a745cf0db85774da1cc7fce8efcc6acfcca27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:07:24 +0200 Subject: [PATCH 06/20] Update R/FilteredDataset.R MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dawid Kałędkowski Signed-off-by: André Veríssimo <211358+averissimo@users.noreply.github.com> --- R/FilteredDataset.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/FilteredDataset.R b/R/FilteredDataset.R index b6637c69a..2ffefe420 100644 --- a/R/FilteredDataset.R +++ b/R/FilteredDataset.R @@ -30,7 +30,7 @@ FilteredDataset <- R6::R6Class( # nolint #' @return Object of class `FilteredDataset`, invisibly. #' initialize = function(dataset, dataname, keys = character(0), label = attr(dataset, "label", exact = TRUE)) { - checkmate::assert_character(dataname, min.len = 1, any.missing = FALSE) + checkmate::assert_string(dataname) logger::log_debug("Instantiating { class(self)[1] }, dataname: { dataname }") checkmate::assert_character(keys, any.missing = FALSE) checkmate::assert_character(label, null.ok = TRUE) From ab587a151cefa8ef9893b347d55bddc9c5897a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:06:22 +0100 Subject: [PATCH 07/20] fix: correct assertion --- R/FilteredData.R | 1 - 1 file changed, 1 deletion(-) diff --git a/R/FilteredData.R b/R/FilteredData.R index 226f8a0db..c36227c80 100644 --- a/R/FilteredData.R +++ b/R/FilteredData.R @@ -306,7 +306,6 @@ FilteredData <- R6::R6Class( # nolint #' set_dataset = function(data, dataname) { checkmate::assert_string(dataname) - checkmate::assert_character(dataname, min.len = 1, any.missing = FALSE) logger::log_debug("FilteredData$set_dataset setting dataset, name: { dataname }") parent_dataname <- teal.data::parent(private$join_keys, dataname) From 6da0941c5d80d0e1a5608c9f4806c3510b4c24c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:30:02 +0100 Subject: [PATCH 08/20] fix: bug with exotic names and SE --- R/FilterStates.R | 6 +++++- R/FilterStatesSE.R | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/R/FilterStates.R b/R/FilterStates.R index 6e7173668..5ac79756e 100644 --- a/R/FilterStates.R +++ b/R/FilterStates.R @@ -57,7 +57,11 @@ FilterStates <- R6::R6Class( # nolint private$dataname <- dataname private$datalabel <- datalabel - private$dataname_prefixed <- dataname + private$dataname_prefixed <- if (identical(dataname, make.names(dataname))) { + dataname + } else { + sprintf("`%s`", dataname) + } private$data <- data private$data_reactive <- data_reactive private$state_list <- reactiveVal() diff --git a/R/FilterStatesSE.R b/R/FilterStatesSE.R index 686bb44c4..06e14eac1 100644 --- a/R/FilterStatesSE.R +++ b/R/FilterStatesSE.R @@ -39,7 +39,9 @@ SEFilterStates <- R6::R6Class( # nolint checkmate::assert_class(data, "SummarizedExperiment") super$initialize(data, data_reactive, dataname, datalabel) if (!is.null(datalabel)) { - private$dataname_prefixed <- sprintf("%s[['%s']]", dataname, datalabel) + private$dataname_prefixed <- sprintf( + "%s[['%s']]", private$dataname_prefixed, datalabel + ) } }, From cebf69d4623a86ffa6090f36f416321fecf77729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:30:59 +0100 Subject: [PATCH 09/20] fix: filter Matrix elements todo: teal.slice export does not contains experiment --- R/FilterStatesMatrix.R | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/R/FilterStatesMatrix.R b/R/FilterStatesMatrix.R index 776c1bbc9..d5ed4d136 100644 --- a/R/FilterStatesMatrix.R +++ b/R/FilterStatesMatrix.R @@ -35,6 +35,11 @@ MatrixFilterStates <- R6::R6Class( # nolint checkmate::assert_matrix(data) super$initialize(data, data_reactive, dataname, datalabel) private$set_filterable_varnames(include_varnames = colnames(private$data)) + if (!is.null(datalabel)) { + private$dataname_prefixed <- sprintf( + "%s[['%s']]", private$dataname_prefixed, datalabel + ) + } } ), private = list( From efeb6c3a0b6a68442c05959d422aed5be63d7397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:00:10 +0100 Subject: [PATCH 10/20] fix: use u to flag next code as utf8 --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index 5a213f15b..228aa736c 100644 --- a/R/utils.R +++ b/R/utils.R @@ -75,7 +75,7 @@ make_c_call <- function(choices) { #' #' @keywords internal js_encode <- function(id) { - gsub("%([0-9]{2})", "__html_\\1__", utils::URLencode(as.character(id))) + gsub("%([0-9]{2})", "__u\\1__", utils::URLencode(as.character(id))) } #' `NS` wrapper to sanitize ids for shiny From 25a5d96a8f722b55ac24172904598709ef318ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:20:56 +0100 Subject: [PATCH 11/20] fix: simplify the sanitize_id function --- DESCRIPTION | 1 + R/utils.R | 24 ++++++++++++++++-------- man/{js_encode.Rd => sanitize_id.Rd} | 15 ++++++--------- 3 files changed, 23 insertions(+), 17 deletions(-) rename man/{js_encode.Rd => sanitize_id.Rd} (60%) diff --git a/DESCRIPTION b/DESCRIPTION index 81a89c49e..72ad39930 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -45,6 +45,7 @@ Imports: methods, plotly (>= 4.9.2.2), R6 (>= 2.2.0), + rlang (>= 1.0.0), shiny (>= 1.6.0), shinycssloaders (>= 1.0.0), shinyjs, diff --git a/R/utils.R b/R/utils.R index 228aa736c..fb8e3b05d 100644 --- a/R/utils.R +++ b/R/utils.R @@ -61,21 +61,29 @@ make_c_call <- function(choices) { #' Encodes ids to be used in JavaScript and Shiny #' +#' @description #' Replaces non-ASCII characters into a format that can be used in HTML, #' JavaScript and Shiny. #' -#' Typically, the `%` symbol is used in this format, but we it is not allowed -#' in this context.. -#' We replace `%` with `__html_XX__` where `XX` is the HTML representation of -#' the character. +#' When the id has a character that is not allowed, it is replaced with `"_"` +#' and a hash of the original id is added to the beginning of the id. #' #' @param id (`character(1)`) The id string. #' #' @return Sanitized string that removes special characters and spaces. #' #' @keywords internal -js_encode <- function(id) { - gsub("%([0-9]{2})", "__u\\1__", utils::URLencode(as.character(id))) +sanitize_id <- function(id) { + id_converted <- make.names(id) + if (identical(make.names(id), id)) { + return(id) + } + id_converted <- gsub("\\.", "_", id_converted) + if (!grepl("^X", id)) { + id_converted <- gsub("^X", "", id_converted) + } + + paste0(substr(rlang::hash(id), 1, 4), "_", id_converted) } #' `NS` wrapper to sanitize ids for shiny @@ -85,11 +93,11 @@ js_encode <- function(id) { #' @noRd NS <- function(namespace, id = NULL) { # nolint: object_name. if (!missing(id)) { - return(shiny::NS(namespace, js_encode(id))) + return(shiny::NS(namespace, sanitize_id(id))) } function(id) { - shiny::NS(namespace, js_encode(id)) + shiny::NS(namespace, sanitize_id(id)) } } diff --git a/man/js_encode.Rd b/man/sanitize_id.Rd similarity index 60% rename from man/js_encode.Rd rename to man/sanitize_id.Rd index 8c02cb0ef..ec69c3a34 100644 --- a/man/js_encode.Rd +++ b/man/sanitize_id.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/utils.R -\name{js_encode} -\alias{js_encode} +\name{sanitize_id} +\alias{sanitize_id} \title{Encodes ids to be used in JavaScript and Shiny} \usage{ -js_encode(id) +sanitize_id(id) } \arguments{ \item{id}{(\code{character(1)}) The id string.} @@ -15,11 +15,8 @@ Sanitized string that removes special characters and spaces. \description{ Replaces non-ASCII characters into a format that can be used in HTML, JavaScript and Shiny. -} -\details{ -Typically, the \verb{\%} symbol is used in this format, but we it is not allowed -in this context.. -We replace \verb{\%} with \verb{__html_XX__} where \code{XX} is the HTML representation of -the character. + +When the id has a character that is not allowed, it is replaced with \code{"_"} +and a hash of the original id is added to the beginning of the id. } \keyword{internal} From b3f6dad37ea4f86cfcadd48df5e08e1c8164ab98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:08:49 +0100 Subject: [PATCH 12/20] fix: adds tests and better protects against problematic names --- R/utils.R | 4 ++-- tests/testthat/test-utils.R | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/R/utils.R b/R/utils.R index fb8e3b05d..687451070 100644 --- a/R/utils.R +++ b/R/utils.R @@ -74,6 +74,7 @@ make_c_call <- function(choices) { #' #' @keywords internal sanitize_id <- function(id) { + id <- as.character(as.name(id)) id_converted <- make.names(id) if (identical(make.names(id), id)) { return(id) @@ -82,8 +83,7 @@ sanitize_id <- function(id) { if (!grepl("^X", id)) { id_converted <- gsub("^X", "", id_converted) } - - paste0(substr(rlang::hash(id), 1, 4), "_", id_converted) + paste0(substr(rlang::hash(as.character(id)), 1, 4), "_", id_converted) } #' `NS` wrapper to sanitize ids for shiny diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 314c90d2e..d66187f13 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -3,3 +3,32 @@ testthat::test_that("make_c_call", { testthat::expect_identical(make_c_call(1:3), quote(c(1L, 2L, 3L))) testthat::expect_identical(make_c_call(1), 1) }) + +testthat::describe("sanitize_id", { + testthat::it("should replace non-ASCII characters in middle of id with `_`", { + testthat::expect_identical(NS("app", "a$b"), paste0("app-", substr(rlang::hash("a$b"), 1, 4), "_a_b")) + }) + + testthat::it("should replace non-ASCII characters in the start/end of id with `_`", { + testthat::expect_identical(NS("app", "%a.b%c$"), paste0("app-", substr(rlang::hash("%a.b%c$"), 1, 4), "__a_b_c_")) + }) + + testthat::it("should replace all quotes characters with `_`", { + testthat::expect_identical(NS("app", " a.b.c\"d`e'j"), paste0("app-", substr(rlang::hash(" a.b.c\"d`e'j"), 1, 4), "__a_b_c_d_e_j")) + }) + + testthat::it("should replace UTF-8 special characters with `_`", { + testthat::expect_identical( + NS("app", "a\U1F643"), + paste0("app-", substr(rlang::hash("a\U1F643"), 1, 4), "_a_") + ) + }) + + testthat::it("should replace all escape characters from JQuery selectors", { + forbidden <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~]" + testthat::expect_identical( + NS("app", forbidden), + paste0("app-", substr(rlang::hash(forbidden), 1, 4), paste(rep("_", nchar(forbidden) + 1), collapse = "")) + ) + }) +}) From 9fdf441ab3f314f98090a0bf940a4e318cd9df7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:18:21 +0100 Subject: [PATCH 13/20] fix: lintr and remove extra line --- R/utils.R | 1 - tests/testthat/test-utils.R | 29 +++++++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/R/utils.R b/R/utils.R index 687451070..49f54a696 100644 --- a/R/utils.R +++ b/R/utils.R @@ -74,7 +74,6 @@ make_c_call <- function(choices) { #' #' @keywords internal sanitize_id <- function(id) { - id <- as.character(as.name(id)) id_converted <- make.names(id) if (identical(make.names(id), id)) { return(id) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index d66187f13..619187b53 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -6,21 +6,34 @@ testthat::test_that("make_c_call", { testthat::describe("sanitize_id", { testthat::it("should replace non-ASCII characters in middle of id with `_`", { - testthat::expect_identical(NS("app", "a$b"), paste0("app-", substr(rlang::hash("a$b"), 1, 4), "_a_b")) + id <- "a$b" + testthat::expect_identical( + NS("app", id), + paste0("app-", substr(rlang::hash(id), 1, 4), "_a_b") + ) }) testthat::it("should replace non-ASCII characters in the start/end of id with `_`", { - testthat::expect_identical(NS("app", "%a.b%c$"), paste0("app-", substr(rlang::hash("%a.b%c$"), 1, 4), "__a_b_c_")) + id <- "%a.b%c$" + testthat::expect_identical( + NS("app", id), + paste0("app-", substr(rlang::hash(id), 1, 4), "__a_b_c_") + ) }) testthat::it("should replace all quotes characters with `_`", { - testthat::expect_identical(NS("app", " a.b.c\"d`e'j"), paste0("app-", substr(rlang::hash(" a.b.c\"d`e'j"), 1, 4), "__a_b_c_d_e_j")) + id <- " a.b.c\"d`e'j" + testthat::expect_identical( + NS("app", id), + paste0("app-", substr(rlang::hash(id), 1, 4), "__a_b_c_d_e_j") + ) }) testthat::it("should replace UTF-8 special characters with `_`", { + id <- "a\U1F643" testthat::expect_identical( - NS("app", "a\U1F643"), - paste0("app-", substr(rlang::hash("a\U1F643"), 1, 4), "_a_") + NS("app", id), + paste0("app-", substr(rlang::hash(id), 1, 4), "_a_") ) }) @@ -28,7 +41,11 @@ testthat::describe("sanitize_id", { forbidden <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~]" testthat::expect_identical( NS("app", forbidden), - paste0("app-", substr(rlang::hash(forbidden), 1, 4), paste(rep("_", nchar(forbidden) + 1), collapse = "")) + paste0( + "app-", + substr(rlang::hash(forbidden), 1, 4), + paste(rep("_", nchar(forbidden) + 1), collapse = "") + ) ) }) }) From 7be5992a2bf1a9cb797aa4be1ee5759ba6179422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:22:05 +0100 Subject: [PATCH 14/20] chore: test NS when it returns a functiont push --- tests/testthat/test-utils.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 619187b53..3a06c28e3 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -4,11 +4,13 @@ testthat::test_that("make_c_call", { testthat::expect_identical(make_c_call(1), 1) }) +# sanitize_id ---- testthat::describe("sanitize_id", { testthat::it("should replace non-ASCII characters in middle of id with `_`", { id <- "a$b" + ns <- NS("app") testthat::expect_identical( - NS("app", id), + ns(id), paste0("app-", substr(rlang::hash(id), 1, 4), "_a_b") ) }) From b24da53e48a5babf38743e1087c34e552894e47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:25:21 +0100 Subject: [PATCH 15/20] fix: missing replacement of old function name --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index 49f54a696..4261c3acb 100644 --- a/R/utils.R +++ b/R/utils.R @@ -106,6 +106,6 @@ NS <- function(namespace, id = NULL) { # nolint: object_name. #' #' @noRd moduleServer <- function(id, module, session = getDefaultReactiveDomain()) { # nolint: object_name. - id <- js_encode(id) + id <- sanitize_id(id) shiny::moduleServer(id, module, session) } From 28d2246c598c072bb20d915516fc7aa2ff3657c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:36:52 +0100 Subject: [PATCH 16/20] add 'h' character before hash in case id is used in top-level --- R/utils.R | 2 +- tests/testthat/test-utils.R | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/R/utils.R b/R/utils.R index 4261c3acb..d53128d9f 100644 --- a/R/utils.R +++ b/R/utils.R @@ -82,7 +82,7 @@ sanitize_id <- function(id) { if (!grepl("^X", id)) { id_converted <- gsub("^X", "", id_converted) } - paste0(substr(rlang::hash(as.character(id)), 1, 4), "_", id_converted) + paste0("h", substr(rlang::hash(as.character(id)), 1, 4), "_", id_converted) } #' `NS` wrapper to sanitize ids for shiny diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 3a06c28e3..8c67d60b9 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -11,15 +11,21 @@ testthat::describe("sanitize_id", { ns <- NS("app") testthat::expect_identical( ns(id), - paste0("app-", substr(rlang::hash(id), 1, 4), "_a_b") + paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_b") ) }) testthat::it("should replace non-ASCII characters in the start/end of id with `_`", { - id <- "%a.b%c$" + id <- "%a bad symbol$" + id2 <- "a&b#" + id_from_module <- shiny::withReactiveDomain( + MockShinySession$new(), + moduleServer(id, function(input, output, session) session$ns("a_good_name")) + ) + testthat::expect_identical( - NS("app", id), - paste0("app-", substr(rlang::hash(id), 1, 4), "__a_b_c_") + id_from_module, + paste0("h", substr(rlang::hash(id), 1, 4), "__a_bad_symbol_-a_good_name") ) }) @@ -27,7 +33,7 @@ testthat::describe("sanitize_id", { id <- " a.b.c\"d`e'j" testthat::expect_identical( NS("app", id), - paste0("app-", substr(rlang::hash(id), 1, 4), "__a_b_c_d_e_j") + paste0("app-h", substr(rlang::hash(id), 1, 4), "__a_b_c_d_e_j") ) }) @@ -35,7 +41,7 @@ testthat::describe("sanitize_id", { id <- "a\U1F643" testthat::expect_identical( NS("app", id), - paste0("app-", substr(rlang::hash(id), 1, 4), "_a_") + paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_") ) }) @@ -44,7 +50,7 @@ testthat::describe("sanitize_id", { testthat::expect_identical( NS("app", forbidden), paste0( - "app-", + "app-h", substr(rlang::hash(forbidden), 1, 4), paste(rep("_", nchar(forbidden) + 1), collapse = "") ) From 67f57c6be8e54644f9ea473ae1e3601e0e2db24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:39:01 +0100 Subject: [PATCH 17/20] fix: replace dots when id is otherwise valid --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index d53128d9f..ad2209818 100644 --- a/R/utils.R +++ b/R/utils.R @@ -76,7 +76,7 @@ make_c_call <- function(choices) { sanitize_id <- function(id) { id_converted <- make.names(id) if (identical(make.names(id), id)) { - return(id) + return(gsub("\\.", "_", id)) } id_converted <- gsub("\\.", "_", id_converted) if (!grepl("^X", id)) { From 91f257694e23a6423a6ea555ed3e3b91f4fde334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:41:20 +0100 Subject: [PATCH 18/20] fix: allow for vector inputs and integers --- R/utils.R | 25 ++++++++++++++--------- tests/testthat/test-utils.R | 40 +++++++++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/R/utils.R b/R/utils.R index ad2209818..bed69aa6e 100644 --- a/R/utils.R +++ b/R/utils.R @@ -74,15 +74,22 @@ make_c_call <- function(choices) { #' #' @keywords internal sanitize_id <- function(id) { - id_converted <- make.names(id) - if (identical(make.names(id), id)) { - return(gsub("\\.", "_", id)) - } - id_converted <- gsub("\\.", "_", id_converted) - if (!grepl("^X", id)) { - id_converted <- gsub("^X", "", id_converted) - } - paste0("h", substr(rlang::hash(as.character(id)), 1, 4), "_", id_converted) + escape_characters <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~" + pattern <- paste( + sep = "", collapse = "|", "\\", strsplit(escape_characters, "")[[1]] + ) + pattern <- gsub("\\<", "<", pattern, fixed = TRUE) + pattern <- gsub("\\>", ">", pattern, fixed = TRUE) + + id_new <- gsub(pattern, "_", id) + hashes <- vapply( + id[id != id_new], + rlang::hash, character(1), + USE.NAMES = FALSE + ) + + id[id != id_new] <- paste0("h", substr(hashes, 1, 4), "_", id_new[id != id_new]) + id } #' `NS` wrapper to sanitize ids for shiny diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 8c67d60b9..63b83d65b 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -6,6 +6,38 @@ testthat::test_that("make_c_call", { # sanitize_id ---- testthat::describe("sanitize_id", { + testthat::it("should replace dots with `_` when id is otherwise valid", { + id <- "a.b" + ns <- NS("app") + testthat::expect_identical( + ns(id), + paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_b") + ) + }) + + testthat::it("should take vector input", { + id <- c("a.b", "a", "b", " c") + ns <- NS("app") + testthat::expect_identical( + ns(id), + c( + paste0("app-h", substr(rlang::hash(id[1]), 1, 4), "_a_b"), + "app-a", + "app-b", + paste0("app-h", substr(rlang::hash(id[4]), 1, 4), "__c") + ) + ) + }) + + testthat::it("should allow for integer input", { + id <- c(1L, 2L, 3L) + ns <- NS("app") + testthat::expect_identical( + ns(id), + c("app-1", "app-2", "app-3") + ) + }) + testthat::it("should replace non-ASCII characters in middle of id with `_`", { id <- "a$b" ns <- NS("app") @@ -37,14 +69,6 @@ testthat::describe("sanitize_id", { ) }) - testthat::it("should replace UTF-8 special characters with `_`", { - id <- "a\U1F643" - testthat::expect_identical( - NS("app", id), - paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_") - ) - }) - testthat::it("should replace all escape characters from JQuery selectors", { forbidden <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~]" testthat::expect_identical( From 76ef423f8c6a68944dc8585c118b812c69c7ded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Thu, 24 Oct 2024 18:30:11 +0100 Subject: [PATCH 19/20] fix: improve on pattern by using ] as first element --- R/utils.R | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/R/utils.R b/R/utils.R index bed69aa6e..9268b448a 100644 --- a/R/utils.R +++ b/R/utils.R @@ -74,14 +74,10 @@ make_c_call <- function(choices) { #' #' @keywords internal sanitize_id <- function(id) { - escape_characters <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~" - pattern <- paste( - sep = "", collapse = "|", "\\", strsplit(escape_characters, "")[[1]] - ) - pattern <- gsub("\\<", "<", pattern, fixed = TRUE) - pattern <- gsub("\\>", ">", pattern, fixed = TRUE) + # Left square bracket needs to be first in the pattern to avoid errors with pattern + pattern_escape <- "[] !\"#$%&'()*+,./:;<=>?@[\\^`{|}~]" - id_new <- gsub(pattern, "_", id) + id_new <- gsub(pattern_escape, "_", id) hashes <- vapply( id[id != id_new], rlang::hash, character(1), From 5a6c823e27938e4ddbf2cc98a402e52986893030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ver=C3=ADssimo?= <211358+averissimo@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:32:58 +0100 Subject: [PATCH 20/20] fix: R CMD check and small improvements --- R/utils.R | 13 +++++-------- man/sanitize_id.Rd | 3 ++- tests/testthat/test-utils.R | 27 ++++++++++++++++++++------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/R/utils.R b/R/utils.R index 9268b448a..caceb09c5 100644 --- a/R/utils.R +++ b/R/utils.R @@ -66,7 +66,9 @@ make_c_call <- function(choices) { #' JavaScript and Shiny. #' #' When the id has a character that is not allowed, it is replaced with `"_"` -#' and a hash of the original id is added to the beginning of the id. +#' and a 4 character hash of the original id is added to the start of the +#' resulting id. +#' #' #' @param id (`character(1)`) The id string. #' @@ -74,15 +76,10 @@ make_c_call <- function(choices) { #' #' @keywords internal sanitize_id <- function(id) { - # Left square bracket needs to be first in the pattern to avoid errors with pattern - pattern_escape <- "[] !\"#$%&'()*+,./:;<=>?@[\\^`{|}~]" + pattern_escape <- "[^0-9A-Za-z_]" id_new <- gsub(pattern_escape, "_", id) - hashes <- vapply( - id[id != id_new], - rlang::hash, character(1), - USE.NAMES = FALSE - ) + hashes <- vapply(id[id != id_new], rlang::hash, character(1), USE.NAMES = FALSE) id[id != id_new] <- paste0("h", substr(hashes, 1, 4), "_", id_new[id != id_new]) id diff --git a/man/sanitize_id.Rd b/man/sanitize_id.Rd index ec69c3a34..7a67ebd69 100644 --- a/man/sanitize_id.Rd +++ b/man/sanitize_id.Rd @@ -17,6 +17,7 @@ Replaces non-ASCII characters into a format that can be used in HTML, JavaScript and Shiny. When the id has a character that is not allowed, it is replaced with \code{"_"} -and a hash of the original id is added to the beginning of the id. +and a 4 character hash of the original id is added to the start of the +resulting id. } \keyword{internal} diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 63b83d65b..6d9d542fd 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -8,7 +8,7 @@ testthat::test_that("make_c_call", { testthat::describe("sanitize_id", { testthat::it("should replace dots with `_` when id is otherwise valid", { id <- "a.b" - ns <- NS("app") + ns <- teal.slice:::NS("app") testthat::expect_identical( ns(id), paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_b") @@ -17,7 +17,7 @@ testthat::describe("sanitize_id", { testthat::it("should take vector input", { id <- c("a.b", "a", "b", " c") - ns <- NS("app") + ns <- teal.slice:::NS("app") testthat::expect_identical( ns(id), c( @@ -31,7 +31,7 @@ testthat::describe("sanitize_id", { testthat::it("should allow for integer input", { id <- c(1L, 2L, 3L) - ns <- NS("app") + ns <- teal.slice:::NS("app") testthat::expect_identical( ns(id), c("app-1", "app-2", "app-3") @@ -40,19 +40,20 @@ testthat::describe("sanitize_id", { testthat::it("should replace non-ASCII characters in middle of id with `_`", { id <- "a$b" - ns <- NS("app") + ns <- teal.slice:::NS("app") testthat::expect_identical( ns(id), paste0("app-h", substr(rlang::hash(id), 1, 4), "_a_b") ) }) + # Test using moduleServer to access the sanitized id testthat::it("should replace non-ASCII characters in the start/end of id with `_`", { id <- "%a bad symbol$" id2 <- "a&b#" id_from_module <- shiny::withReactiveDomain( MockShinySession$new(), - moduleServer(id, function(input, output, session) session$ns("a_good_name")) + teal.slice:::moduleServer(id, function(input, output, session) session$ns("a_good_name")) ) testthat::expect_identical( @@ -64,7 +65,7 @@ testthat::describe("sanitize_id", { testthat::it("should replace all quotes characters with `_`", { id <- " a.b.c\"d`e'j" testthat::expect_identical( - NS("app", id), + teal.slice:::NS("app", id), paste0("app-h", substr(rlang::hash(id), 1, 4), "__a_b_c_d_e_j") ) }) @@ -72,7 +73,7 @@ testthat::describe("sanitize_id", { testthat::it("should replace all escape characters from JQuery selectors", { forbidden <- " !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~]" testthat::expect_identical( - NS("app", forbidden), + teal.slice:::NS("app", forbidden), paste0( "app-h", substr(rlang::hash(forbidden), 1, 4), @@ -80,4 +81,16 @@ testthat::describe("sanitize_id", { ) ) }) + + testthat::it("should replace UTF characters outside the allowed range", { + id <- "\U41\U05E\U30\U5F\U7A\U1F4AA" # "A:circumflex_accent:0_z:flexed_biceps: + testthat::expect_identical( + teal.slice:::NS("app", id), + paste0( + "app-h", + substr(rlang::hash(id), 1, 4), + "_A_0_z_" + ) + ) + }) })