Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Rlang nested quotation fails for tibble but works for data.frame

I am trying to evaluate some user-provided arguments in a specific data environment using the rlang quasi-quotation approach. In addition, I want to wrap the output in a data.frame / tibble. However, when I use tibble the code fails. Here is a minimal reproducible example:

wrap_in_df <- function(...){
  dots <- rlang::enquos(...)
  eval_in_mtcars(data.frame(!!! dots))
}
wrap_in_tibble <- function(...){
  dots <- rlang::enquos(...)
  eval_in_mtcars(tibble::tibble(!!! dots))
}
eval_in_mtcars <- function(expr){
  quo <- rlang::enquo(expr)
  rlang::eval_tidy(quo, data = mtcars[1:3,])
}

wrap_in_df(mpg * 2, cyl + 3)
#>   X.mpg...2 X.cyl...3
#> 1      42.0         9
#> 2      42.0         9
#> 3      45.6         7
wrap_in_tibble(mpg * 2, cyl + 3)
#> Error: object 'mpg' not found

Created on 2025-02-17 with reprex v2.1.1

The problem appears when the tibble in the tibble_quos function calls eval_tidy on the mpg * 2 argument without providing the mtcars data.

I remember reading at some point about problems of nesting multiple quosure evaluations, but cannot find this. I know that I could use something like quo_squash in eval_in_mtcars, but that has its own set of problems.

Is there some clever invocation that allows me to use a tibble in combination with quasi-evaluation?

like image 514
const-ae Avatar asked Oct 17 '25 13:10

const-ae


1 Answers

This happens because tibble() captures its arguments as quosures and evaluates them in its own data mask (which is the tibble being constructed sequentially). You have no way to modify that mask, so you need to instead inject your own data mask into the quosures’ chain of environments.

One way to do that is substitute() the ... in to an uneavaluated expression of a call to tibble() and then evaluate that expression with the data in place:

tibble_with_mtcars <- function(...) {
  eval(substitute(tibble::tibble(...)), head(mtcars), parent.frame())
}

tibble_with_mtcars(mpg * 2, cyl + 3)
#> # A tibble: 6 × 2
#>   `mpg * 2` `cyl + 3`
#>       <dbl>     <dbl>
#> 1      42           9
#> 2      42           9
#> 3      45.6         7
#> 4      42.8         9
#> 5      37.4        11
#> 6      36.2         9

… but unfortunately this is also fragile:

foo <- function(x) {
  tibble_with_mtcars(mpg + {{ x }})
}

foo(wt)
#> Error: object 'wt' not found

A more comprehensive approach would be to go ahead and slap the mask into the environment chain of the quosure, including nested quosures. That could look something like the following, but this is bound to be quite slow at the R level and is certainly not tested thoroughly.

A helper to do that:

quo_mask <- function(quo, mask, recursive = TRUE) {
  if (!rlang::is_call(quo)) {
    return(quo)
  }
  
  if (!rlang::is_environment(mask)) {
    mask <- rlang::as_environment(mask)
  }

  # Insert mask at the bottom of the environment chain.
  if (rlang::is_quosure(quo)) {
    env <- rlang::quo_get_env(quo)
    env <- rlang::env_clone(mask, env)
    quo <- rlang::quo_set_env(quo, env)
  }
  
  # Iterate through the expression, modifying in place.
  if (recursive) {
    x <- quo
    while (!rlang::is_null(x)) {
      car <- rlang::node_car(x)
      car <- quo_mask(car, mask)
      rlang::node_poke_car(x, car)
      x <- rlang::node_cdr(x)
    }
  }
  
  quo
}

And the application:

tibble_with_mtcars <- function(...){
  dots <- rlang::enquos(...)
  eval_with_mtcars(tibble::tibble(!!!dots))
}

eval_with_mtcars <- function(expr) {
  quo <- rlang::enquo(expr)
  quo <- quo_mask(quo, head(mtcars))
  rlang::eval_tidy(quo)
}

tibble_with_mtcars(mpg * 2)
#> # A tibble: 6 × 1
#>   `mpg * 2`
#>       <dbl>
#> 1      42  
#> 2      42  
#> 3      45.6
#> 4      42.8
#> 5      37.4
#> 6      36.2

foo(wt)
#> # A tibble: 6 × 1
#>   `mpg + wt`
#>        <dbl>
#> 1       23.6
#> 2       23.9
#> 3       25.1
#> 4       24.6
#> 5       22.1
#> 6       21.6
like image 58
Mikko Marttila Avatar answered Oct 19 '25 03:10

Mikko Marttila