From 2754bf9f934c7a567ffec0b9302c5aff1e45d6b3 Mon Sep 17 00:00:00 2001 From: Scott Chamberlain Date: Tue, 12 Nov 2024 11:54:41 -0800 Subject: [PATCH] added row policy fns --- NAMESPACE | 11 +++ R/as_priv.R | 9 -- R/as_row_policy.R | 46 ++++++++++ R/create.R | 10 +-- R/pipeline.R | 2 +- R/privileges.R | 26 +++++- R/rls-package.R | 2 +- R/row_policy.R | 132 +++++++++++++++++++++++++++++ R/utils.R | 9 ++ man/as_row_policy.Rd | 14 +++ man/commands.Rd | 25 ++++++ man/rls_run.Rd | 4 +- man/row_policy.Rd | 24 ++++++ man/rows_existing.Rd | 31 +++++++ man/rows_new.Rd | 42 +++++++++ tests/testthat/test-rls_policies.R | 6 ++ 16 files changed, 371 insertions(+), 22 deletions(-) create mode 100644 R/as_row_policy.R create mode 100644 R/row_policy.R create mode 100644 man/as_row_policy.Rd create mode 100644 man/commands.Rd create mode 100644 man/row_policy.Rd create mode 100644 man/rows_existing.Rd create mode 100644 man/rows_new.Rd diff --git a/NAMESPACE b/NAMESPACE index dfe08d3..98f6a86 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,11 +2,16 @@ S3method(as_priv,privilege) S3method(as_priv,tbl_sql) +S3method(as_row_policy,row_policy) +S3method(as_row_policy,tbl_sql) S3method(print,privilege) S3method(print,rls_policy) +S3method(print,row_policy) export("%>%") export(as_priv) +export(as_row_policy) export(auto_pipe) +export(commands) export(from) export(grant) export(has_postgres) @@ -27,6 +32,9 @@ export(rls_privileges) export(rls_run) export(rls_table_privileges) export(rls_tbl) +export(row_policy) +export(rows_existing) +export(rows_new) export(to) export(translate_privilege) import(dbplyr) @@ -38,6 +46,7 @@ importFrom(cli,cat_line) importFrom(cli,cli_abort) importFrom(cli,format_error) importFrom(dbplyr,sql) +importFrom(dbplyr,translate_sql) importFrom(dplyr,"%>%") importFrom(dplyr,filter) importFrom(dplyr,tbl) @@ -53,4 +62,6 @@ importFrom(rlang,enquos) importFrom(rlang,has_length) importFrom(rlang,is_character) importFrom(rlang,is_empty) +importFrom(rlang,is_scalar_character) +importFrom(rlang,quo_is_null) importFrom(tibble,as_tibble) diff --git a/R/as_priv.R b/R/as_priv.R index 7a8d19b..7f517af 100644 --- a/R/as_priv.R +++ b/R/as_priv.R @@ -36,12 +36,3 @@ cat_me <- function(x, y, indent = " ") { y <- paste0(y, collapse = ", ") cat_line(glue("{indent}{x}: {y}", .trim = FALSE)) } - -rls_grant <- function(commands, cols) { - x <- list(commands = commands, cols = cols) - structure(x, class = "rls_grant") -} -rls_revoke <- function(commands, cols) { - x <- list(commands = commands, cols = cols) - structure(x, class = "rls_revoke") -} diff --git a/R/as_row_policy.R b/R/as_row_policy.R new file mode 100644 index 0000000..9722904 --- /dev/null +++ b/R/as_row_policy.R @@ -0,0 +1,46 @@ +#' As row policy +#' @param x some input +#' @export +as_row_policy <- function(x) { + UseMethod("as_row_policy") +} +#' @export +as_row_policy.row_policy <- function(x) { + return(x) +} +#' @export +as_row_policy.tbl_sql <- function(x) { + tmp <- list( + data = x, + name = NULL, + commands = NULL, + user = NULL, + existing_rows = NULL, + new_rows = NULL, + type = NULL + ) + structure(tmp, class = "row_policy") +} +#' @export +print.row_policy <- function(x, ...) { + cat_line(glue(" {x$name}")) + if (!is_really_empty(x$user)) { + cat_me("user", x$user) + } + if (!is_really_empty(x$commands)) { + cat_me("commands", x$commands) + } + if (!is_really_empty(x$existing_rows)) { + cat_me("existing rows", x$existing_rows) + } + if (!is_really_empty(x$new_rows)) { + cat_me("new rows", x$new_rows) + } + if (!is_really_empty(x$privilege)) { + cat_me("type", x$type) + for (i in x$privilege) { + cat_me(x = i$commands, y = i$cols %|||% "", indent = " ") + } + } + print(x$data) +} diff --git a/R/create.R b/R/create.R index 3343871..aaeb399 100644 --- a/R/create.R +++ b/R/create.R @@ -44,16 +44,16 @@ rls_create_policy <- function(con, policy) { ) sql_create_policy <- glue(" {create_statement} POLICY {policy$name} ON {policy$table} - {combine_if('FOR', policy$command)} - {combine_if('TO', policy$role)} - {combine_if('USING', policy$using)} - {combine_if('WITH CHECK', policy$check)} + {combine_if_old('FOR', policy$command)} + {combine_if_old('TO', policy$role)} + {combine_if_old('USING', policy$using)} + {combine_if_old('WITH CHECK', policy$check)} ") sql_create_policy <- gsub("\n\\s+\n", "\n", sql_create_policy) invisible(dbExecute(con, sql_create_policy)) } # {ifelse(!is.null(policy$for_), paste('FOR', policy$for_), '')} -combine_if <- function(statement, item) { +combine_if_old <- function(statement, item) { ifelse(!is.null(item), paste(statement, item), "") } diff --git a/R/pipeline.R b/R/pipeline.R index 5bfed51..8942e99 100644 --- a/R/pipeline.R +++ b/R/pipeline.R @@ -73,7 +73,7 @@ pipe_autoexec <- function(toggle) { info <- pipeline_info() if (isTRUE(info[["is_piped"]])) { - rls_exit <- function(x) if (inherits(x, "privilege")) rls_run(x@data$src$con, x) else x + rls_exit <- function(x) if (inherits(x, c("privilege", "row_policy"))) rls_run(x$data$src$con, x) else x pipeline_on_exit(info$env) info$env$.rls_exitfun <- if (toggle) rls_exit else identity } diff --git a/R/privileges.R b/R/privileges.R index de5a460..4119301 100644 --- a/R/privileges.R +++ b/R/privileges.R @@ -72,7 +72,10 @@ revoke <- function(.data, ..., cols = NULL) { #' rls_tbl(con, "passwd") %>% to(jane, bob, alice) to <- function(.data, ...) { pipe_autoexec(toggle = rls_env$auto_pipe) - .data <- as_priv(.data) + .data <- switch(class(.data), + privilege = as_priv(.data), + row_policy = as_row_policy(.data) + ) .data$user <- dot_names(...) .data } @@ -161,9 +164,24 @@ priv_templates <- list( #' Run a query #' #' @export -#' @param priv an s3 object of class `privilege`, required +#' @param query an s3 object of class `privilege` or `row_policy, required #' @param con DBI connection object, required -rls_run <- function(con, priv) { - sql <- translate_privilege(priv, con) +rls_run <- function(con, query) { + is_conn(con) + assert_is(query, c("privilege", "row_policy")) + sql <- switch(class(query), + privilege = translate_privilege(query, con), + row_policy = translate_row_policy(query, con) + ) dbExecute(con, sql) } + +rls_grant <- function(commands, cols) { + x <- list(commands = commands, cols = cols) + structure(x, class = "rls_grant") +} + +rls_revoke <- function(commands, cols) { + x <- list(commands = commands, cols = cols) + structure(x, class = "rls_revoke") +} diff --git a/R/rls-package.R b/R/rls-package.R index 08ebabb..c7e9ce1 100644 --- a/R/rls-package.R +++ b/R/rls-package.R @@ -7,6 +7,6 @@ #' @importFrom RPostgres Postgres #' @importFrom tibble as_tibble #' @importFrom dplyr %>% -#' @importFrom rlang as_name enquo enquos is_character +#' @importFrom rlang as_name enquo enquos is_character quo_is_null ## usethis namespace: end NULL diff --git a/R/row_policy.R b/R/row_policy.R new file mode 100644 index 0000000..6cbe161 --- /dev/null +++ b/R/row_policy.R @@ -0,0 +1,132 @@ +#' Row policy +#' +#' @export +#' @inheritParams grant +#' @param name (character) scalar name for the policy. required +#' @examplesIf interactive() && has_postgres() +#' library(RPostgres) +#' con <- dbConnect(Postgres()) +#' rls_tbl(con, "passwd") %>% +#' row_policy("my_policy") +row_policy <- function(.data, name) { + pipe_autoexec(toggle = rls_env$auto_pipe) + assert_is(name, "character") + assert_scalar(name) + .data <- as_row_policy(.data) + .data$name <- name + .data +} + +#' Commands +#' +#' @export +#' @inheritParams grant +#' @examplesIf interactive() && has_postgres() +#' library(RPostgres) +#' con <- dbConnect(Postgres()) +#' rls_tbl(con, "passwd") %>% +#' row_policy("my_policy") %>% +#' commands(update) +commands <- function(.data, ...) { + pipe_autoexec(toggle = rls_env$auto_pipe) + .data <- as_row_policy(.data) + .data$commands <- dot_names(...) + .data +} + +#' Create rule for existing rows +#' +#' @export +#' @inheritParams grant +#' @param using an expression to use to check against existing rows +#' @param sql (character) sql syntax to use for existing rows +#' @details Use either `using` or `sql`, not both +#' @examplesIf interactive() && has_postgres() +#' library(RPostgres) +#' con <- dbConnect(Postgres()) +#' rls_tbl(con, "passwd") %>% +#' row_policy("my_policy") %>% +#' commands(update) %>% +#' rows_existing(sql = 'current_user = "user_name"') +rows_existing <- function(.data, using = NULL, sql = NULL) { + pipe_autoexec(toggle = rls_env$auto_pipe) + using_quo <- enquo(using) + stopifnot("Can not using and sql parameters together" = + xor(!rlang::quo_is_null(using_quo), !is_empty(sql))) + .data <- as_row_policy(.data) + if (rlang::is_null(sql)) { + .data$existing_rows <- translate_sql(!!using_quo, con = as_con(.data)) + } else { + .data$existing_rows <- sql + } + .data +} + +#' Create rule for new rows +#' +#' @export +#' @importFrom dbplyr translate_sql +#' @inheritParams grant +#' @param check an expression to use to check against addition of +#' new rows or editing of existing rows +#' @param sql (character) sql syntax to use for new rows +#' @details Use either `check` or `sql`, not both +#' @examplesIf interactive() && has_postgres() +#' library(RPostgres) +#' con <- dbConnect(Postgres()) +#' +#' rls_tbl(con, "passwd") %>% +#' row_policy("a_policy") %>% +#' commands(update) %>% +#' rows_existing(TRUE) %>% +#' rows_new(TRUE) %>% +#' to(jane) +#' +#' rls_tbl(con, "passwd") %>% +#' row_policy("my_policy") %>% +#' commands(update) %>% +#' rows_existing(sql = 'current_user = "user_name"') %>% +#' rows_new(home_phone == "098-765-4321") %>% +#' to(jane) +rows_new <- function(.data, check = NULL, sql = NULL) { + pipe_autoexec(toggle = rls_env$auto_pipe) + check_quo <- enquo(check) + stopifnot("Can not check and sql parameters together" = + xor(!rlang::quo_is_null(check_quo), !is_empty(sql))) + .data <- as_row_policy(.data) + if (rlang::is_null(sql)) { + .data$new_rows <- translate_sql(!!check_quo, con = as_con(.data)) + } else { + .data$new_rows <- sql + } + .data +} + +as_con <- function(x) { + assert_is(x, "row_policy") + x$data$src$con +} + +combine_if <- function(statement, item, fun = \(x) x) { + ifelse(!rlang::is_null(item), paste(statement, fun(item)), "") +} + +express <- function(x) { + glue("({ifelse(x == 'TRUE', tolower(x), x)})") +} + +translate_row_policy <- function(policy, con) { + is_conn(con) + create_statement <- switch(class(con), + RedshiftConnection = "CREATE RLS", + PqConnection = "CREATE" + ) + sql_create_policy <- glue(" + {create_statement} POLICY {policy$name} ON {attr(policy$data, 'table')} + {combine_if('FOR', policy$commands)} + {combine_if('TO', policy$user)} + {combine_if('USING', policy$existing_rows, express)} + {combine_if('WITH CHECK', policy$new_rows, express)} + ") + sql(gsub("\n\\s+\n", "\n", sql_create_policy)) +} diff --git a/R/utils.R b/R/utils.R index 3ffec42..c836ef7 100644 --- a/R/utils.R +++ b/R/utils.R @@ -28,6 +28,15 @@ assert_is <- function(x, y, arg = caller_arg(x)) { } } +#' @importFrom rlang is_scalar_character +assert_scalar <- function(x, arg = caller_arg(x)) { + if (!is_scalar_character(x)) { + rls_abort( + format_error("{.arg {arg}} must be scalar") + ) + } +} + #' @importFrom rlang has_length assert_len <- function(x, y, arg = caller_arg(x)) { if (!has_length(x, y)) { diff --git a/man/as_row_policy.Rd b/man/as_row_policy.Rd new file mode 100644 index 0000000..6d94fe7 --- /dev/null +++ b/man/as_row_policy.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/as_row_policy.R +\name{as_row_policy} +\alias{as_row_policy} +\title{As row policy} +\usage{ +as_row_policy(x) +} +\arguments{ +\item{x}{some input} +} +\description{ +As row policy +} diff --git a/man/commands.Rd b/man/commands.Rd new file mode 100644 index 0000000..cddba7f --- /dev/null +++ b/man/commands.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/row_policy.R +\name{commands} +\alias{commands} +\title{Commands} +\usage{ +commands(.data, ...) +} +\arguments{ +\item{.data}{an s3 object of class \code{privilege}} + +\item{...}{one of all, select, update, insert, delete} +} +\description{ +Commands +} +\examples{ +\dontshow{if (interactive() && has_postgres()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +library(RPostgres) +con <- dbConnect(Postgres()) +rls_tbl(con, "passwd") \%>\% + row_policy("my_policy") \%>\% + commands(update) +\dontshow{\}) # examplesIf} +} diff --git a/man/rls_run.Rd b/man/rls_run.Rd index 0489e19..6644f48 100644 --- a/man/rls_run.Rd +++ b/man/rls_run.Rd @@ -4,12 +4,12 @@ \alias{rls_run} \title{Run a query} \usage{ -rls_run(con, priv) +rls_run(con, query) } \arguments{ \item{con}{DBI connection object, required} -\item{priv}{an s3 object of class \code{privilege}, required} +\item{query}{an s3 object of class \code{privilege} or `row_policy, required} } \description{ Run a query diff --git a/man/row_policy.Rd b/man/row_policy.Rd new file mode 100644 index 0000000..5f05cc9 --- /dev/null +++ b/man/row_policy.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/row_policy.R +\name{row_policy} +\alias{row_policy} +\title{Row policy} +\usage{ +row_policy(.data, name) +} +\arguments{ +\item{.data}{an s3 object of class \code{privilege}} + +\item{name}{(character) scalar name for the policy. required} +} +\description{ +Row policy +} +\examples{ +\dontshow{if (interactive() && has_postgres()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +library(RPostgres) +con <- dbConnect(Postgres()) +rls_tbl(con, "passwd") \%>\% + row_policy("my_policy") +\dontshow{\}) # examplesIf} +} diff --git a/man/rows_existing.Rd b/man/rows_existing.Rd new file mode 100644 index 0000000..eb5225b --- /dev/null +++ b/man/rows_existing.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/row_policy.R +\name{rows_existing} +\alias{rows_existing} +\title{Create rule for existing rows} +\usage{ +rows_existing(.data, using = NULL, sql = NULL) +} +\arguments{ +\item{.data}{an s3 object of class \code{privilege}} + +\item{using}{an expression to use to check against existing rows} + +\item{sql}{(character) sql syntax to use for existing rows} +} +\description{ +Create rule for existing rows +} +\details{ +Use either \code{using} or \code{sql}, not both +} +\examples{ +\dontshow{if (interactive() && has_postgres()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +library(RPostgres) +con <- dbConnect(Postgres()) +rls_tbl(con, "passwd") \%>\% + row_policy("my_policy") \%>\% + commands(update) \%>\% + rows_existing(sql = 'current_user = "user_name"') +\dontshow{\}) # examplesIf} +} diff --git a/man/rows_new.Rd b/man/rows_new.Rd new file mode 100644 index 0000000..eb3e88e --- /dev/null +++ b/man/rows_new.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/row_policy.R +\name{rows_new} +\alias{rows_new} +\title{Create rule for new rows} +\usage{ +rows_new(.data, check = NULL, sql = NULL) +} +\arguments{ +\item{.data}{an s3 object of class \code{privilege}} + +\item{check}{an expression to use to check against addition of +new rows or editing of existing rows} + +\item{sql}{(character) sql syntax to use for new rows} +} +\description{ +Create rule for new rows +} +\details{ +Use either \code{check} or \code{sql}, not both +} +\examples{ +\dontshow{if (interactive() && has_postgres()) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +library(RPostgres) +con <- dbConnect(Postgres()) + +rls_tbl(con, "passwd") \%>\% + row_policy("a_policy") \%>\% + commands(update) \%>\% + rows_existing(TRUE) \%>\% + rows_new(TRUE) \%>\% + to(jane) + +rls_tbl(con, "passwd") \%>\% + row_policy("my_policy") \%>\% + commands(update) \%>\% + rows_existing(sql = 'current_user = "user_name"') \%>\% + rows_new(home_phone == "098-765-4321") \%>\% + to(jane) +\dontshow{\}) # examplesIf} +} diff --git a/tests/testthat/test-rls_policies.R b/tests/testthat/test-rls_policies.R index e7e1628..7ec89ab 100644 --- a/tests/testthat/test-rls_policies.R +++ b/tests/testthat/test-rls_policies.R @@ -1,5 +1,11 @@ test_that("rls_policies", { with_database_connection({ + # first, drop any existing tables + tables <- DBI::dbListTables(con) + if (length(tables)) { + invisible(lapply(tables, \(x) DBI::dbRemoveTable(con, x))) + } + DBI::dbWriteTable(con, "attitude", attitude, temporary = TRUE) on.exit(DBI::dbRemoveTable(con, "attitude"), add = TRUE)