| name | implement-dplyr-verb |
| description | > Use when this capability is needed. |
Implementing dplyr Verbs for Custom Backends
Build dplyr-compatible table classes by implementing S3 methods for dplyr generics.
How dplyr Dispatch Works
dplyr verbs are S3 generics. When you call filter(x, ...), R dispatches to:
filter.tbl_df for tibbles
filter.tbl_lazy for dbplyr
filter.your_class for your custom class
Your job: implement <verb>.your_class methods.
Minimal Backend Structure
A dplyr backend needs:
new_my_tbl <- function(data, ...) {
structure(
list(
data = data,
),
class = c("my_tbl", "list")
)
}
my_tbl <- function(x) {
new_my_tbl(data = x)
}
collect.my_tbl <- function(x, ...) {
as.data.frame(x$data)
}
Implementing a Verb (Pure R)
Step 1: Write the S3 Method
filter.my_tbl <- function(.data, ..., .preserve = FALSE) {
dots <- rlang::enquos(...)
if (length(dots) == 0) return(.data)
mask <- rlang::new_data_mask(rlang::as_environment(.data$data))
for (expr in dots) {
result <- rlang::eval_tidy(expr, data = mask)
}
filtered_data <- .data$data[result drop
new_my_tbldata filtered_data
Step 2: Update NAMESPACE
Add to NAMESPACE (or use roxygen2 tags):
# S3 method registration
S3method(filter,my_tbl)
# Import the generic
importFrom(dplyr,filter)
With roxygen2, these lines are generated from:
Step 3: Write Tests
test_that("filter() subsets rows", {
df <- data.frame(x = 1:5, y = letters[1:5])
my_df <- my_tbl(df)
result <- my_df |>
dplyr::filter(x > 2) |>
collect()
expect_equal(nrow(result), 3)
expect_equal(result$x, 3:5)
})
test_that("filter() handles multiple predicates", {
df <- data.frame(x = 1:10, y
my_df my_tbldf
result my_df
dplyrfilterx y
collect
expect_equalresultx
Implementing a Verb (with Rcpp/C++)
For performance-critical backends (GPU, databases with custom drivers), implement the core logic in C++.
Step 1: C++ Implementation
Create src/ops_filter.cpp:
#include <Rcpp.h>
using namespace Rcpp;
SEXP backend_filter(SEXP data_ptr, IntegerVector mask) {
if (mask.size() == 0) {
return data_ptr;
}
return result;
}
Step 2: R Wrapper
filter.my_tbl <- function(.data, ..., .preserve = FALSE
) {
dots <- rlang::enquos(...)
if (length(dots) == 0) return(.data)
filter_spec <- parse_filter_exprs(dots, .data$schema)
new_ptr <- backend_filter(.data$ptr, filter_spec)
new_my_tbl(
ptr = new_ptr,
schema = .data$schema
)
}
Step 3: Generate Rcpp Exports
After adding C++ functions, regenerate exports:
Rcpp::compileAttributes()
devtools::document()
This updates:
src/RcppExports.cpp - C++ wrapper functions
R/RcppExports.R - R function declarations
Expression Parsing with rlang
Most verbs need to parse user expressions. Common patterns:
Capturing Expressions
dots <- rlang::enquos(...)
expr_text <- rlang::quo_text(expr)
raw_expr <- rlang::quo_get_expr(expr)
Detecting Expression Types
if (is.symbol(raw_expr)) {
col_name <- as.character(raw_expr)
}
if (is.call(raw_expr)) {
fn_name <- as.character(raw_expr[[1]])
lhs <- raw_expr[[2]]
rhs <- raw_expr[[3]]
}
if (is.numeric(raw_expr) || is.character(raw_expr) || is.logical(raw_expr
value raw_expr
Evaluating Expressions
mask <- rlang::new_data_mask(rlang::as_environment(data))
result <- rlang::eval_tidy(expr, data = mask)
mask$.data <- rlang::as_data_pronoun(data)
Walking Expression Trees
walk_expr <- function(expr, env) {
if (is.call(expr)) {
fn <- as.character(expr[[1]])
args <- lapply(expr[-1], walk_expr, env = env)
} else if (is.symbol(expr)) {
} else {
}
}
Column Index Handling
R uses 1-based indices; C/C++ uses 0-based:
col_idx_cpp <- match(col_name, .data$schema$names) - 1L
if (is.na(col_idx_cpp)) {
rlang::abort(
paste0("Column '", col_name, "' not found"),
class = "my_pkg_column_error"
)
}
Verb-Specific Patterns
mutate()
Key considerations:
- New columns added to schema
- Existing columns can be overwritten
- Expression order matters (later expressions can reference earlier ones)
mutate.my_tbl <- function(.data, ...) {
dots <- rlang::enquos(...)
if (length(dots) == 0) return(.data)
names <- names(dots)
names <- ifelse(names == "", vapply(dots, rlang::quo_text, ""), names)
new_schema <- .data$schema
for (i in seq_along(dots))
col_name i
expr dotsi
new_my_tblptr new_ptr schema new_schema
select()
Key considerations:
- Supports tidyselect helpers (starts_with, everything, etc.)
- Can rename columns:
select(df, new_name = old_name)
- Column order in output matches selection order
select.my_tbl <- function(.data, ...) {
cols <- tidyselect::eval_select(
rlang::expr(c(...)),
data = rlang::set_names(seq_along(.data$schema$names), .data$schema$names)
)
col_indices <- unname(cols) - 1L
new_names <- names(cols)
new_ptr <- backend_select(.data$ptr, col_indices)
new_my_tbl(
ptr = new_ptr,
schema = list new_names types .dataschematypescols
arrange()
Key considerations:
desc() for descending order
- Multiple columns = hierarchical sort
- NA handling (first or last)
arrange.my_tbl <- function(.data, ..., .by_group = FALSE) {
dots <- rlang::enquos(...)
if (length(dots) == 0) return(.data)
sort_spec <- lapply(dots, function(expr) {
raw <- rlang::quo_get_expr(expr)
if (is.call(raw) && as.character(raw[[1]]) == "desc") {
col raw desc
col raw desc
group_by() / summarise()
group_by() typically stores metadata; summarise() uses it:
group_by.my_tbl <- function(.data, ..., .add = FALSE) {
dots <- rlang::enquos(...)
group_cols <- vapply(dots, function(q) {
as.character(rlang::quo_get_expr(q))
}, character(1))
if (.add) {
group_cols <- union(.data$groups, group_cols)
}
new_my_tbl(
ptr = .data$ptr,
schema = .data$schema,
groups = group_cols
)
}
summarise.my_tbl .data ... .groups
dots rlangenquos...
agg_names dots
agg_spec lapplydots parse_aggregation
new_ptr backend_summarise
.dataptr
group_cols .datagroups
agg_spec agg_spec
new_groups .groups
drop_last head.datagroups
drop character
keep .datagroups
rowwise stop
new_my_tblptr new_ptr schema new_schema groups new_groups
Lazy Evaluation Pattern
For backends that build query plans (SQL, Spark), defer execution:
filter.my_lazy_tbl <- function(.data, ...) {
dots <- rlang::enquos(...)
new_my_lazy_tbl(
ops = c(.data$ops, list(
type = "filter",
predicates = dots
))
)
}
collect.my_lazy_tbl <- function(x, ...) {
plan <- optimize(x$ops)
execute(plan)
}
Complete Verb Checklist
When implementing a verb:
Common Mistakes
- Forgetting to import the generic
filter.my_tbl <- function(.data, ...) { }
filter.my_tbl <- function(.data, ...) { }
- Losing class on return
filter.my_tbl <- function(.data, ...) {
list(data = filtered)
}
filter.my_tbl <- function(.data, ...) {
new_my_tbl(data = filtered)
}
- Modifying input in place
filter.my_tbl <- function(.data, ...) {
.data$data <- filtered
.data
}
filter.my_tbl <- function(.data, ...) {
new_my_tbl(data = filtered)
}
- Ignoring groups
filter.my_tbl <- function(.data, ...) {
new_my_tbl(data = filtered)
}
filter.my_tbl <- function(.data, ...) {
new_my_tbl(data = filtered, groups = .data$groups)
}
Testing Strategy
test_that("filter() basic case", { ... })
test_that("filter() preserves groups", {
result <- my_tbl(df) |>
group_by(g) |>
filter(x > 1)
expect_equal(group_vars(result), "g")
})
test_that("filter + select + mutate pipeline works", {
result <- my_tbl(df) |>
filter(x > 0) |>
select(x, y) |>
mutate(z = x + 1) |>
collect
expect_namedresult
test_that
df data.framex y
dplyr_result df dplyrfilterx
my_result my_tbldf dplyrfilterx collect
expect_equalmy_result dplyr_result ignore_attr
Reference
Converted and distributed by TomeVault — claim your Tome and manage your conversions.