diff --git a/.Rbuildignore b/.Rbuildignore index e0144475..ea1c59ac 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -11,3 +11,5 @@ ^Meta$ ^_pkgdown\.yml$ ^pkgdown$ +^.Renviron +^[.]?air[.]toml$ diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 3255fc1f..bf02c95a 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -28,10 +28,12 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} R_KEEP_PKG_SOURCE: yes # Configuring ojodb access for github actions + OJO_DRIVER: ${{ secrets.OJO_DRIVER }} + OJO_DATABASE: ${{ secrets.OJO_DATABASE }} OJO_HOST: ${{ secrets.OJO_HOST }} OJO_PORT: ${{ secrets.OJO_PORT }} - OJO_DEFAULT_USER: ${{ secrets.OJO_DEFAULT_USER }} - OJO_DEFAULT_PASS: ${{ secrets.OJO_DEFAULT_PASS }} + OJO_USER: ${{ secrets.OJO_USER }} + OJO_PASS: ${{ secrets.OJO_PASS }} OJO_SSL_MODE: ${{ secrets.OJO_SSL_MODE }} # Encoded secrets OJO_SSL_CERT_BASE64: ${{ secrets.OJO_SSL_CERT_BASE64 }} diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index b76381fd..e129b4e0 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -21,10 +21,12 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} R_KEEP_PKG_SOURCE: yes # Configuring ojodb access for github actions + OJO_DRIVER: ${{ secrets.OJO_DRIVER }} + OJO_DATABASE: ${{ secrets.OJO_DATABASE }} OJO_HOST: ${{ secrets.OJO_HOST }} OJO_PORT: ${{ secrets.OJO_PORT }} - OJO_DEFAULT_USER: ${{ secrets.OJO_DEFAULT_USER }} - OJO_DEFAULT_PASS: ${{ secrets.OJO_DEFAULT_PASS }} + OJO_USER: ${{ secrets.OJO_USER }} + OJO_PASS: ${{ secrets.OJO_PASS }} OJO_SSL_MODE: ${{ secrets.OJO_SSL_MODE }} # Encoded secrets OJO_SSL_CERT_BASE64: ${{ secrets.OJO_SSL_CERT_BASE64 }} diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 705bc631..43cb8b25 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -12,10 +12,12 @@ jobs: env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} # Configuring ojodb access for github actions + OJO_DRIVER: ${{ secrets.OJO_DRIVER }} + OJO_DATABASE: ${{ secrets.OJO_DATABASE }} OJO_HOST: ${{ secrets.OJO_HOST }} OJO_PORT: ${{ secrets.OJO_PORT }} - OJO_DEFAULT_USER: ${{ secrets.OJO_DEFAULT_USER }} - OJO_DEFAULT_PASS: ${{ secrets.OJO_DEFAULT_PASS }} + OJO_USER: ${{ secrets.OJO_USER }} + OJO_PASS: ${{ secrets.OJO_PASS }} OJO_SSL_MODE: ${{ secrets.OJO_SSL_MODE }} # Encoded secrets OJO_SSL_CERT_BASE64: ${{ secrets.OJO_SSL_CERT_BASE64 }} diff --git a/.gitignore b/.gitignore index 0691ddd4..1df3a338 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ inst/doc /doc/ /Meta/ docs +.Renviron diff --git a/DESCRIPTION b/DESCRIPTION index 86ca4d03..fa4e12b9 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: ojodb Title: Analyze Data from the Open Justice Oklahoma Database -Version: 2.11.1 +Version: 3.0.0 Authors@R: c( person( given = "Brancen", @@ -33,23 +33,23 @@ Authors@R: c( )) Description: {ojodb} provides convenient functions to query court data from the Open Justice Oklahoma database. Imports: - cli, - dbplyr, - DBI, - ggplot2, - glue, - lifecycle, - lubridate, - pool, - purrr, - rlang, - RPostgres, - stringi, - stringr, - tibble, - tidyr, - utils, - withr + cli, + dbplyr, + DBI, + digest, + glue, + lubridate, + pool, + purrr, + rlang, + RPostgres, + stringi, + stringr, + tibble, + tidyr, + utils, + withr, + yaml Suggests: arrow, attachment, @@ -64,10 +64,12 @@ Suggests: janitor, knitr, languageserver, + lifecycle, lintr, readr, roxygen2, rmarkdown, + RSQLite, rvest, styler, testthat (>= 3.0.0), @@ -75,6 +77,7 @@ Suggests: usethis, xml2 Depends: + R (>= 4.1.0), dplyr Remotes: nx10/httpgd @@ -84,7 +87,6 @@ LazyData: true RoxygenNote: 7.3.2 VignetteBuilder: knitr Config/testthat/edition: 3 -Config/testthat/parallel: true URL: https://github.com/openjusticeok/ojodb BugReports: https://github.com/openjusticeok/ojodb/issues Roxygen: list(markdown = TRUE) diff --git a/NAMESPACE b/NAMESPACE index 680249b0..b1175324 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand +export(db_config) export(ojo_add_counts) export(ojo_add_issues) export(ojo_add_minutes) @@ -9,23 +10,18 @@ export(ojo_case_types) export(ojo_check_ssl) export(ojo_civ_cases) export(ojo_collect) -export(ojo_color) export(ojo_connect) +export(ojo_connection_string) export(ojo_county_population) export(ojo_crim_cases) -export(ojo_env) export(ojo_eviction_cases) -export(ojo_fill) export(ojo_fiscal_year) export(ojo_list_schemas) export(ojo_list_tables) export(ojo_list_vars) -export(ojo_pal) +export(ojo_pool) export(ojo_query) -export(ojo_search_minutes) -export(ojo_show_row) export(ojo_tbl) -export(ojo_theme) export(ojo_version) import(dplyr) importFrom(dplyr,case_when) @@ -33,7 +29,6 @@ importFrom(dplyr,filter) importFrom(dplyr,left_join) importFrom(dplyr,mutate) importFrom(dplyr,select) -importFrom(ggplot2,"%+replace%") importFrom(lubridate,as_date) importFrom(lubridate,floor_date) importFrom(stringr,str_detect) diff --git a/NEWS.md b/NEWS.md index d8d4cb44..e01f33d5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,33 @@ +# ojodb 3.0.0 + +## Breaking Changes +- **Connection Management:** `ojo_connect()` now requires a `db_config` object (or creates a default one) and returns a connection object directly, instead of using side effects to store it in a package environment. The arguments `.admin`, `.driver`, `.global`, `.env`, and `.pool` have been removed. +- **Authentication:** `ojo_auth()` now accepts a `db_config` object. The arguments `host`, `port`, `username`, `password`, `.admin`, and `.overwrite` (old signature) have been replaced/consolidated. +- **Environment Variables:** + - `OJO_ADMIN_USER` and `OJO_DEFAULT_USER` have been consolidated into `OJO_USER`. + - `OJO_ADMIN_PASS` and `OJO_DEFAULT_PASS` have been consolidated into `OJO_PASS`. + - `OJO_SSL_MODE` is no longer hardcoded to "verify-ca". + - `OJO_SSL_ROOT_CERT`, `OJO_SSL_CERT`, and `OJO_SSL_KEY` are now standard environment variables. +- Removed deprecated functions: `ojo_env()`, `ojo_show_row()`, and `ojo_search_minutes()`. +- Removed `tbl_from_gcs_arrow()`, `tbl_from_gcs_duckdb()`, and `tbl_from_rpostgres()`. +- Standardized naming conventions across the package. + +## New Features +- **Flexible Configuration:** Added `db_config()` to create configuration objects from arguments, YAML files, or environment variables. +- **Connection Pooling:** Added `ojo_pool()` to easily create managed connection pools for applications (e.g., Shiny). +- **Explicit Connections:** Added `con` argument to all major query functions (`ojo_case_types()`, `ojo_civ_cases()`, `ojo_crim_cases()`, `ojo_eviction_cases()`, `ojo_tbl()`, `ojo_query()`, etc.) to allow passing specific connection objects. +- **Default Connection:** `ojo_default_connection()` now manages the interactive singleton connection, ensuring a smoother user experience for interactive sessions. +- Improved sorting in `ojo_list_tables()` and `ojo_list_schemas()`. + +## Bug Fixes +- Fixed `ojo_check_ssl()` to correctly handle both single connections and connection pools. +- Fixed `ojo_add_party_details()` to properly handle lazy tibbles and provide better error messages. +- Fixed a bug in `ojo_collect()`'s result estimation. + +## Package Maintenance +- Significant increase in test coverage, including tests for previously untested utility and query functions. +- Documentation updates and standardization. + # ojodb 2.11.1 (2025-07-01) ## Bug Fixes diff --git a/R/aaa.R b/R/aaa.R index 78bf1882..f7541fd8 100644 --- a/R/aaa.R +++ b/R/aaa.R @@ -1 +1 @@ -.ojo_env <- rlang::new_environment() \ No newline at end of file +.ojo_env <- rlang::new_environment() diff --git a/R/globals.R b/R/globals.R new file mode 100644 index 00000000..20b7fd5d --- /dev/null +++ b/R/globals.R @@ -0,0 +1,21 @@ +utils::globalVariables( + c( + "NAME", + "case_id", + "case_type", + "column_name", + "court", + "date_filed", + "description", + "disposition", + "disposition_date", + "district", + "schema", + "schema_name", + "table_name", + "table_schema", + "value", + "variable", + "year" + ) +) diff --git a/R/ojo_add_minutes.R b/R/ojo_add_minutes.R index 4c3ab91b..67c7e1d5 100644 --- a/R/ojo_add_minutes.R +++ b/R/ojo_add_minutes.R @@ -3,21 +3,23 @@ #' Query the Open Justice Oklahoma database for the minutes of a case #' #' @param data A lazy tibble containing the cases to query +#' @param con The OJO database connection to use #' @param ... Placeholder for additional arguments -#' +#' #' @export ojo_add_minutes #' @returns A lazy tibble containing the resulting cases with minutes -#' +#' #' @examples #' \dontrun{ #' ojo_add_minutes() #' } #' -ojo_add_minutes <- function(data, ...) { - minutes <- ojo_tbl("minute") +ojo_add_minutes <- function(data, con = NULL, ...) { + minutes <- ojo_tbl("minute", con = con) data <- data |> - dplyr::left_join(minutes, + dplyr::left_join( + minutes, by = c("id" = "case_id"), suffix = c("", ".minute") ) diff --git a/R/ojo_add_party_details.R b/R/ojo_add_party_details.R index 6d957c55..817e5b09 100644 --- a/R/ojo_add_party_details.R +++ b/R/ojo_add_party_details.R @@ -4,6 +4,7 @@ #' #' @param data A lazy tibble containing the results of a parties query #' @param vars A character vector of variables to return +#' @param con The OJO database connection to use #' @param ... Placeholder for additional arguments #' #' @export ojo_add_party_details @@ -13,7 +14,7 @@ #' ojo_add_party_details() #' } #' -ojo_add_party_details <- function(data, vars = NULL, ...) { +ojo_add_party_details <- function(data, vars = NULL, con = NULL, ...) { if (!inherits(data, "tbl_lazy")) { stop("Don't use `collect()` before this function") } @@ -22,17 +23,19 @@ ojo_add_party_details <- function(data, vars = NULL, ...) { if (!"party" %in% columns) { if ("parties" %in% columns) { - if (inherits(data$parties, "pq__text")) { - stop("You must first unnest the `parties` column") - } else { - stop("Make sure you unnested the `parties` column into a column named `party`") - } + stop( + "Data contains a `parties` column but no `party` column. ", + "You must first unnest the `parties` column into a column named `party`." + ) } - stop("Data must contain a column named `party`") + stop("Data must contain a column named `party`.") } data <- data |> - dplyr::left_join(ojo_tbl("person_record"), by = c("party" = "id")) + dplyr::left_join( + ojo_tbl("person_record", con = con), + by = c("party" = "id") + ) return(data) } diff --git a/R/ojo_auth.R b/R/ojo_auth.R index 8ac78b0e..ad247869 100644 --- a/R/ojo_auth.R +++ b/R/ojo_auth.R @@ -1,152 +1,387 @@ -#' @title Create configuration for OJO database connection +#' @title Create database configuration object #' -#' @description Configure credentials for the Open Justice Oklahoma database +#' @description Creates a configuration object for OJO database connections with flexible configuration sources. +#' +#' @param ... Placeholder for future args +#' @param driver Database driver name +#' @param database Database name +#' @param host Database host name +#' @param port Database port number +#' @param username Database username +#' @param password Database password +#' @param ssl_mode SSL connection mode +#' @param ssl_root_cert Path to SSL root certificate file +#' @param ssl_cert Path to SSL client certificate file +#' @param ssl_key Path to SSL private key file +#' @param config_file Path to YAML configuration file #' #' @details -#' Assists the user in populating a .Renviron file with the necessary environment variables to connect to the Open Justice Oklahoma database. +#' This function creates a database configuration object by reading from multiple sources +#' with the following precedence (highest to lowest): +#' 1. Function arguments +#' 2. YAML configuration file +#' 3. Environment variables (.Renviron and system) +#' +#' @return A db_config object (list) containing database connection parameters +#' @export +#' +#' @examples +#' \dontrun{ +#' # Create config with explicit parameters +#' config <- db_config( +#' driver = "RPostgres", +#' database = "ojodb", +#' host = "localhost", +#' port = "5432", +#' username = "user", +#' password = "pass", +#' ssl_mode = "require" +#' ) +#' +#' # For local testing where SSL is not configured +#' local_config <- db_config( +#' driver = "RPostgres", +#' database = "ojodb", +#' host = "localhost", +#' port = "5432", +#' username = "postgres", +#' password = "password", +#' ssl_mode = "disable" +#' ) +#' +#' # Create config from YAML file +#' config <- db_config(config_file = "~/.ojo_config.yaml") +#' +#' } +db_config <- function( + ..., + driver = NULL, + database = NULL, + host = NULL, + port = NULL, + username = NULL, + password = NULL, + ssl_mode = NULL, + ssl_root_cert = NULL, + ssl_cert = NULL, + ssl_key = NULL, + config_file = NULL +) { + # Initialize default configuration + config <- list( + driver = NULL, + database = NULL, + host = NULL, + port = NULL, + username = NULL, + password = NULL, + ssl_mode = NULL, + ssl_root_cert = NULL, + ssl_cert = NULL, + ssl_key = NULL + ) + + # Read from environment variables first + if (Sys.getenv("OJO_DRIVER") != "") { + config$driver <- Sys.getenv("OJO_DRIVER") + } + + if (Sys.getenv("OJO_DATABASE") != "") { + config$database <- Sys.getenv("OJO_DATABASE") + } + + if (Sys.getenv("OJO_HOST") != "") { + config$host <- Sys.getenv("OJO_HOST") + } + + if (Sys.getenv("OJO_PORT") != "") { + config$port <- Sys.getenv("OJO_PORT") + } + + if (Sys.getenv("OJO_USER") != "") { + config$username <- Sys.getenv("OJO_USER") + } + + if (Sys.getenv("OJO_PASS") != "") { + config$password <- Sys.getenv("OJO_PASS") + } + + if (Sys.getenv("OJO_SSL_MODE") != "") { + config$ssl_mode <- Sys.getenv("OJO_SSL_MODE") + } + + if (Sys.getenv("OJO_SSL_ROOT_CERT") != "") { + config$ssl_root_cert <- Sys.getenv("OJO_SSL_ROOT_CERT") + } + + if (Sys.getenv("OJO_SSL_CERT") != "") { + config$ssl_cert <- Sys.getenv("OJO_SSL_CERT") + } + + if (Sys.getenv("OJO_SSL_KEY") != "") { + config$ssl_key <- Sys.getenv("OJO_SSL_KEY") + } + + # Override with config file if provided + if (!is.null(config_file) && file.exists(config_file)) { + file_config <- yaml::read_yaml(config_file) + # Support both flat and nested config structures + if (!is.null(file_config$ojodb)) { + file_config <- file_config$ojodb + } + + for (name in names(config)) { + if (!is.null(file_config[[name]])) { + config[[name]] <- file_config[[name]] + } + } + } + + # Override with function arguments (highest precedence) + if (!is.null(driver)) { + config$driver <- driver + } + + if (!is.null(database)) { + config$database <- database + } + + if (!is.null(host)) { + config$host <- host + } + + if (!is.null(port)) { + config$port <- port + } + + if (!is.null(username)) { + config$username <- username + } + + if (!is.null(password)) { + config$password <- password + } + + if (!is.null(ssl_mode)) { + config$ssl_mode <- ssl_mode + } + + if (!is.null(ssl_root_cert)) { + config$ssl_root_cert <- ssl_root_cert + } + + if (!is.null(ssl_cert)) { + config$ssl_cert <- ssl_cert + } + + if (!is.null(ssl_key)) { + config$ssl_key <- ssl_key + } + + # Add class for method dispatch + class(config) <- c("db_config", "list") + config +} + +#' @title Configure OJO database authentication +#' +#' @description Sets up authentication for the Open Justice Oklahoma database using a db_config object. #' -#' @param host The host name of the database server -#' @param port The port number of the database server -#' @param username The username to use to connect to the database -#' @param password The password to use to connect to the database -#' @param ... Placeholder for additional arguments -#' @param .admin A logical value indicating whether to connect to the database as an administrator -#' @param .overwrite A logical value indicating whether to overwrite the existing .Renviron file -#' @param .install A logical value indicating whether to install the database connection or use it only for the current session +#' @param ... Placeholder for future arguments +#' @param db_config A db_config object created by `db_config()` +#' @param .install Logical indicating whether to save configuration to .Renviron (TRUE) or use for current session only (FALSE) +#' @param .overwrite Logical indicating whether to overwrite existing .Renviron entries #' +#' @details +#' This function takes a db_config object and sets up the database authentication. +#' When .install = FALSE (default), credentials are only set for the current R session. +#' When .install = TRUE, credentials are saved to .Renviron for persistent use. +#' +#' @return Invisible NULL #' @export -#' @returns Nothing #' #' @examples #' \dontrun{ -#' ojo_auth() +#' # Create config and authenticate for a remote server +#' config <- db_config( +#' driver = "RPostgres", +#' database = "ojodb", +#' host = "remote.db.com", +#' port = "5432", +#' username = "user", +#' password = "pass", +#' ssl_mode = "require" +#' ) +#' ojo_auth(db_config = config) +#' +#' # One-liner with explicit parameters +#' ojo_auth(db_config = db_config( +#' driver = "RPostgres", +#' database = "ojodb", +#' host = "remote.db.com", +#' port = "5432", +#' username = "user", +#' password = "pass", +#' ssl_mode = "require" +#' )) +#' +#' # From config file +#' ojo_auth(db_config = db_config(config_file = "~/.ojo_config.yaml")) +#' +#' # Permanent configuration +#' ojo_auth( +#' db_config = db_config( +#' driver = "RPostgres", +#' database = "ojodb", +#' host = "remote.db.com", +#' port = "5432", +#' username = "user", +#' password = "pass", +#' ssl_mode = "require" +#' ), +#' .install = TRUE +#' ) #' } -#' @section Side Effects: -#' The first time this function is run, it will prompt the user for a username, password, and host name. -#' It will then store these credentials in the user's .Renviron file. -#' If the .Renviron file already exists, it will be backed up and the new credentials will be appended to the end of the file. -#' If the .Renviron file does not exist, it will be created and the credentials will be stored there. -#' -ojo_auth <- function(host, port, username, password, ..., .admin = F, .overwrite = T, .install = T) { - home <- Sys.getenv("HOME") - renv <- fs::path(home, ".Renviron") - rootcert <- fs::path(home, ".postgresql/ojodb/server-ca.pem") - clientcert <- fs::path(home, ".postgresql/ojodb/client-cert.pem") - clientkey <- fs::path(home, ".postgresql/ojodb/client-key.pem") - - # Check if SSL certs are in correct location; if not, throw error - if (!fs::file_exists(rootcert) | - !fs::file_exists(clientcert) | - !fs::file_exists(clientkey)) { - rlang::abort( - paste0( - "It looks like your SSL certs are not in the correct location (", - fs::path(home, ".postgresql/ojodb/..."), - ").\nPlease check that you have all three (server-ca.pem, client-cert.pem, and client-key.pem)." - ) +#' +ojo_auth <- function(..., db_config, .install = FALSE, .overwrite = FALSE) { + # Validate input + if (!inherits(db_config, "db_config")) { + stop( + "db_config must be a db_config object created by db_config()", + call. = FALSE ) } - if (.install) { + # Check required parameters + required <- c("driver", "host", "port", "username", "password", "ssl_mode") + missing <- required[sapply(required, function(x) is.null(db_config[[x]]))] + if (length(missing) > 0) { + stop( + "Missing required configuration: ", + paste(missing, collapse = ", "), + call. = FALSE + ) + } + + # TODO: Make required params depend on value of driver - # Check if .Renviron exists. If it does, make a backup... - if (fs::file_exists(renv)) { - # Backup original .Renviron before doing anything else here. - fs::file_copy(renv, fs::path(home, ".Renviron_backup"), - overwrite = TRUE) + # Validate SSL certificates exist if using verify modes + if ( + !is.null(db_config$ssl_mode) && + db_config$ssl_mode %in% c("verify-ca", "verify-full") + ) { + missing_certs <- c() + ssl_files <- c("ssl_root_cert", "ssl_cert", "ssl_key") + for (cert_type in ssl_files) { + cert_path <- db_config[[cert_type]] + if (!is.null(cert_path) && !file.exists(cert_path)) { + missing_certs <- c( + missing_certs, + paste0(cert_type, " (", cert_path, ")") + ) + } } - # ...if not, create a fresh one. - if (!fs::file_exists(renv)) { - fs::file_create(renv) - # Filling out the .Renviron file + if (length(missing_certs) > 0) { + warning( + "SSL certificate files not found:\n", + paste(missing_certs, collapse = "\n"), + "\nConnection may fail. Consider using ssl_mode = 'require' to try a secure connection without certs or, more likely, provide valid certificate paths.", + call. = FALSE + ) + } + } + + if (.install) { + home <- path.expand("~") + renv <- file.path(home, ".Renviron") + + # Backup existing .Renviron + if (file.exists(renv)) { + file.copy(renv, file.path(home, ".Renviron_backup"), overwrite = TRUE) } else { + file.create(renv) + } - # If we want to overwrite the old config: - if (isTRUE(.overwrite)) { - cli::cli_alert_info("Your original .Renviron will be backed up and stored in your R HOME directory if needed.") - - # Saving the original .Renviron file - oldenv <- utils::read.table(renv, stringsAsFactors = FALSE) - # Creating the new .Renviron file (not filled out yet, all OJO variables removed) - newenv <- oldenv |> - dplyr::as_tibble() |> - dplyr::filter(!stringi::stri_detect_regex(.data$V1, "(OJO_HOST)|(OJO_PORT)|(OJO_DRIVER)|(OJO_SSL)")) - if (.admin == T) { - newenv <- newenv |> - dplyr::filter(!stringi::stri_detect_regex(.data$V1, "(OJO_ADMIN_USER)|(OJO_ADMIN_PASS)")) |> - as.data.frame() - } else { - newenv <- newenv |> - dplyr::filter(!stringi::stri_detect_regex(.data$V1, "(OJO_DEFAULT_USER)|(OJO_DEFAULT_PASS)")) |> - as.data.frame() - } - - # Save new .Renviron file with OJO variables removed - utils::write.table(newenv, renv, - quote = FALSE, sep = "\n", - col.names = FALSE, row.names = FALSE - ) + # Clean existing OJO variables if overwriting + if (.overwrite && file.exists(renv)) { + lines <- readLines(renv) + # Remove all OJO-related lines + clean_lines <- lines[!grepl("^OJO_", lines)] - # If a config already exists, and we don't want to overwrite it: - } else { - tv <- readLines(renv) - if (.admin) { - if (any(grepl("OJO_ADMIN_USER", tv))) { - stop("An OJO_ADMIN_USER already exists. You can overwrite it with the argument `.overwrite = TRUE`", call. = F) - } - } else { - if (any(grepl("OJO_DEFAULT_USER", tv))) { - stop("An OJO_DEFAULT_USER already exists. You can overwrite it with the argument `.overwrite = TRUE`", call. = FALSE) - } - } + # Write new configuration + new_vars <- c( + clean_lines, + paste0("OJO_DRIVER='", db_config$driver, "'"), + paste0("OJO_DATABASE='", db_config$database, "'"), + paste0("OJO_HOST='", db_config$host, "'"), + paste0("OJO_PORT='", db_config$port, "'"), + paste0("OJO_USER='", db_config$username, "'"), + paste0("OJO_PASS='", db_config$password, "'"), + paste0("OJO_SSL_MODE='", db_config$ssl_mode, "'"), + paste0("OJO_SSL_ROOT_CERT='", db_config$ssl_root_cert, "'"), + paste0("OJO_SSL_CERT='", db_config$ssl_cert, "'"), + paste0("OJO_SSL_KEY='", db_config$ssl_key, "'") + ) + writeLines(new_vars, renv) + } else if (!.overwrite && file.exists(renv)) { + # Check for conflicts + lines <- readLines(renv) + if (any(grepl("OJO_USER", lines))) { + stop( + "Configuration already exists. Use .overwrite = TRUE to replace it.", + call. = FALSE + ) } - } - # Fill out .Renviron with new arguments - hostconcat <- paste0("OJO_HOST='", host, "'") - portconcat <- paste0("OJO_PORT='", port, "'") - if (.admin) { - userconcat <- paste0("OJO_ADMIN_USER='", username, "'") - passconcat <- paste0("OJO_ADMIN_PASS='", password, "'") + # Append new configuration + new_vars <- c( + paste0("OJO_DRIVER='", db_config$driver, "'"), + paste0("OJO_DATABASE='", db_config$database, "'"), + paste0("OJO_HOST='", db_config$host, "'"), + paste0("OJO_PORT='", db_config$port, "'"), + paste0("OJO_USER='", db_config$username, "'"), + paste0("OJO_PASS='", db_config$password, "'"), + paste0("OJO_SSL_MODE='", db_config$ssl_mode, "'"), + paste0("OJO_SSL_ROOT_CERT='", db_config$ssl_root_cert, "'"), + paste0("OJO_SSL_CERT='", db_config$ssl_cert, "'"), + paste0("OJO_SSL_KEY='", db_config$ssl_key, "'") + ) + cat(new_vars, file = renv, sep = "\n", append = TRUE) } else { - userconcat <- paste0("OJO_DEFAULT_USER='", username, "'") - passconcat <- paste0("OJO_DEFAULT_PASS='", password, "'") + # New .Renviron file + new_vars <- c( + paste0("OJO_DRIVER='", db_config$driver, "'"), + paste0("OJO_DATABASE='", db_config$database, "'"), + paste0("OJO_HOST='", db_config$host, "'"), + paste0("OJO_PORT='", db_config$port, "'"), + paste0("OJO_USER='", db_config$username, "'"), + paste0("OJO_PASS='", db_config$password, "'"), + paste0("OJO_SSL_MODE='", db_config$ssl_mode, "'"), + paste0("OJO_SSL_ROOT_CERT='", db_config$ssl_root_cert, "'"), + paste0("OJO_SSL_CERT='", db_config$ssl_cert, "'"), + paste0("OJO_SSL_KEY='", db_config$ssl_key, "'") + ) + writeLines(new_vars, renv) } - sslmodeconcat <- paste0("OJO_SSL_MODE='verify-ca'") - rootcertconcat <- paste0("OJO_SSL_ROOT_CERT='", rootcert, "'") - clientcertconcat <- paste0("OJO_SSL_CERT='", clientcert, "'") - clientkeyconcat <- paste0("OJO_SSL_KEY='", clientkey, "'") - write(hostconcat, renv, sep = "\n", append = TRUE) - write(portconcat, renv, sep = "\n", append = TRUE) - write(userconcat, renv, sep = "\n", append = TRUE) - write(passconcat, renv, sep = "\n", append = TRUE) - write(sslmodeconcat, renv, sep = "\n", append = TRUE) - write(rootcertconcat, renv, sep = "\n", append = TRUE) - write(clientcertconcat, renv, sep = "\n", append = TRUE) - write(clientkeyconcat, renv, sep = "\n", append = TRUE) - cli::cli_alert_success('Your configuration has been stored in your .Renviron. - To use now, restart R or run `readRenviron("~/.Renviron")`') - invisible() - - # If .install = FALSE... + cat("Configuration saved to .Renviron\n") + cat("To use now, restart R or run readRenviron('~/.Renviron')\n") } else { - cli::cli_alert_info("To install your configuration for use in future sessions, run this function with `.install = TRUE`.") - - # ...set up local environment, but don't save to .Renviron - Sys.setenv(OJO_HOST = host) - Sys.setenv(OJO_PORT = port) - if (.admin == T) { - Sys.setenv(OJO_ADMIN_USER = username) - Sys.setenv(OJO_ADMIN_PASS = password) - } else { - Sys.setenv(OJO_DEFAULT_USER = username) - Sys.setenv(OJO_DEFAULT_PASS = password) - } - Sys.setenv(OJO_SSL_MODE = "verify-ca") - Sys.setenv(OJO_SSL_ROOT_CERT = rootcert) - Sys.setenv(OJO_SSL_CERT = clientcert) - Sys.setenv(OJO_SSL_KEY = clientkey) + # Set environment variables for current session only + Sys.setenv(OJO_DRIVER = db_config$driver) + Sys.setenv(OJO_DATABASE = db_config$database) + Sys.setenv(OJO_HOST = db_config$host) + Sys.setenv(OJO_PORT = db_config$port) + Sys.setenv(OJO_USER = db_config$username) + Sys.setenv(OJO_PASS = db_config$password) + Sys.setenv(OJO_SSL_MODE = db_config$ssl_mode) + Sys.setenv(OJO_SSL_ROOT_CERT = db_config$ssl_root_cert) + Sys.setenv(OJO_SSL_CERT = db_config$ssl_cert) + Sys.setenv(OJO_SSL_KEY = db_config$ssl_key) + + cat("Configuration set for current session only\n") + cat("To persist configuration, run with .install = TRUE\n") } + invisible() } diff --git a/R/ojo_case_types.R b/R/ojo_case_types.R index 7ff23b22..752f0624 100644 --- a/R/ojo_case_types.R +++ b/R/ojo_case_types.R @@ -2,6 +2,8 @@ #' #' Returns a tibble containing all the case types present in the OJO database #' +#' @param con The OJO database connection to use. +#' #' @export ojo_case_types #' @return Tibble of case types #' @examples @@ -10,8 +12,8 @@ #' ojo_case_types() #'} #' -ojo_case_types <- function() { - ojo_tbl("case") |> - dplyr::count(.data$case_type, sort = T) |> +ojo_case_types <- function(con = NULL) { + ojo_tbl("case", con = con) |> + dplyr::count(case_type, sort = T) |> dplyr::collect() } diff --git a/R/ojo_check_ssl.R b/R/ojo_check_ssl.R index 90f72f1c..bda4d007 100644 --- a/R/ojo_check_ssl.R +++ b/R/ojo_check_ssl.R @@ -2,23 +2,27 @@ #' #' @export ojo_check_ssl #' -#' @param ... Placeholder -#' @param .con The ojodb connection to use +#' @param con The ojodb connection to use #' #' @return A logical indicator of whether the db connection is properly using SSL #' @examples #' \dontrun{ -#' # Identifies the table +#' # Check SSL status for the default connection #' ojo_check_ssl() #' } #' -ojo_check_ssl <- function(..., .con = NULL) { - if (is.null(.con)) { - .con <- ojo_connect() +ojo_check_ssl <- function(con = NULL) { + if (is.null(con)) { + con <- ojo_default_connection() + } + + if (inherits(con, "Pool")) { + pool_src <- pool::poolCheckout(con) + on.exit(pool::poolReturn(pool_src), add = TRUE) + } else { + pool_src <- con } - pool_src <- pool::poolCheckout(.con) - on.exit(pool::poolReturn(pool_src)) pool_src |> - pool::dbGetQuery("select * from pg_stat_ssl where pid = pg_backend_pid();") + DBI::dbGetQuery("select * from pg_stat_ssl where pid = pg_backend_pid();") } diff --git a/R/ojo_civ_cases.R b/R/ojo_civ_cases.R index b37da2f4..b00c192b 100644 --- a/R/ojo_civ_cases.R +++ b/R/ojo_civ_cases.R @@ -6,6 +6,7 @@ #' @param vars A character vector of variables to return #' @param case_types A character vector of case types to query #' @param file_years A character vector of years to query +#' @param con The OJO database connection to use #' @param ... Placeholder for additional arguments #' #' @export ojo_civ_cases ojo_add_issues @@ -19,27 +20,33 @@ #' ojo_civ_cases(vars = c("updated_at", "created_at")) #' } #' -ojo_civ_cases <- function(districts = "all", vars = NULL, case_types = c("CS", "SC", "CJ"), - file_years = lubridate::year(Sys.Date()), ...) { - +ojo_civ_cases <- function( + districts = "all", + vars = NULL, + case_types = c("CS", "SC", "CJ"), + file_years = lubridate::year(Sys.Date()), + con = NULL, + ... +) { case_types_upper <- toupper(case_types) if (!all(case_types_upper %in% c("CS", "SC", "CJ"))) { - stop("The 'case_types' argument must only include 'CS', 'SC', or 'CJ' cases.") + stop( + "The 'case_types' argument must only include 'CS', 'SC', or 'CJ' cases." + ) } - data <- ojo_tbl("case") |> + data <- ojo_tbl("case", con = con) |> dplyr::filter( - .data$case_type %in% case_types_upper, - .data$year %in% file_years + case_type %in% case_types_upper, + year %in% file_years ) if (all(districts != "all")) { - districts_upper <- toupper(districts) data <- data |> - dplyr::filter(.data$district %in% districts_upper) + dplyr::filter(district %in% districts_upper) } selection <- c("id", "district", "case_type", "date_filed", "date_closed") @@ -47,12 +54,12 @@ ojo_civ_cases <- function(districts = "all", vars = NULL, case_types = c("CS", " if (is.null(vars)) { data <- data |> dplyr::select(dplyr::all_of(selection)) |> - ojo_add_issues() + ojo_add_issues(con = con) return(data) } else { if (any(vars == "all")) { data <- data |> - ojo_add_issues() + ojo_add_issues(con = con) return(data) } else { selection <- append(selection, vars) |> @@ -60,7 +67,7 @@ ojo_civ_cases <- function(districts = "all", vars = NULL, case_types = c("CS", " data <- data |> dplyr::select(dplyr::all_of(selection)) |> - ojo_add_issues() + ojo_add_issues(con = con) return(data) } @@ -73,6 +80,7 @@ ojo_civ_cases <- function(districts = "all", vars = NULL, case_types = c("CS", " #' #' @param data A lazy tibble of civil cases #' @param vars A character vector of variables to return +#' @param con The OJO database connection to use #' @param ... Placeholder for additional arguments #' #' @export ojo_add_issues @@ -84,19 +92,23 @@ ojo_civ_cases <- function(districts = "all", vars = NULL, case_types = c("CS", " #' ojo_add_issues() #' } #' -ojo_add_issues <- function(data, vars = NULL, ...) { +ojo_add_issues <- function(data, vars = NULL, con = NULL, ...) { if (!inherits(data, "tbl_lazy")) { stop("Don't use `collect()` before this function") } columns <- colnames(data) - issues <- ojo_tbl("issue") + issues <- ojo_tbl("issue", con = con) if (is.null(vars)) { issues <- issues |> dplyr::select( - case_id, rank, description, disposition, disposition_date + case_id, + rank, + description, + disposition, + disposition_date ) } else { if (vars != "all") { @@ -107,10 +119,7 @@ ojo_add_issues <- function(data, vars = NULL, ...) { } data <- data |> - dplyr::left_join(issues, - by = c("id" = "case_id"), - suffix = c("", ".issue") - ) + dplyr::left_join(issues, by = c("id" = "case_id"), suffix = c("", ".issue")) return(data) } diff --git a/R/ojo_collect.R b/R/ojo_collect.R index 2ffbdc7d..49961ae1 100644 --- a/R/ojo_collect.R +++ b/R/ojo_collect.R @@ -4,9 +4,8 @@ #' A wrapper for dplyr::collect() that includes a progress bar #' Pull a lazy tibble into your local environment from the OJO database, using `ojo_crim_cases()`, `ojo_civ_cases()`, etc. #' -#' @param .data The lazy tibble to be pulled -#' @param ... Placeholder for additional arguments -#' @param .silent Should the command line interface elements be shown? Defaults to `TRUE` if the user is in an interactive session, and `FALSE` if they are not. +#' @param data The lazy tibble to be pulled +#' @param silent Should the command line interface elements be shown? Defaults to `TRUE` if the user is in an interactive session, and `FALSE` if they are not. #' #' @export ojo_collect #' @@ -21,102 +20,116 @@ #' ojo_collect() #' } #' -ojo_collect <- function(.data, ..., .silent = !rlang::is_interactive()) { - - # Check class of `.data` +ojo_collect <- function(data, silent = !rlang::is_interactive()) { + # Check class of `data` # First, check if it's an arrow connection; this won't work with the rest of ojo_collect() - if (inherits(.data, "ArrowTabular")) { - + if (inherits(data, "ArrowTabular")) { # Display CLI cli::cli_div( theme = list( - rule = list(color = "br_yellow", - "line-type" = "single"), + rule = list(color = "br_yellow", "line-type" = "single"), "span.grayed" = list(color = "grey") ) ) - cli::cli_rule(left = paste("Connection: OJO GCS Arrow Tables"), - right = "{.emph ojodb {utils::packageVersion('ojodb')}}") - - return(dplyr::collect(.data)) + cli::cli_rule( + left = paste("Connection: OJO GCS Arrow Tables"), + right = "{.emph ojodb {utils::packageVersion('ojodb')}}" + ) + return(dplyr::collect(data)) } # Otherwise, make sure it's a postgres / duckdb connection - if (!inherits(.data, c("tbl_lazy", "tbl_Pool", "tbl_dbi"))) { - rlang::abort("`.data` must be a lazy tibble created with `ojo_connect()`, `pool::dbPool()`, or `DBI::dbConnect()`.") + if (!inherits(data, c("tbl_lazy", "tbl_Pool", "tbl_dbi"))) { + rlang::abort( + "`data` must be a lazy tibble created with `ojo_connect()`, `pool::dbPool()`, or `DBI::dbConnect()`." + ) } # Extract the database connection - .con <- dbplyr::remote_con(.data) - if (inherits(.data, "tbl_Pool")) { - .con <- pool::localCheckout(.con) + con <- dbplyr::remote_con(data) + if (inherits(data, "tbl_Pool")) { + con <- pool::localCheckout(con) } # Ensure the connection is valid - if (!DBI::dbIsValid(.con)) { - rlang::abort("The connection to the OJO database is no longer valid. Please reconnect to the database using `ojo_connect()`.") + if (!DBI::dbIsValid(con)) { + rlang::abort( + "The connection to the OJO database is no longer valid. Please reconnect to the database using `ojo_connect()`." + ) } # CLI output - if (!.silent) { - con_desc <- dbplyr::db_connection_describe(.con) |> + if (!silent) { + con_desc <- dbplyr::db_connection_describe(con) |> gsub(pattern = "postgres", replacement = "") |> trimws() # cli styling rules cli::cli_div( theme = list( - rule = list(color = "br_yellow", - "line-type" = "single"), + rule = list(color = "br_yellow", "line-type" = "single"), "span.grayed" = list(color = "grey") ) ) - cli::cli_rule(left = paste("Connection:", con_desc), - right = "{.emph ojodb {utils::packageVersion('ojodb')}}") + cli::cli_rule( + left = paste("Connection:", con_desc), + right = "{.emph ojodb {utils::packageVersion('ojodb')}}" + ) - cli::cli_progress_step(msg = "Searching OJO database for matching results...", - msg_failed = "Something went wrong sending your query to the database! Please check your connection.") + cli::cli_progress_step( + msg = "Searching OJO database for matching results...", + msg_failed = "Something went wrong sending your query to the database! Please check your connection." + ) } # Estimate number of results and warn about potential issues - n_results <- estimate_results(.data, .silent) + n_results <- estimate_results(data, silent) # Collect data - res <- collect_data(.con, .data, n_results, .silent) + res <- collect_data(con, data, n_results, silent) return(res) } -estimate_results <- function(.data, .silent) { - if ("n" %in% names(.data)) { - rlang::warn("The tbl you are requesting has a variable named `n`. This might cause issues with progress bar rendering.", - .frequency = "once", .frequency_id = "ojo_collect_n_warning") +estimate_results <- function(data, silent) { + if ("n" %in% colnames(data)) { + rlang::warn( + "The tbl you are requesting has a variable named `n`. This might cause issues with progress bar rendering.", + .frequency = "once", + .frequency_id = "ojo_collect_n_warning" + ) } - n_results <- .data |> + n_results <- data |> dplyr::ungroup() |> dplyr::tally() |> - dplyr::pull(n = n) - - if (!.silent) { - cli::cli_progress_step(msg = paste0("Found ", format(n_results, big.mark = ","), " matching results!")) + dplyr::pull(n) + + if (!silent) { + cli::cli_progress_step( + msg = paste0( + "Found ", + format(n_results, big.mark = ","), + " matching results!" + ) + ) } return(n_results) } -collect_data <- function(.con, .data, n_results, .silent) { - query <- dbplyr::sql_render(.data) - req <- DBI::dbSendQuery(.con, query) +collect_data <- function(con, data, n_results, silent) { + query <- dbplyr::sql_render(data) + req <- DBI::dbSendQuery(con, query) withr::defer(DBI::dbClearResult(req), envir = parent.frame()) res <- NULL chunk_size <- max(round(n_results / 100, 0), 1000) # Download in chunks of 1% or at least 1000 - if (!.silent) { + if (!silent) { init_progress_bar(n_results) } @@ -124,7 +137,7 @@ collect_data <- function(.con, .data, n_results, .silent) { chunk <- DBI::dbFetch(req, n = chunk_size) res <- rbind(res, tibble::as_tibble(chunk)) - if (!.silent) { + if (!silent) { cli::cli_progress_update(set = nrow(res)) } } diff --git a/R/ojo_colors.R b/R/ojo_colors.R deleted file mode 100644 index 8e11f3ce..00000000 --- a/R/ojo_colors.R +++ /dev/null @@ -1,72 +0,0 @@ -#' Add OJO styling to a ggplot -#' -#' Add OJO styling to a ggplot -#' -#' @export -#' -#' @examples -#' \dontrun{ -#' ggplot(ojo_example, aes(file_year, n_cases, color = court)) + -#' geom_line(size = 1.5) + -#' theme_ojo() + -#' ojo_color() + -#' scale_x_continuous( -#' breaks = 2010:2019, -#' limits = c(NA, 2019) -#' ) -#' } -#' -ojo_pal <- c( - "#F8D64E", "black", "#0D0887FF", "#6A00A8FF", - "#B12A90FF", "#E16462FF", "#FCA636FF", "#F0F921FF" -) - - -#' Add OJO styling to a ggplot -#' -#' Add OJO styling to a ggplot -#' -#' @param numbers A vector of numbers to use for the color palette -#' -#' @export -#' -#' @examples -#' \dontrun{ -#' ggplot(ojo_example, aes(file_year, n_cases, color = court)) + -#' geom_line(size = 1.5) + -#' theme_ojo() + -#' ojo_color() + -#' scale_x_continuous( -#' breaks = 2010:2019, -#' limits = c(NA, 2019) -#' ) -#' } -#' -ojo_color <- function(numbers = 1:8) { - ggplot2::scale_color_manual(values = ojo_pal[numbers]) -} - - -#' Add OJO styling to a ggplot -#' -#' Add OJO styling to a ggplot -#' -#' @param numbers A vector of numbers to use for the color palette -#' -#' @export -#' -#' @examples -#' \dontrun{ -#' ggplot(ojo_example, aes(file_year, n_cases, color = court)) + -#' geom_line(size = 1.5) + -#' theme_ojo() + -#' ojo_color() + -#' scale_x_continuous( -#' breaks = 2010:2019, -#' limits = c(NA, 2019) -#' ) -#' } -#' -ojo_fill <- function(numbers = 1:8) { - ggplot2::scale_fill_manual(values = ojo_pal[numbers]) -} diff --git a/R/ojo_connect.R b/R/ojo_connect.R index 699907bf..1a372404 100644 --- a/R/ojo_connect.R +++ b/R/ojo_connect.R @@ -1,139 +1,219 @@ -#' @title OJO Connect +#' @title Connect to an OJO Database Backend #' -#' @description Connect to the Open Justice Oklahoma database +#' @description Establishes a single connection to a database, such as the Open +#' Justice Oklahoma Postgres database or a local DuckDB instance. #' -#' @details -#' Opens a connection to the Open Justice Oklahoma database using credentials stored in the .Renviron file. -#' If no credentials exist, prompts for user, password, and host name and provides instructions to store them for future sessions. +#' @details This function serves as the primary way to create individual database +#' connection objects. It is designed to be side-effect-free, returning the +#' connection object directly to the user for manual management. For creating +#' a managed pool of connections suitable for applications, use `ojo_pool()`. +#' +#' @param db_config A `db_config` object created by `db_config()`. If `NULL` (the +#' default), a default configuration is created by calling `db_config()` with +#' no arguments, which will pull from environment variables. +#' @param ... Placeholder for future arguments. #' -#' @param .admin A logical value indicating whether to connect to the database as an administrator. -#' @param ... Placeholder. -#' @param .driver The driver to use for the connection. Default is "RPostgres". "duckdb" is also supported. -#' @param .global Deprecated. A connection will always be created in the specified environment, or in the package environment by default. -#' @param .env The environment in which you want the connection stored. -#' @param .pool A logical value indicating whether to use a connection pool from the `{pool}` package, or not. +#' @return A database connection object (e.g., a `PqConnection` or `duckdb_connection`). #' #' @export -#' @returns A database connection object created with `RPostgres::Postgres()` and either `pool::dbPool` or `DBI::dbConnect` #' #' @examples #' \dontrun{ -#' ojo_connect() -#' } -#' @section Side Effects: -#' A connection object (named `ojo_con` or `ojo_pool` depending on the `.pool` argument) is created in the package environment. +#' # --- Manual Connection Management --- +#' +#' # 1. Create a configuration +#' my_config <- db_config(.admin = TRUE) +#' +#' # 2. Create a connection object +#' con <- ojo_connect(db_config = my_config) #' -#' @seealso ojo_auth() +#' # 3. Use the connection with dplyr or DBI +#' dplyr::tbl(con, "case") +#' DBI::dbListTables(con) #' -ojo_connect <- function(..., .admin = FALSE, .driver = "RPostgres", .global = lifecycle::deprecated(), .env = ojo_env(), .pool = FALSE) { +#' # 4. Close the connection when finished +#' DBI::dbDisconnect(con) +#' } +#' @seealso [db_config()] for creating configuration objects, and [ojo_pool()] +#' for creating a connection pool. +ojo_connect <- function(db_config = NULL, ...) { + # If no config is provided, create a default one. + if (is.null(db_config)) { + db_config <- db_config() + } - if (lifecycle::is_present(.global)) { - lifecycle::deprecate_warn( - when = "2.8.0", - what = "ojo_connect(.global)" + # Ensure the config object is valid + if (!inherits(db_config, "db_config")) { + rlang::abort( + "`db_config` must be a `db_config` object created by `db_config()`." ) } - user_type <- if (.admin) "ADMIN" else "DEFAULT" - - if (Sys.getenv("OJO_HOST") == "" && .driver == "RPostgres") { + if (is.null(db_config$driver) || db_config$driver == "") { rlang::abort( - "No {tolower(user_type)} configuration for the OJO database was found. Please create one now using `ojo_auth`, or manually, by adding the necessary environment variables with `usethis::edit_r_environ`.", - use_cli_format = TRUE + c( + "Database configuration is missing required parameters.", + "i" = "Missing: driver", + "*" = "Please set it with `ojo_auth()` or in your `db_config()` call." + ) ) } - connection_type <- if (.pool) "ojo_pool" else "ojo_con" - connection_key <- paste0(connection_type, "_", .driver) + # Dispatch to the appropriate backend-specific connection function + connection_function <- switch( + db_config$driver, + "RPostgres" = .connect_postgres, + "duckdb" = .connect_duckdb, + "RSQLite" = .connect_sqlite, + rlang::abort(glue::glue( + "The driver '{db_config$driver}' is not supported." + )) + ) + + # Call the selected connection function + connection_function(db_config, ...) +} - # Check if a valid connection object already exists in the environment - existing_conn <- get_connection_object(.env, connection_key) - if (!is.null(existing_conn) && DBI::dbIsValid(existing_conn)) { - return(existing_conn) +#' Internal function to connect to Postgres +#' @keywords internal +.connect_postgres <- function(db_config, ...) { + # Check for required parameters + required <- c("database", "host", "port", "username", "password", "ssl_mode") + missing_params <- setdiff(required, names(db_config)) + if (length(missing_params) > 0) { + rlang::abort( + c( + "Postgres connection is missing required configuration parameters.", + "i" = glue::glue("Missing: {paste(missing_params, collapse = ', ')}"), + "*" = "Please set them with `ojo_auth()` or in your `db_config()` call." + ) + ) } - # Set the driver and connection arguments - conn_args <- switch( - .driver, - "RPostgres" = list( - drv = RPostgres::Postgres(), - dbname = "ojodb", - host = Sys.getenv("OJO_HOST"), - port = Sys.getenv("OJO_PORT"), - user = Sys.getenv(glue::glue("OJO_{user_type}_USER")), - password = Sys.getenv(glue::glue("OJO_{user_type}_PASS")), - sslmode = Sys.getenv("OJO_SSL_MODE"), - sslrootcert = Sys.getenv("OJO_SSL_ROOT_CERT"), - sslcert = Sys.getenv("OJO_SSL_CERT"), - sslkey = Sys.getenv("OJO_SSL_KEY"), - bigint = "integer", - check_interrupts = TRUE, - ... - ), - "duckdb" = list( - drv = duckdb::duckdb(), - ... - ), - rlang::abort("Unsupported driver: {.driver}") + # Assemble the arguments for the connection function + conn_args <- list( + drv = RPostgres::Postgres(), + dbname = db_config$database, + host = db_config$host, + port = as.integer(db_config$port), + user = db_config$username, + password = db_config$password, + sslmode = db_config$ssl_mode, + sslrootcert = db_config$ssl_root_cert, + sslcert = db_config$ssl_cert, + sslkey = db_config$ssl_key, + bigint = "integer", + check_interrupts = TRUE ) - conn_fn <- switch( - connection_type, - ojo_pool = pool::dbPool, - ojo_con = DBI::dbConnect + # Create the connection + rlang::exec(DBI::dbConnect, !!!conn_args) +} + +#' Internal function to connect to DuckDB +#' @keywords internal +.connect_duckdb <- function(db_config, ...) { + # Establish the connection (in-memory by default) + con <- DBI::dbConnect(duckdb::duckdb(), dbdir = ":memory:") + + # Install and load necessary extensions for accessing remote data + tryCatch( + { + DBI::dbExecute(con, "INSTALL httpfs; LOAD httpfs;") + DBI::dbExecute(con, "SET s3_endpoint='storage.googleapis.com';") + }, + error = function(e) { + rlang::warn(c( + "Failed to install or configure DuckDB extensions.", + "i" = "Accessing remote data (e.g., from GCS) may not work.", + "x" = e$message + )) + } ) - new_conn <- rlang::exec(conn_fn, !!!conn_args) - assign(connection_key, new_conn, envir = .env) + con +} - # Make sure duckdb instance has needed features - if (.driver == "duckdb") { - DBI::dbExecute( - new_conn, - stringr::str_glue("INSTALL httpfs; LOAD httpfs; SET s3_endpoint='storage.googleapis.com';") +#' Internal function to connect to SQLite +#' @keywords internal +.connect_sqlite <- function(db_config, ...) { + # For SQLite, the 'host' is treated as the file path. + if (is.null(db_config$host) || db_config$host == "") { + rlang::abort( + c( + "SQLite config is missing the database file path.", + "i" = "Please provide the path in the `host` argument of `db_config()`." + ) ) } - withr::defer({ - if (exists(connection_key, envir = .env)) { - connection_object <- get(connection_key, envir = .env, inherits = FALSE) - if (.pool) { - pool::poolClose(connection_object) - } else { - DBI::dbDisconnect(connection_object) - } - rm(list = connection_key, envir = .env) - } - }, envir = .env) - - return(new_conn) + DBI::dbConnect(RSQLite::SQLite(), dbname = db_config$host) } -#' @title Get Connection Object +#' @title Get or create the default OJO database connection #' -#' @description -#' Gets the connection object from the environment specified by the `.env` argument. +#' @description This internal function manages a single, default connection object +#' stored in the package's private environment (`.ojo_env`). It is the +#' cornerstone of the interactive user experience, ensuring all default +#' database operations for a given backend share the same connection. #' -#' @param env The environment to search for the connection object. +#' @details +#' This function accepts arguments (...) that are passed to `ojo_connect()`, +#' allowing it to manage distinct default connections for different backends. #' -#' @keywords internal +#' The first time this function is called with a unique configuration, it will: +#' 1. Call `ojo_connect()` with the provided arguments. +#' 2. Store the connection in the `.ojo_env` environment under a unique key. +#' 3. Use `withr::defer()` to register a cleanup handler for that connection. #' -get_connection_object <- function(env, key) { - connection_object_exists <- exists( - key, - envir = env, - inherits = FALSE - ) +#' @param ... Arguments to pass to `ojo_connect()`, primarily a `db_config` object +#' to specify the backend (e.g., `db_config = db_config(.driver = "duckdb")`). +#' +#' @return A valid database connection object. +#' @keywords internal +ojo_default_connection <- function(...) { + # Capture the arguments to create a unique fingerprint for the connection type. + args <- list(...) + connection_key <- paste0("default_con_", digest::digest(args)) - if (!connection_object_exists) { - return(NULL) + # Check if a valid connection for this specific configuration already exists + if (exists(connection_key, envir = .ojo_env)) { + con <- get(connection_key, envir = .ojo_env) + if (DBI::dbIsValid(con)) { + return(con) + } } - connection_object <- get( - key, - envir = env, - inherits = FALSE + # If no valid connection exists, create a new one. + cli::cli_inform( + c( + "i" = "Creating a new default connection to the OJO database.", + "*" = "This connection will be closed automatically when your R session ends." + ), + .frequency = "once", + .frequency_id = "ojo_connect_inform" + ) + + new_con <- rlang::exec(ojo_connect, !!!args) + + # Store the new connection in the package environment under its unique key. + assign(connection_key, new_con, envir = .ojo_env) + + # Register a deferred event in the global environment to ensure cleanup + # happens when the user's session ends. + withr::defer( + { + if (exists(connection_key, envir = .ojo_env)) { + con_to_close <- get(connection_key, envir = .ojo_env) + if (DBI::dbIsValid(con_to_close)) { + # This handles single connections only now + DBI::dbDisconnect(con_to_close) + } + rm(list = connection_key, envir = .ojo_env) + } + }, + envir = globalenv() ) - return(connection_object) + return(new_con) } diff --git a/R/ojo_connection_string.R b/R/ojo_connection_string.R new file mode 100644 index 00000000..8ff757c4 --- /dev/null +++ b/R/ojo_connection_string.R @@ -0,0 +1,143 @@ +#' @title Create a Database Connection String +#' +#' @description Generates a standard connection string URI for various database +#' backends from an `ojo_db` configuration object. +#' +#' @details This function is useful for applications or libraries that require a +#' connection URI. It intelligently constructs the appropriate string based on +#' the driver specified in the configuration. +#' +#' - For **PostgreSQL** (`RPostgres`), it creates a `postgresql://` URI. +#' - For **DuckDB** (`duckdb`), it creates a `duckdb://` URI, treating the +#' `host` field in the config as the path to the database file. +#' +#' @param config A `db_config` object created by `db_config()`. If `NULL` (the +#' default), a default configuration is created by calling `db_config()`, +#' which will pull from environment variables. +#' +#' @return A single character string representing the database connection URI. +#' @export +#' +#' @examples +#' \dontrun{ +#' # Create a PostgreSQL connection string from environment variables +#' ojo_connection_string(config = db_config(driver = "RPostgres")) +#' +#' # Create a connection string for a local SQLite database file +#' duckdb_config <- db_config(driver = "duckdb", host = "/path/to/db.duckdb") +#' ojo_connection_string(config = duckdb_config) +#' } +ojo_connection_string <- function(config = NULL) { + # If no config is provided, create a default one. + if (is.null(config)) { + config <- db_config() + } + + # Ensure the config object is valid + if (!inherits(config, "db_config")) { + rlang::abort( + "`config` must be a `db_config` object created by `db_config()`." + ) + } + + # Dispatch to the appropriate internal function based on the driver + connection_string_builder <- switch( + config$driver, + "RPostgres" = .create_postgres_string, + "RSQLite" = .create_sqlite_string, + "duckdb" = .create_duckdb_string, + rlang::abort(glue::glue( + "Connection string generation is not supported for the driver: '{config$driver}'." + )) + ) + + # TODO: Return invisibly unless arg switch flipped + connection_string_builder(config) +} + +#' @title Create Postgres Connection String +#' @description Creates a PostgreSQL connection string. +#' @keywords internal +.create_postgres_string <- function(config) { + # Check for required parameters + required <- c("host", "port", "username", "password") + missing_params <- setdiff(required, names(config)) + if (length(missing_params) > 0) { + rlang::abort( + c( + "PostgreSQL config is missing required parameters to build a connection string.", + "i" = glue::glue("Missing: {paste(missing_params, collapse = ', ')}") + ) + ) + } + + # Build the base URI + base_uri <- glue::glue( + "postgresql://{config$username}:{config$password}@{config$host}:{config$port}/ojodb" + ) + + # Identify and collect query parameters (e.g., for SSL) + param_keys <- c("sslmode", "sslrootcert", "sslcert", "sslkey") + + # Rename keys from db_config to match connection string params + names(config)[names(config) == "ssl_mode"] <- "sslmode" + names(config)[names(config) == "ssl_root_cert"] <- "sslrootcert" + names(config)[names(config) == "ssl_cert"] <- "sslcert" + names(config)[names(config) == "ssl_key"] <- "sslkey" + + query_params <- config[names(config) %in% param_keys] + + # Filter out any NULL or empty parameters + query_params <- query_params[!sapply(query_params, is.null)] + query_params <- query_params[query_params != ""] + + # If there are any parameters, format them into a query string + if (length(query_params) > 0) { + query_string <- paste( + names(query_params), + query_params, + sep = "=", + collapse = "&" + ) + return(paste0(base_uri, "?", query_string)) + } else { + return(base_uri) + } +} + +#' @title Create DuckDB Connection String +#' @description Creates a DuckDB connection string. +#' @keywords internal +.create_duckdb_string <- function(config) { + # For DuckDB, the connection string is simply the path to the database file. + # An empty path signifies an in-memory database. + if (is.null(config$host)) { + rlang::abort( + c( + "DuckDB config is missing the database file path.", + "i" = "Please provide the path in the `host` argument of `db_config()`. For an in-memory database, use an empty string `''`." + ) + ) + } + + # For DuckDB, the connection string is just the path. + return(config$host) +} + +#' @title Create SQLite Connection String +#' @description Creates a SQLite connection string. +#' @keywords internal +.create_sqlite_string <- function(config) { + # For SQLite, the 'host' is treated as the file path. + if (is.null(config$host) || config$host == "") { + rlang::abort( + c( + "SQLite config is missing the database file path.", + "i" = "Please provide the path in the `host` argument of `db_config()`." + ) + ) + } + + # The connection string for SQLite is the protocol followed by the path. + glue::glue("sqlite://{config$host}") +} diff --git a/R/ojo_county_population.R b/R/ojo_county_population.R index 2aaf90f0..07ab37c4 100644 --- a/R/ojo_county_population.R +++ b/R/ojo_county_population.R @@ -23,19 +23,19 @@ ojo_county_population <- function(years) { geography = "county", state = "OK" ) |> - dplyr::filter(.data$variable == "POP") |> + dplyr::filter(variable == "POP") |> dplyr::mutate(year = y) |> dplyr::bind_rows(d) } d <- d |> dplyr::mutate( - court = .data$NAME |> + court = NAME |> stringi::stri_replace_all_regex(" County.*", "") |> stringi::stri_trans_toupper() |> stringi::stri_replace_all_regex(" ", "") ) |> - dplyr::select(.data$court, .data$year, pop = .data$value) + dplyr::select(court, year, pop = value) return(d) } diff --git a/R/ojo_crim_cases.R b/R/ojo_crim_cases.R index 2f797d8d..11935da1 100644 --- a/R/ojo_crim_cases.R +++ b/R/ojo_crim_cases.R @@ -6,6 +6,7 @@ #' @param vars A character vector of variables to return #' @param case_types A character vector of case types to query #' @param file_years A character vector of years to query +#' @param con The OJO database connection to use #' @param ... Placeholder for additional arguments #' #' @export ojo_crim_cases @@ -18,41 +19,56 @@ #' ojo_crim_cases(vars = c("updated_at", "created_at")) #' } #' -ojo_crim_cases <- function(districts = "all", vars = NULL, case_types = c("CM", "CF", "TR"), - file_years = 2000:lubridate::year(Sys.Date()), ...) { - +ojo_crim_cases <- function( + districts = "all", + vars = NULL, + case_types = c("CM", "CF", "TR"), + file_years = 2000:lubridate::year(Sys.Date()), + con = NULL, + ... +) { case_types_upper <- toupper(case_types) if (!all(case_types_upper %in% c("CM", "CF", "TR"))) { - stop("The 'case_types' argument must only include 'CM', 'CF', or 'TR' cases.") + stop( + "The 'case_types' argument must only include 'CM', 'CF', or 'TR' cases." + ) } - data <- ojo_tbl("case") |> + data <- ojo_tbl("case", con = con) |> dplyr::filter( # `upper()` is evaluated in SQL; debug and use `show_query()` to verify - .data$case_type %in% case_types_upper, - .data$year %in% file_years + case_type %in% case_types_upper, + year %in% file_years ) if (all(districts != "all")) { - districts_upper <- toupper(districts) data <- data |> - dplyr::filter(.data$district %in% districts_upper) + dplyr::filter(district %in% districts_upper) } - selection <- c("id", "district", "case_number", "case_type", "date_filed", "date_closed", "counts", "open_counts") + selection <- c( + "id", + "district", + "case_number", + "case_type", + "date_filed", + "date_closed", + "counts", + "open_counts" + ) if (is.null(vars)) { data <- data |> dplyr::select(dplyr::all_of(selection)) |> - ojo_add_counts() + ojo_add_counts(con = con) return(data) } else { if (any(vars == "all")) { data <- data |> - ojo_add_counts() + ojo_add_counts(con = con) return(data) } else { selection <- append(selection, vars) |> @@ -60,7 +76,7 @@ ojo_crim_cases <- function(districts = "all", vars = NULL, case_types = c("CM", data <- data |> dplyr::select(dplyr::all_of(selection)) |> - ojo_add_counts() + ojo_add_counts(con = con) return(data) } @@ -72,6 +88,7 @@ ojo_crim_cases <- function(districts = "all", vars = NULL, case_types = c("CM", #' #' @param data A tibble returned by an `ojo_` prefixed function #' @param vars Variable names from the `count` table to include +#' @param con The OJO database connection to use #' @param ... Placeholder for future arguments #' #' @return A lazy tibble with counts for each case @@ -88,7 +105,7 @@ ojo_crim_cases <- function(districts = "all", vars = NULL, case_types = c("CM", #' ojo_add_counts() #'} #' -ojo_add_counts <- function(data, vars = NULL, ...) { +ojo_add_counts <- function(data, vars = NULL, con = NULL, ...) { if (!inherits(data, "tbl_lazy")) { stop("Don't use `collect()` before this function") } @@ -101,7 +118,7 @@ ojo_add_counts <- function(data, vars = NULL, ...) { counts_data <- data |> dplyr::left_join( - ojo_tbl("count"), + ojo_tbl("count", con = con), by = c("id" = "case_id"), suffix = c("", ".count") ) diff --git a/R/ojo_eviction_cases.R b/R/ojo_eviction_cases.R index 93ca910a..ec483a0a 100644 --- a/R/ojo_eviction_cases.R +++ b/R/ojo_eviction_cases.R @@ -16,6 +16,7 @@ #' @param more_case_variables Additional variables from case table to include in the output #' @param more_issue_variables Additional variables from issue table to include in the output #' @param get_judgments Logical value indicating whether to include eviction judgment information in the output +#' @param con The OJO database connection to use #' #' @importFrom dplyr filter select left_join mutate case_when #' @importFrom stringr str_detect @@ -41,19 +42,22 @@ #' } #' -ojo_eviction_cases <- function(districts = "all", - ..., - date_start = NULL, - date_end = NULL, - more_case_variables = NULL, - more_issue_variables = NULL, - get_judgments = TRUE) { +ojo_eviction_cases <- function( + districts = "all", + ..., + date_start = NULL, + date_end = NULL, + more_case_variables = NULL, + more_issue_variables = NULL, + get_judgments = TRUE, + con = NULL +) { #### Variable Handling .district <- toupper(districts) ##### Data Wrangling / Cleaning ## Construct Data - data <- ojodb::ojo_tbl("case") + data <- ojodb::ojo_tbl("case", con = con) if (!any(.district == "ALL")) { data <- data |> @@ -72,14 +76,21 @@ ojo_eviction_cases <- function(districts = "all", case_vars <- unique( c( - "id", "district", "date_filed", "date_closed", "status", + "id", + "district", + "date_filed", + "date_closed", + "status", more_case_variables ) ) issue_vars <- unique( c( - "id", "case_id", "description", "disposition", + "id", + "case_id", + "description", + "disposition", more_issue_variables ) ) @@ -88,7 +99,7 @@ ojo_eviction_cases <- function(districts = "all", dplyr::filter(case_type == "SC") |> dplyr::select(dplyr::all_of(case_vars)) |> dplyr::left_join( - ojodb::ojo_tbl("issue") |> + ojodb::ojo_tbl("issue", con = con) |> dplyr::select(dplyr::all_of(issue_vars)), by = c("id" = "case_id"), suffix = c(".case", ".issue") @@ -108,26 +119,33 @@ ojo_eviction_cases <- function(districts = "all", if (get_judgments == TRUE) { data <- data |> - dplyr::mutate(clean_disposition = case_when( - stringr::str_detect(disposition, "DISMISS") ~ "DISMISSED", - stringr::str_detect(disposition, "JUDGMENT|JUDGEMENT") ~ - case_when( - stringr::str_detect(disposition, "DEFAULT") ~ "DEFAULT JUDGMENT", - stringr::str_detect(disposition, "PLAINTIFF") ~ "JUDGMENT FOR PLAINTIFF", - stringr::str_detect(disposition, "DEFENDANT") ~ "JUDGMENT FOR DEFENDANT", - TRUE ~ "JUDGMENT ENTERED" - ), - stringr::str_detect(disposition, "ADVISEMENT") ~ "UNDER ADVISEMENT" - )) + dplyr::mutate( + clean_disposition = case_when( + stringr::str_detect(disposition, "DISMISS") ~ "DISMISSED", + stringr::str_detect(disposition, "JUDGMENT|JUDGEMENT") ~ + case_when( + stringr::str_detect(disposition, "DEFAULT") ~ "DEFAULT JUDGMENT", + stringr::str_detect(disposition, "PLAINTIFF") ~ + "JUDGMENT FOR PLAINTIFF", + stringr::str_detect(disposition, "DEFENDANT") ~ + "JUDGMENT FOR DEFENDANT", + TRUE ~ "JUDGMENT ENTERED" + ), + stringr::str_detect(disposition, "ADVISEMENT") ~ "UNDER ADVISEMENT" + ) + ) data <- data |> dplyr::mutate( judgment = dplyr::case_when( clean_disposition %in% - c("DEFAULT JUDGMENT", "JUDGMENT FOR PLAINTIFF") ~ "Eviction Granted", + c("DEFAULT JUDGMENT", "JUDGMENT FOR PLAINTIFF") ~ + "Eviction Granted", clean_disposition == "JUDGMENT FOR DEFENDANT" ~ "Eviction Denied", - clean_disposition == "JUDGMENT ENTERED" ~ "Case Decided, Outcome Unknown", - clean_disposition == "DISMISSED" ~ "Case Dismissed (Settled Outside Court)", + clean_disposition == "JUDGMENT ENTERED" ~ + "Case Decided, Outcome Unknown", + clean_disposition == "DISMISSED" ~ + "Case Dismissed (Settled Outside Court)", clean_disposition == "UNDER ADVISEMENT" ~ "Case Under Advisement", .default = "Case Undecided" ) diff --git a/R/ojo_fiscal_year.R b/R/ojo_fiscal_year.R index 29ccb52d..e5db4384 100644 --- a/R/ojo_fiscal_year.R +++ b/R/ojo_fiscal_year.R @@ -2,7 +2,7 @@ #' #' Returns the Oklahoma state fiscal year (July 1 - June 30) in which a given Date falls. #' -#' @param date An atomic value of class Date +#' @param date An atomic value of class Date #' #' @export ojo_fiscal_year #' @return Fiscal year of a Date as an integer diff --git a/R/ojo_list_schemas.R b/R/ojo_list_schemas.R index 039489b9..84883772 100644 --- a/R/ojo_list_schemas.R +++ b/R/ojo_list_schemas.R @@ -2,8 +2,7 @@ #' #' Query the Open Justice Oklahoma database for the names of all schemas #' -#' @param ... Placeholder -#' @param .con The OJO database connection to use +#' @param con The OJO database connection to use #' #' @export ojo_list_schemas #' @return data, a tibble containing the names of all schemas @@ -12,17 +11,17 @@ #' ojo_list_schemas() #' } #' -ojo_list_schemas <- function(..., .con = NULL) { - if (is.null(.con)) { - .con <- ojo_connect() +ojo_list_schemas <- function(con = NULL) { + if (is.null(con)) { + con <- ojo_default_connection() } ojo_query( "SELECT schema_name FROM information_schema.schemata", - .con = .con + con = con ) |> dplyr::rename(schema = schema_name) |> - dplyr::filter(!.data$schema %in% c("pg_catalog", "information_schema")) |> - dplyr::arrange(.data$schema) |> + dplyr::filter(!schema %in% c("pg_catalog", "information_schema")) |> + dplyr::arrange(schema) |> dplyr::collect() } diff --git a/R/ojo_list_tables.R b/R/ojo_list_tables.R index 11c34d58..a3c73728 100644 --- a/R/ojo_list_tables.R +++ b/R/ojo_list_tables.R @@ -3,8 +3,7 @@ #' Query the Open Justice Oklahoma database for the names of all tables #' #' @param schema The name of the schema to query -#' @param ... Placeholder for additional arguments -#' @param .con The OJO database connection to use +#' @param con The OJO database connection to use #' #' @export ojo_list_tables #' @return data, a tibble containing the names of all tables @@ -15,10 +14,9 @@ #' ojo_list_tables("iic") #' } #' -ojo_list_tables <- function(schema = "public", ..., .con = NULL) { - - if (is.null(.con)) { - .con <- ojo_connect() +ojo_list_tables <- function(schema = "public", con = NULL) { + if (is.null(con)) { + con <- ojo_default_connection() } query <- glue::glue_sql( @@ -26,16 +24,16 @@ ojo_list_tables <- function(schema = "public", ..., .con = NULL) { if (!schema == "all") { "WHERE table_schema = {schema}" }, - .con = .con + .con = con ) list_tables <- function(x) { query <- glue::glue_sql( "SELECT * FROM information_schema.tables WHERE table_schema = {x}", - .con = .con + .con = con ) - pool::dbGetQuery(.con, query) |> + pool::dbGetQuery(con, query) |> dplyr::as_tibble() |> dplyr::select(table = table_name) } @@ -45,7 +43,7 @@ ojo_list_tables <- function(schema = "public", ..., .con = NULL) { data <- schemas |> dplyr::mutate(table = purrr::map(schema, list_tables)) |> tidyr::unnest(cols = table) |> - dplyr::arrange(table) + dplyr::arrange(schema, table) return(data) } else { diff --git a/R/ojo_list_vars.R b/R/ojo_list_vars.R index 1bb7282e..4b0316a1 100644 --- a/R/ojo_list_vars.R +++ b/R/ojo_list_vars.R @@ -4,8 +4,7 @@ #' #' @param table The name of the table to query #' @param schema The name of the schema to query -#' @param ... Placeholder for additional arguments -#' @param .con The ojodb connection to use +#' @param con The ojodb connection to use #' #' @export ojo_list_vars #' @@ -17,15 +16,15 @@ #' ojo_list_vars("inmate", "iic") #' } #' -ojo_list_vars <- function(table, schema = "public", ..., .con = NULL) { +ojo_list_vars <- function(table, schema = "public", con = NULL) { ojo_tbl( table = "columns", schema = "information_schema", - .con = .con + con = con ) |> dplyr::filter( - .data$table_schema == schema, - .data$table_name == table + table_schema == schema, + table_name == table ) |> dplyr::select(column_name) |> collect() |> diff --git a/R/ojo_pool.R b/R/ojo_pool.R new file mode 100644 index 00000000..50122f76 --- /dev/null +++ b/R/ojo_pool.R @@ -0,0 +1,100 @@ +#' @title Create a Database Connection Pool +#' +#' @description Creates a managed pool of database connections using the \{pool\} +#' package. This is the recommended way to connect to the database for +#' applications with concurrent users, such as Shiny apps. +#' +#' @details A connection pool is more efficient than managing individual +#' connections in a concurrent environment. Instead of creating and tearing +#' down a new connection for each user or session, the pool maintains a set of +#' active connections that are "checked out" as needed and returned to the +#' pool when the operation is complete. +#' +#' This function is a dedicated wrapper around `ojo_connect()` that forces the +#' creation of a pool. +#' +#' **Important:** Remember to close the pool with `pool::poolClose(pool)` when +#' your application shuts down to release all database connections. +#' +#' @param config A `db_config` object created by `db_config()`. If `NULL` (the +#' default), a default configuration is created, which will pull from +#' environment variables. +#' +#' @param ... Additional arguments passed on to the underlying `pool::dbPool()` +#' function, such as `minSize`, `maxSize`, or `idleTimeout`. +#' +#' @return A `Pool` object. +#' @export +#' +#' @examples +#' \dontrun{ +#' # Create a connection pool using default settings +#' pool <- ojo_pool() +#' +#' # Use the pool with ojo_tbl or dplyr +#' ojo_tbl("case", con = pool) +#' +#' # When your application stops, close the pool +#' pool::poolClose(pool) +#' +#' # Create a pool for a specific backend (e.g., SQLite) +#' sqlite_config <- db_config(.driver = "RSQLite", host = "local.db") +#' sqlite_pool <- ojo_pool(config = sqlite_config) +#' pool::poolClose(sqlite_pool) +#' } +ojo_pool <- function(config = NULL, ...) { + # If no config is provided, create a default one. + if (is.null(config)) { + config <- db_config() + } + + # Ensure the config object is valid + if (!inherits(config, "db_config")) { + rlang::abort( + "`config` must be a `db_config` object created by `db_config()`." + ) + } + + # Check if the selected driver supports connection pooling + supported_drivers <- c("RPostgres", "RSQLite") + if (!config$driver %in% supported_drivers) { + rlang::abort( + glue::glue( + "Connection pooling is not supported for the '{config$driver}' driver." + ), + "i" = glue::glue( + "Supported drivers are: {paste(supported_drivers, collapse = ', ')}." + ) + ) + } + + # Assemble the arguments for the pool::dbPool function based on the driver. + # This logic is similar to ojo_connect's internals but targets pool::dbPool. + conn_args <- switch( + config$driver, + "RPostgres" = list( + drv = RPostgres::Postgres(), + dbname = config$database, + host = config$host, + port = as.integer(config$port), + user = config$username, + password = config$password, + sslmode = config$ssl_mode, + sslrootcert = config$ssl_root_cert, + sslcert = config$ssl_cert, + sslkey = config$ssl_key, + bigint = "integer" + ), + "RSQLite" = list( + drv = RSQLite::SQLite(), + # TODO: Decide whether host or database should be the file path + dbname = config$host # For SQLite, 'host' is the file path + ) + ) + + # Combine the generated args with any additional args passed via ... + final_args <- c(conn_args, list(...)) + + # Create the connection pool using the assembled arguments + rlang::exec(pool::dbPool, !!!final_args) +} diff --git a/R/ojo_query.R b/R/ojo_query.R index debf6510..03482a91 100644 --- a/R/ojo_query.R +++ b/R/ojo_query.R @@ -1,27 +1,36 @@ -#' @title OJO Query +#' @title Send a raw SQL query to the database #' -#' @description Query the Open Justice Oklahoma database +#' @description Executes a raw SQL query against a database connection. This +#' function is backend-agnostic and will send the query to any valid DBI +#' connection. #' -#' @param ... Arguments to pass to glue::glue_sql -#' @param .con The ojodb connection to use -#' @param query The query to send to ojodb +#' @param query The SQL query to send to the database. The user is responsible +#' for ensuring the syntax is correct for the target database backend. +#' @param con The database connection to use. If `NULL`, a default connection +#' to the primary Postgres database will be created and used. +#' +#' @export +#' @returns A lazy tibble with the `ojo_tbl` class. #' -#' @export ojo_query -#' @returns data, a lazy tibble containing the results of the query #' @examples #' \dontrun{ -#' ojo_query("SELECT * FROM \"case\" LIMIT 10") -#' ojo_query("SELECT * FROM iic.inmate LIMIT 10") -#' } +#' # Run a query against the default Postgres database +#' ojo_query("SELECT * FROM case LIMIT 10") #' -ojo_query <- function(query, ..., .con = NULL) { - if (is.null(.con)) { - .con <- ojo_connect(...) +#' # Run a query against a manual DuckDB connection +#' duck_con <- ojo_connect(db_config = db_config(.driver = "duckdb")) +#' ojo_query("SELECT * FROM 'my_duck_db_file.parquet' LIMIT 5", con = duck_con) +#' } +ojo_query <- function(query, con = NULL) { + # If no connection is provided, get the default singleton connection. + if (is.null(con)) { + con <- ojo_default_connection() } - if (!inherits(.con, "PqConnection")) { - rlang::abort("Direct SQL querying is currently only supported for Postgres backends. Make sure your connection is using `ojo_connect(.driver = 'RPostgres')`") - } + # Create a lazy tibble from the raw SQL query + data <- dplyr::tbl(con, dbplyr::sql(query)) - dplyr::tbl(.con, sql(query)) + # Add the ojo_tbl class for consistency with ojo_tbl() + class(data) <- c("ojo_tbl", class(data)) + return(data) } diff --git a/R/ojo_search_minutes.R b/R/ojo_search_minutes.R deleted file mode 100644 index f8df5569..00000000 --- a/R/ojo_search_minutes.R +++ /dev/null @@ -1,86 +0,0 @@ -#' Query minutes for a given search term -#' -#' Query the Open Justice Oklahoma database for minutes matching a search term -#' -#' @param query A term or phrase to search for using the PostgreSQL full text search syntax -#' @param ... Placeholder -#' @param .con The OJO connection to use -#' @param .silent Should the command line interface elements be shown? Defaults to `TRUE` if the user is in an interactive session, and `FALSE` if they are not. -#' -#' @export ojo_search_minutes -#' @return data, a lazy tibble containing the resulting minutes -#' @examples -#' \dontrun{ -#' -#' # Minutes containing words like 'mcgirt' -#' ojo_search_minutes("mcgirt") -#' -#' # Minutes containing 'mcgirt' AND 'jursdiction -#' ojo_search_minutes("mcgirt & jurisdiction") -#' -#' # Minutes containing 'mcgirt' OR 'jurisdiction -#' ojo_search_minutes("mcgirt | jurisdiction") -#' -#' # Minutes NOT containing 'mcgirt' -#' ## Use sparingly! It is likely to return rows in the hundreds of thousands or millions! -#' ojo_search_minutes("!! mcgirt") -#' -#' # Minutes containing 'tribal' FOLLOWED BY 'jurisdiction' -#' ojo_search_minutes("tribal <-> jurisdiction") -#' } -#' -ojo_search_minutes <- function(query, ..., .con = NULL, .silent = F) { - - if (is.null(.con)) { - .con <- ojo_connect() - } - ojo_connect() - - if(!.silent){ - # CLI - con_desc <- dbplyr::db_connection_describe(.con) |> - gsub(pattern = "postgres", replacement = "") |> - trimws() - # Styling rules - cli::cli_div( - theme = list( - rule = list(color = "br_yellow", - "line-type" = "single"), - "span.grayed" = list(color = "grey") - ) - ) - - cli::cli_rule(left = paste("Connection:", con_desc), - right = "{.emph ojodb {utils::packageVersion('ojodb')}}") - cli::cli_alert_info("Searching OJO database for matching minutes...") - } - - # If query contains spaces and no postgres operators, wrap it in quotes - query_clean <- dplyr::if_else(grepl(" ", query) & !grepl("&|\\||<->|\\!\\!", query), - paste0("'", query, "'"), - query) - - q <- glue::glue_sql( - "SELECT * FROM minute WHERE to_tsvector('english', description) @@ to_tsquery('english', {query_clean});", - .con = .con - ) - - df <- .con |> - pool::dbGetQuery(q) |> - dplyr::as_tibble() - - n_results <- nrow(df) - - if(!.silent){ - if (n_results > 0) { - cli::cli_alert_success(paste0("Success! ", - format(n_results, big.mark = ","), - " matching results found.")) - return(df) - } else { - cli::cli_alert_warning("No matching results found.") - } - } else { - return(df) - } -} diff --git a/R/ojo_show_row.R b/R/ojo_show_row.R deleted file mode 100644 index fd75fc32..00000000 --- a/R/ojo_show_row.R +++ /dev/null @@ -1,26 +0,0 @@ -#' Browse the source court record of a row -#' -#' Displays the OSCN court record for a given row in a dataframe with columns named `court` and `casenum` -#' -#' @param df A local dataframe with columns named `court` and `casenum` -#' @param row_number The number of the row of the case to be shown -#' -#' @export ojo_show_row -#' @returns Nothing -#' -#' @section Side effects: -#' Opens a browser window to the OSCN court record -#' -ojo_show_row <- function(df, row_number) { - if ("court" %in% names(df) & "casenum" %in% names(df) & row_number <= nrow(df)) { - paste0( - "https://www.oscn.net/dockets/GetCaseInformation.aspx?db=", - df[row_number, ]$district, - "&number=", - df[row_number, ]$case_number - ) |> - utils::browseURL() - } else { - message("Data frame must contain columns `court` and `casenum`.") - } -} diff --git a/R/ojo_tbl.R b/R/ojo_tbl.R index 7d9e61d9..4045fa8b 100644 --- a/R/ojo_tbl.R +++ b/R/ojo_tbl.R @@ -1,126 +1,41 @@ -#' Identify a table from the OJO database +#' @title Get a table from a database connection #' -#' Identifies a table in the OJO database from which to query data. Remember to run `connect_ojo()` to establish a connection before attempting to query and to close the connection afterwards with `disconnect_ojo()`. +#' @description Creates a lazy tibble from a table in a database. This is the +#' primary way to begin a database query with \{ojodb\}. #' -#' @aliases ojo_tbl +#' @details For interactive use, if no connection is provided, this function will +#' automatically create and manage a default connection to the primary OJO +#' Postgres database. To connect to other backends (like DuckDB or SQLite), +#' you must first create a connection object with `ojo_connect()` and pass it +#' via the `con` argument. #' -#' @param table The name of a table in the OJO database. To get a list of tables, run `ojo_list_tables()` -#' @param schema The name of a schema in the OJO database. To get a list of schemas, run `ojo_list_schemas()` -#' @param ... Placeholder -#' @param .con The ojodb connection to use -#' @param .source `r lifecycle::badge("experimental")` The source of the table. Options are 'postgres', 'gcs_duckdb', and 'gcs_arrow'. Default is 'postgres'. +#' @param table The name of the table to query. +#' @param schema The name of the schema where the table resides. +#' @param con A database connection object. If `NULL` (the default), a managed +#' default connection will be created and used automatically. +#' +#' @export +#' @return A lazy tibble with the `ojo_tbl` class. #' -#' @export ojo_tbl -#' @return A pointer to a table that can be passed to dplyr functions and/or pulled into a dataframe using `ojo_collect()` #' @examples #' \dontrun{ -#' # Identifies the table -#' ojo_tbl("case") +#' # Interactively connect to the 'case' table in the default Postgres DB +#' case_tbl <- ojo_tbl("case") #' -#' # Pulls down case information data for every Tulsa felony filed in 2020 into a dataframe d -#' d <- ojo_tbl("case") %>% -#' filter(district == "TULSA", case_type == "CF", year == 2020) %>% -#' collect() +#' # Connect to a table on a manually created DuckDB connection +#' my_duckdb_con <- ojo_connect(db_config = db_config(.driver = "duckdb")) +#' my_duckdb_tbl <- ojo_tbl("some_table", con = my_duckdb_con) #' } -#' @seealso ojo_list_tables(), ojo_list_vars(), ojo_list_schemas() -#' -ojo_tbl <- function( - table, - schema = "public", - ..., - .con = NULL, - .source = "postgres" -) { - if (.source == "database") { - lifecycle::deprecate_warn("2.9.0", I(".source = 'database' is deprecated. Use '.source = 'postgres' instead.")) - .source <- "postgres" +ojo_tbl <- function(table, schema = "public", con = NULL) { + # If no connection is provided, get the default singleton connection. + # This defaults to the primary Postgres database. + if (is.null(con)) { + con <- ojo_default_connection() } - data <- switch( - .source, - "postgres" = { - if (is.null(.con)) { - .con <- ojo_connect( - ..., - .driver = "RPostgres" - ) - } - tbl_from_rpostgres(.con, schema, table) - }, - "gcs_duckdb" = { - if (!rlang::is_installed("duckdb")) { - rlang::abort(".source == \"gcs_duckdb\" requires {duckdb}.") - } - if (is.null(.con)) { - .con <- ojo_connect(..., .driver = "duckdb", .source = .source) - } - if (schema == "public") { - schema <- "oscn" - } - tbl_from_gcs_duckdb(.con, schema, table) - }, - "gcs_arrow" = { - if (!rlang::is_installed("arrow")) { - rlang::abort(".source == \"gcs_arrow\" requires {arrow} with GCS support.") - } - gcs_available <- arrow::arrow_with_gcs() - if (!gcs_available) { - rlang::abort("Arrow wasn't compiled with GCS support.") - } - if (schema == "public") { - schema <- "oscn" - } - tbl_from_gcs_arrow(schema, table) - }, - rlang::abort("Invalid source specified. Please choose one of: 'postgres', 'gcs_duckdb', or 'gcs_arrow'.") - ) + # Create a lazy tibble from the connection + data <- dplyr::tbl(con, DBI::Id(schema = schema, table = table)) class(data) <- c("ojo_tbl", class(data)) - return(data) } - -#' Fetch data from a database -#' -#' @param con A database connection object. -#' @param schema The schema name in the database. -#' @param table The table name to fetch. -#' -#' @return A dplyr tbl object connected to the specified table. -#' -#' @keywords internal -#' -tbl_from_rpostgres <- function(con, schema, table) { - dplyr::tbl(con, DBI::Id(schema = schema, table = table)) -} - -#' Fetch data from Google Cloud Storage -#' -#' @param schema The schema (directory) name in Google Cloud Storage. -#' @param table The table (file) name to fetch. -#' @param anonymous Logical, whether to access GCS anonymously (default: TRUE). -#' -#' @return A dataset object from the specified GCS path. -#' -#' @keywords internal -#' -tbl_from_gcs_arrow <- function(schema, table, anonymous = TRUE) { - bucket_path <- stringr::str_glue("{schema}/{table}") - bucket <- arrow::gs_bucket(bucket_path, anonymous = anonymous) - arrow::open_dataset(bucket, format = "parquet") -} - -#' Fetch data from Google Cloud Storage using {duckdb} -#' -#' @param schema The schema (directory) name in Google Cloud Storage. -#' @param table The table (file) name to fetch. -#' -#' @return A lazy {duckdb} result -#' -#' @keywords internal -#' -tbl_from_gcs_duckdb <- function(con, schema, table) { - bucket_path <- stringr::str_glue("gs://ojo-data-warehouse/raw-data/{schema}/{table}/*.parquet") - DBI::dbExecute(con, stringr::str_glue("CREATE OR REPLACE VIEW \'{table}\' AS SELECT * FROM read_parquet('{bucket_path}')")) - tbl(con, table) -} diff --git a/R/ojo_theme.R b/R/ojo_theme.R deleted file mode 100644 index cfb6b518..00000000 --- a/R/ojo_theme.R +++ /dev/null @@ -1,28 +0,0 @@ -#' Style a ggplot in the OJO style -#' -#' Add OJO styling to a ggplot -#' -#' @export ojo_theme -#' -#' @importFrom ggplot2 %+replace% -#' -#' @examples -#' \dontrun{ -#' ggplot(ojo_example, aes(file_year, n_cases, color = court)) + -#' geom_line(size = 1.5) + -#' ojo_theme() + -#' ojo_colors() + -#' scale_x_continuous( -#' breaks = 2010:2019, -#' limits = c(NA, 2019) -#' ) -#' } -ojo_theme <- function() { - ggplot2::theme_bw(base_size = 14, base_family = "Menlo") %+replace% - ggplot2::theme( - panel.background = ggplot2::element_blank(), - plot.background = ggplot2::element_blank(), - legend.background = ggplot2::element_rect(fill = "transparent", colour = NA), - legend.key = ggplot2::element_rect(fill = "transparent", colour = NA) - ) -} diff --git a/R/utils.R b/R/utils.R index f0787b60..dc0d91a6 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,15 +1,3 @@ -#' @title OJO Environment -#' -#' @description Get the environment of the OJO package, which holds the database pool object. -#' -#' @param ... Placeholder for future arguments -#' -#' @export -#' -ojo_env <- function(...) { - .ojo_env -} - #' @title OJO Version #' #' @description @@ -23,14 +11,3 @@ ojo_env <- function(...) { ojo_version <- function(...) { utils::packageVersion("ojodb") } - -#' @title Skip If No Database Connection -#' -#' @description -#' Skips a test if the `ojodb` package is not installed or if the user is not connected to the OJO database. -#' -#' @keywords internal -#' -skip_if_no_db <- function() { - testthat::skip("No database connection available") -} diff --git a/R/zzz.R b/R/zzz.R index 8a3cc565..00fd8c03 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -1,26 +1,37 @@ ojodbStartupMessage <- function() { - ojo_art <- " _ _ _\n \u2554\u2550\u2550 \u2550\u2550\u2557 ___ (_) ___ __| | |__\n \u2551/ \\\u2551 / _ \\| |/ _ \\ / _` | '_ \\\n | (_) | | (_) | (_| | |_) |\n \u2551\\ /\u2551 \\___// |\\___/ \\__,_|_.__/\n \u255a\u2550\u2550 \u2550\u2550\u255d |__/\n version " - emoji_list <- c("\U0001F600", "\U0001F603", "\U0001F604", "\U0001F601", "\U0001F606", - "\U0001F60A", "\U0001F60E", "\U0001F61C", "\U0001F61D", "\U0001F61B", - "\U0001F920", "\U0001F642", "\U0001F914") + emoji_list <- c( + "\U0001F600", + "\U0001F603", + "\U0001F604", + "\U0001F601", + "\U0001F606", + "\U0001F60A", + "\U0001F60E", + "\U0001F61C", + "\U0001F61D", + "\U0001F61B", + "\U0001F920", + "\U0001F642", + "\U0001F914" + ) random_emoji <- sample(emoji_list, 1) - ojo_art_full <- paste0(ojo_art, as.character(ojo_version()), " ", random_emoji) + ojo_art_full <- paste0( + ojo_art, + as.character(ojo_version()), + " ", + random_emoji + ) startup_message <- paste0(ojo_art_full, sep = "\n") return(startup_message) - } .onAttach <- function(lib, pkg) { - - if(Sys.getenv("OJO_LOAD_MESSAGE") != FALSE){ + if ((Sys.getenv("OJO_LOAD_MESSAGE") != FALSE) && rlang::is_interactive()) { msg <- ojodbStartupMessage() - if(!interactive()) - msg[1] <- paste("Package 'ojodb' version", utils::packageVersion("ojodb")) packageStartupMessage(msg) invisible() } - } diff --git a/_pkgdown.yml b/_pkgdown.yml index c6f16fce..c8be0582 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -13,15 +13,10 @@ reference: - ojo_add_minutes - ojo_add_issues - ojo_add_party_details - - ojo_search_minutes - ojo_collect - title: "Functions to help aid with analysis" desc: "These include some other helpful functions you might use in your analysis." contents: - - ojo_color - - ojo_fill - - ojo_pal - - ojo_theme - ojo_county_population - ojo_fiscal_year - title: "Database management functions" @@ -31,15 +26,16 @@ reference: - ojo_list_tables - ojo_list_vars - ojo_case_types - - ojo_show_row - title: "'Behind the scenes' functions" desc: "These are the most basic / fundamental functions in the package. Generally, we shouldn't need to use them directly." contents: - ojo_connect + - ojo_pool + - ojo_connection_string - ojo_tbl - ojo_query - ojo_check_ssl - ojo_auth - - ojo_env + - db_config - ojo_version diff --git a/air.toml b/air.toml new file mode 100644 index 00000000..e69de29b diff --git a/docs/404.html b/docs/404.html index c7f6ca4b..1f99c568 100644 --- a/docs/404.html +++ b/docs/404.html @@ -6,68 +6,60 @@ Page not found (404) • ojodb - - - - - - + + + + + - - - - + + + + - Skip to contents - -