diff --git a/DESCRIPTION b/DESCRIPTION index 9191dc5a..a9686e49 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -31,7 +31,6 @@ License: MIT + file LICENSE URL: https://posit-dev.github.io/connectapi/, https://github.com/posit-dev/connectapi BugReports: https://github.com/posit-dev/connectapi/issues Imports: - bit64, fs, glue, httr, diff --git a/R/connectapi.R b/R/connectapi.R index 5b625696..b18badf2 100644 --- a/R/connectapi.R +++ b/R/connectapi.R @@ -10,7 +10,8 @@ utils::globalVariables( c( ".", "access_type", - "connectapi_ptypes", + "connectapi_datetime_cols", + "connectapi_lazy_cols", "guid", "last_deployed_time", "owner_guid", @@ -27,6 +28,5 @@ current_connect_version <- "2024.03.0" .onLoad <- function(...) { vctrs::s3_register("dplyr::collect", "tbl_connect") - vctrs::s3_register("vctrs::vec_cast", "character.integer") invisible() } diff --git a/R/content.R b/R/content.R index 486c7e39..451bd8ec 100644 --- a/R/content.R +++ b/R/content.R @@ -788,7 +788,7 @@ get_jobs <- function(content) { validate_R6_class(content, "Content") jobs <- content$jobs() - parse_connectapi_typed(jobs, connectapi_ptypes$jobs, strict = TRUE) + parse_connectapi_typed(jobs, connectapi_datetime_cols$jobs) } #' Terminate Jobs @@ -832,7 +832,7 @@ terminate_jobs <- function(content, keys = NULL) { keys <- all_jobs[all_jobs$status == 0, ]$key if (length(keys) == 0) { message("No active jobs found.") - return(vctrs::vec_ptype(connectapi_ptypes$job_termination)) + return(tibble::tibble()) } } @@ -841,8 +841,7 @@ terminate_jobs <- function(content, keys = NULL) { res_df <- tibble::tibble( parse_connectapi_typed( res_content, - connectapi_ptypes$job_termination, - strict = TRUE + connectapi_datetime_cols$job_termination ) ) # Errors will not have the job_key. @@ -896,7 +895,7 @@ get_log <- function(job, max_log_lines = NULL) { v1_url("content", job$app_guid, "jobs", job$key, "log"), query = query ) - parse_connectapi_typed(res$entries, connectapi_ptypes$job_log) + parse_connectapi_typed(res$entries, connectapi_datetime_cols$job_log) } #' Set RunAs User @@ -1141,7 +1140,7 @@ get_bundles <- function(content) { validate_R6_class(content, "Content") bundles <- content$get_bundles() - parse_connectapi_typed(bundles, connectapi_ptypes$bundles) + parse_connectapi_typed(bundles, connectapi_datetime_cols$bundles) } #' @rdname get_bundles @@ -1347,7 +1346,7 @@ get_group_permission <- function(content, guid) { get_content_permissions <- function(content, add_owner = TRUE) { validate_R6_class(content, "Content") res <- content$permissions(add_owner = add_owner) - parse_connectapi_typed(res, connectapi_ptypes$permissions) + parse_connectapi_typed(res, connectapi_datetime_cols$permissions) } #' Render a content item. @@ -1495,7 +1494,7 @@ content_restart <- function(content) { get_content_packages <- function(content) { error_if_less_than(content$connect$version, "2025.01.0") res <- content$packages() - parse_connectapi_typed(res, connectapi_ptypes$content_packages) + parse_connectapi_typed(res, connectapi_datetime_cols$content_packages) } #' Search for content on the Connect server @@ -1627,5 +1626,5 @@ as.data.frame.connect_content_list <- function( #' @export as_tibble.connect_content_list <- function(x, ...) { content_data <- purrr::map(x, "content") - parse_connectapi_typed(content_data, connectapi_ptypes$content) + parse_connectapi_typed(content_data, connectapi_datetime_cols$content) } diff --git a/R/get.R b/R/get.R index 14ee3d31..2c193510 100644 --- a/R/get.R +++ b/R/get.R @@ -73,7 +73,7 @@ get_users <- function( limit = limit ) - out <- parse_connectapi_typed(res, connectapi_ptypes$users) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$users) return(out) } @@ -229,12 +229,8 @@ get_content <- function( # v2024.06.0. if (compare_connect_version(src$version, "2024.06.0") < 0) { include <- "tags,owner" - content_ptype <- connectapi_ptypes$content[, - names(connectapi_ptypes$content) != "vanity_url" - ] } else { include <- "tags,owner,vanity_url" - content_ptype <- connectapi_ptypes$content } res <- src$content( @@ -253,7 +249,7 @@ get_content <- function( res <- res %>% purrr::keep(.p = .p) } - out <- parse_connectapi_typed(res, content_ptype) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$content) return(out) } @@ -327,7 +323,7 @@ content_list_by_tag <- function(src, tag) { res <- src$GET(v1_url("tags", tag_id, "content")) - out <- parse_connectapi_typed(res, connectapi_ptypes$content) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$content) return(out) } @@ -425,7 +421,7 @@ get_usage_shiny <- function( res <- page_cursor(src, res, limit = limit) - out <- parse_connectapi_typed(res, connectapi_ptypes$usage_shiny) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$usage_shiny) return(out) } @@ -521,7 +517,7 @@ get_usage_static <- function( res <- page_cursor(src, res, limit = limit) - out <- parse_connectapi_typed(res, connectapi_ptypes$usage_static) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$usage_static) return(out) } @@ -658,7 +654,7 @@ as.data.frame.connect_list_hits <- function( ..., unnest = TRUE ) { - usage_df <- parse_connectapi_typed(x, connectapi_ptypes$usage) + usage_df <- parse_connectapi_typed(x, connectapi_datetime_cols$usage) if (unnest) { if (!requireNamespace("tidyr", quietly = TRUE)) { stop( @@ -750,7 +746,7 @@ get_audit_logs <- function( res <- page_cursor(src, res, limit = limit) - out <- parse_connectapi_typed(res, connectapi_ptypes$audit_logs) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$audit_logs) return(out) } @@ -792,7 +788,7 @@ get_procs <- function(src) { c(list(pid = y), x) } ) - tbl_data <- parse_connectapi_typed(proc_prep, connectapi_ptypes$procs) + tbl_data <- parse_connectapi_typed(proc_prep, connectapi_datetime_cols$procs) return(tbl_data) } @@ -1203,7 +1199,7 @@ get_packages <- function(src, name = NULL, page_size = 100000, limit = Inf) { page_size = page_size ) ) - out <- parse_connectapi_typed(res, connectapi_ptypes$packages) + out <- parse_connectapi_typed(res, connectapi_datetime_cols$packages) # Connect is standardizing on using `content_id` and `content_guid`. # Handle that name change now in a forward-compatible way. @@ -1239,5 +1235,5 @@ get_packages <- function(src, name = NULL, page_size = 100000, limit = Inf) { #' @export get_vanity_urls <- function(client) { res <- client$vanities() - parse_connectapi_typed(res, connectapi_ptypes$vanities) + parse_connectapi_typed(res, connectapi_datetime_cols$vanities) } diff --git a/R/groups.R b/R/groups.R index 79d98edb..05ffb66a 100644 --- a/R/groups.R +++ b/R/groups.R @@ -53,7 +53,7 @@ get_groups <- function(src, page_size = 500, prefix = NULL, limit = Inf) { limit = limit ) - parse_connectapi_typed(res, connectapi_ptypes$groups) + parse_connectapi_typed(res, connectapi_datetime_cols$groups) } #' Get users within a specific group @@ -172,7 +172,7 @@ get_one_groups_content <- function(src, guid) { role = NA_character_ )) } - parsed <- parse_connectapi_typed(res, connectapi_ptypes$group_content) + parsed <- parse_connectapi_typed(res, connectapi_datetime_cols$group_content) permissions_df <- purrr::list_rbind( purrr::map( diff --git a/R/integrations.R b/R/integrations.R index ad833bbc..34c77cad 100644 --- a/R/integrations.R +++ b/R/integrations.R @@ -136,7 +136,7 @@ as.data.frame.connect_integration_list <- function( #' @return A tibble with one row per integration. #' @export as_tibble.connect_integration_list <- function(x, ...) { - parse_connectapi_typed(x, connectapi_ptypes$integrations) + parse_connectapi_typed(x, connectapi_datetime_cols$integrations) } # Integration class ---- diff --git a/R/lazy.R b/R/lazy.R index cbb03887..9252412c 100644 --- a/R/lazy.R +++ b/R/lazy.R @@ -37,8 +37,7 @@ tbl_connect <- function( from <- arg_match(from) - # TODO: go get the vars we should expect... - vars <- connectapi_ptypes[[from]] + vars <- connectapi_lazy_cols[[from]] if (is.null(vars)) vars <- character() # TODO: figure out number of rows... @@ -81,7 +80,7 @@ api_build.op_base_connect <- function(op, con, ..., n) { } else { stop(glue::glue("'{op$x}' is not recognized")) } - parse_connectapi_typed(res, op$ptype) + parse_connectapi_typed(res, connectapi_datetime_cols[[op$x]]) } cat_line <- function(...) { @@ -124,13 +123,12 @@ op_base_connect <- function(x, vars) { } op_base <- function(x, vars, class = character()) { - stopifnot(is.character(vars) || is.character(names(vars))) + stopifnot(is.character(vars)) structure( list( x = x, - vars = names(vars), - ptype = vars + vars = vars ), class = c(paste0("op_base_", class), "op_base", "op") ) diff --git a/R/parse.R b/R/parse.R index c917f2da..fa7b7ae6 100644 --- a/R/parse.R +++ b/R/parse.R @@ -27,65 +27,18 @@ make_timestamp <- function(input) { safe_format(input, "%Y-%m-%dT%H:%M:%SZ", tz = "UTC", usetz = FALSE) } -ensure_columns <- function(.data, ptype, strict = FALSE) { - # Given a prototype, ensure that all columns are present and cast to the correct type. - # If a column is missing in .data, it will be created with all missing values of the correct type. - # If a column is present in both, it will be cast to the correct type. - # If a column is present in .data but not in ptype, it will be left as is. - # If `strict == TRUE`, include only columns present in the ptype, in the order they occur. - for (i in names(ptype)) { - .data <- ensure_column(.data, ptype[[i]], i) +parse_connectapi_typed <- function(data, datetime_cols = character()) { + df <- parse_connectapi(data) + for (col in intersect(datetime_cols, names(df))) { + df[[col]] <- coerce_datetime(df[[col]]) } - - if (strict) { - .data <- .data[, names(ptype), drop = FALSE] - } - - .data -} - -ensure_column <- function(data, default, name) { - stopifnot(length(default) == 1) - col <- data[[name]] - scoped_experimental_silence() - if (rlang::is_null(col)) { - col <- vctrs::vec_rep(default, nrow(data)) - col <- vctrs::vec_cast(col, default) - } else { - if ( - vctrs::vec_is(default, NA_datetime_) && !vctrs::vec_is(col, NA_datetime_) - ) { - # manual fix because vctrs::vec_cast cannot cast double -> datetime or char -> datetime - col <- coerce_datetime(col, default, name = name) - } - - if (inherits(default, "fs_bytes") && !inherits(col, "fs_bytes")) { - col <- coerce_fsbytes(col, default) - } - - if (inherits(default, "integer64") && !inherits(col, "integer64")) { - col <- bit64::as.integer64(col) - } - - if (inherits(default, "list") && !inherits(col, "list")) { - col <- list(col) - } - - col <- vctrs::vec_cast(col, default, x_arg = name) - } - data[[name]] <- col - data -} - -parse_connectapi_typed <- function(data, ptype, strict = FALSE) { - ensure_columns(parse_connectapi(data), ptype, strict) + df } # Build a tibble column-by-column instead of row-by-row (via list_rbind). # This avoids type conflicts when the same field is NULL in some rows and # non-NULL in others: NULL -> NA, and unlist() coerces that NA to match the -# type of the non-null values in the same column. ensure_columns() handles -# any further type coercion (e.g. character -> POSIXct) after this step. +# type of the non-null values in the same column. parse_connectapi <- function(data) { if (length(data) == 0) return(tibble::tibble()) @@ -104,39 +57,21 @@ parse_connectapi <- function(data) { tibble::as_tibble(cols) } -coerce_fsbytes <- function(x, to, ...) { - if (is.numeric(x)) { - fs::as_fs_bytes(x) - } else { - vctrs::stop_incompatible_cast(x = x, to = to, x_arg = "x", to_arg = "to") - } -} - -# name - optional. Must be named, the name of the variable / column being converted -coerce_datetime <- function(x, to, ...) { - tmp_name <- rlang::dots_list(...)[["name"]] - if (is.null(tmp_name) || is.na(tmp_name) || !is.character(tmp_name)) { - tmp_name <- "x" - } - +coerce_datetime <- function(x) { if (is.null(x)) { - as.POSIXct(character(), tz = tzone(to)) + as.POSIXct(character(), tz = Sys.timezone()) } else if (is.numeric(x)) { - vctrs::new_datetime(as.double(x), tzone = tzone(to)) + as.POSIXct(as.double(x), origin = "1970-01-01", tz = Sys.timezone()) } else if (is.character(x)) { parse_connect_rfc3339(x) } else if (inherits(x, "POSIXct")) { x - } else if ( - all(is.logical(x) & is.na(x)) && length(is.logical(x) & is.na(x)) > 0 - ) { - NA_datetime_ + } else if (all(is.logical(x) & is.na(x)) && length(x) > 0) { + as.POSIXct(NA, tz = Sys.timezone()) } else { - vctrs::stop_incompatible_cast( - x = x, - to = to, - x_arg = tmp_name, - to_arg = "to" + stop( + "Cannot coerce ", class(x)[[1]], " to POSIXct", + call. = FALSE ) } } @@ -173,33 +108,3 @@ parse_connect_rfc3339 <- function(x) { format_string <- "%Y-%m-%dT%H:%M:%OS%z" as.POSIXct(x, format = format_string, tz = Sys.timezone()) } - -vec_cast.POSIXct.double <- # nolint: object_name_linter - function(x, to, ...) { - warn_experimental("vec_cast.POSIXct.double") - vctrs::new_datetime(x, tzone = tzone(to)) - } - -vec_cast.POSIXct.character <- # nolint: object_name_linter - function(x, to, ...) { - as.POSIXct(x, tz = tzone(to)) - } - -tzone <- function(x) { - attr(x, "tzone")[[1]] %||% "" -} - -vec_cast.character.integer <- # nolint: object_name_linter - function(x, to, ...) { - as.character(x) - } - -new_datetime <- function(x = double(), tzone = "") { - tzone <- tzone %||% "" - if (is.integer(x)) { - x <- as.double(x) - } - stopifnot(is.double(x)) - stopifnot(is.character(tzone)) - structure(x, tzone = tzone, class = c("POSIXct", "POSIXt")) -} diff --git a/R/ptype.R b/R/ptype.R index 6e274f1e..294f1ad1 100644 --- a/R/ptype.R +++ b/R/ptype.R @@ -1,269 +1,54 @@ -NA_datetime_ <- # nolint: object_name_linter - vctrs::new_datetime(NA_real_, tzone = Sys.timezone()) -NA_list_ <- # nolint: object_name_linter - list(list()) +connectapi_datetime_cols <- list( + users = c("created_time", "updated_time", "active_time"), + groups = character(), + usage_shiny = c("started", "ended"), + usage_static = c("time"), + usage = c("timestamp"), + content = c("created_time", "last_deployed_time"), + audit_logs = c("time"), + procs = character(), + variant = c("created_time", "render_time"), + rendering = c("render_time"), + jobs = c("start_time", "end_time", "last_heartbeat_time", "queued_time"), + bundles = c("created_time"), + permissions = character(), + group_content = character(), + job_termination = character(), + vanities = c("created_time"), + job_log = c("timestamp"), + packages = character(), + content_packages = character(), + integrations = c("created_time", "updated_time") +) -connectapi_ptypes <- list( - users = tibble::tibble( - "email" = NA_character_, - "username" = NA_character_, - "first_name" = NA_character_, - "last_name" = NA_character_, - "user_role" = NA_character_, - "created_time" = NA_datetime_, - "updated_time" = NA_datetime_, - "active_time" = NA_datetime_, - "confirmed" = FALSE, - "locked" = FALSE, - "external_id" = NA_character_, - "guid" = NA_character_ - ), - groups = tibble::tibble( - "guid" = NA_character_, - "name" = NA_character_, - "owner_guid" = NA_character_, - "gid" = NA_character_ - ), - usage_shiny = tibble::tibble( - "content_guid" = NA_character_, - "user_guid" = NA_character_, - "started" = NA_datetime_, - "ended" = NA_datetime_, - "data_version" = NA_integer_ - ), - usage_static = tibble::tibble( - "content_guid" = NA_character_, - "user_guid" = NA_character_, - "variant_key" = NA_character_, - "time" = NA_datetime_, - "rendering_id" = NA_character_, - "bundle_id" = NA_character_, - "data_version" = NA_integer_ - ), - usage = tibble::tibble( - "id" = NA_integer_, - "user_guid" = NA_character_, - "content_guid" = NA_character_, - "timestamp" = NA_datetime_, - "data" = NA_list_ - ), - content = tibble::tibble( - "guid" = NA_character_, - "name" = NA_character_, - "title" = NA_character_, - "description" = NA_character_, - "access_type" = NA_character_, - "connection_timeout" = NA_integer_, - "read_timeout" = NA_integer_, - "init_timeout" = NA_integer_, - "idle_timeout" = NA_integer_, - "max_processes" = NA_integer_, - "min_processes" = NA_integer_, - "max_conns_per_process" = NA_integer_, - "load_factor" = NA_real_, - "created_time" = NA_datetime_, - "last_deployed_time" = NA_datetime_, - "bundle_id" = NA_character_, - "app_mode" = NA_character_, - "content_category" = NA_character_, - "parameterized" = FALSE, - "cluster_name" = NA_character_, - "image_name" = NA_character_, - "r_version" = NA_character_, - "py_version" = NA_character_, - "quarto_version" = NA_character_, - "run_as" = NA_character_, - "run_as_current_user" = FALSE, - "owner_guid" = NA_character_, - "content_url" = NA_character_, - "dashboard_url" = NA_character_, - "app_role" = NA_character_, - "vanity_url" = NA_character_, - "id" = NA_character_, - "owner" = NA_list_, - "tags" = NA_list_, - ), - content_old = tibble::tibble( - "id" = NA_integer_, - "guid" = NA_character_, - "access_type" = NA_character_, - "connection_timeout" = NA_real_, - "read_timeout" = NA_real_, - "init_timeout" = NA_real_, - "idle_timeout" = NA_real_, - "max_processes" = NA_integer_, - "min_processes" = NA_integer_, - "max_conns_per_process" = NA_integer_, - "load_factor" = NA_real_, - "url" = NA_character_, - "vanity_url" = NA, - "name" = NA_character_, - "title" = NA_character_, - "bundle_id" = NA_integer_, - # (1=shiny, 2=shiny Rmd, 3=source Rmd, 4=static, 5=api, 6=tensorflow, 7=python, 8=flask, 9=dash, 10=streamlit) - "app_mode" = NA_integer_, - "content_category" = NA_character_, - "has_parameters" = NA, - "created_time" = NA_datetime_, - "last_deployed_time" = NA_datetime_, - "r_version" = NA_character_, - "py_version" = NA_character_, - "build_status" = NA_integer_, - "run_as" = NA_character_, - "run_as_current_user" = NA, - "description" = NA_character_, - "app_role" = NA_character_, - "owner_first_name" = NA_character_, - "owner_last_name" = NA_character_, - "owner_username" = NA_character_, - "owner_guid" = NA_character_, - "owner_email" = NA_character_, - "owner_locked" = NA, - "is_scheduled" = NA, - "git" = NA_list_ - ), - audit_logs = tibble::tibble( - "id" = NA_character_, - "time" = NA_datetime_, - "user_id" = NA_character_, - "user_guid" = NA_character_, - "user_description" = NA_character_, - "action" = NA_character_, - "event_description" = NA_character_ - ), - procs = tibble::tibble( - pid = NA_character_, - appId = NA_integer_, - appGuid = NA_character_, - appName = NA_character_, - appUrl = NA_character_, - appRunAs = NA_character_, - type = NA_character_, - cpuCurrent = NA_real_, - cpuTotal = NA_integer_, - ram = fs::as_fs_bytes(NA_integer_) - ), - variant = tibble::tibble( - id = NA_integer_, - app_id = NA_integer_, - key = NA_character_, - bundle_id = NA_integer_, - is_default = NA, - name = NA_character_, - email_collaborators = NA, - email_viewers = NA, - created_time = NA_datetime_, - rendering_id = NA_integer_, - render_time = NA_datetime_, - render_duration = bit64::NA_integer64_, - visibility = NA_character_, - owner_id = NA_integer_ - ), - rendering = tibble::tibble( - id = NA_integer_, - app_id = NA_integer_, - variant_id = NA_integer_, - bundle_id = NA_integer_, - job_key = NA_character_, - render_time = NA_datetime_, - render_duration = bit64::as.integer64(NA_integer_), - active = NA, - app_guid = NA_character_, - variant_key = NA_character_, - ), - jobs = tibble::tibble( - id = NA_character_, - ppid = NA_character_, - pid = NA_character_, - key = NA_character_, - remote_id = NA_character_, - content_id = NA_character_, - content_guid = NA_character_, - app_id = NA_character_, - app_guid = NA_character_, - variant_id = NA_character_, - bundle_id = NA_character_, - start_time = NA_datetime_, - end_time = NA_datetime_, - last_heartbeat_time = NA_datetime_, - queued_time = NA_datetime_, - queue_name = NA_character_, - tag = NA_character_, - exit_code = NA_integer_, - status = NA_integer_, - hostname = NA_character_, - cluster = NA_character_, - image = NA_character_, - run_as = NA_character_, - ), - bundles = tibble::tibble( - id = NA_character_, - content_guid = NA_character_, - created_time = NA_datetime_, - r_version = NA_character_, - py_version = NA_character_, - active = NA, - size = fs::as_fs_bytes(NA_integer_), - metadata = NA_list_, - ), - permissions = tibble::tibble( - id = NA_character_, - content_guid = NA_character_, - principal_guid = NA_character_, - principal_type = NA_character_, - role = NA_character_ - ), - group_content = tibble::tibble( - content_guid = NA_character_, - content_name = NA_character_, - content_title = NA_character_, - access_type = NA_character_, - permissions = NA_list_ - ), - job_termination = tibble::tibble( - app_id = NA_integer_, - app_guid = NA_character_, - job_key = NA_character_, - job_id = NA_character_, - result = NA_character_, - code = NA_integer_, - error = NA_character_ - ), - vanities = tibble::tibble( - content_guid = NA_character_, - path = NA_character_, - created_time = NA_datetime_ - ), - job_log = tibble::tibble( - source = NA_character_, - timestamp = NA_datetime_, - data = NA_character_ - ), - packages = tibble::tibble( - language = NA_character_, - language_version = NA_character_, - name = NA_character_, - version = NA_character_, - hash = NA_character_, - bundle_id = NA_character_, - app_id = NA_character_, - app_guid = NA_character_, - ), - content_packages = tibble::tibble( - language = NA_character_, - name = NA_character_, - version = NA_character_, - hash = NA_character_ - ), - integrations = tibble::tibble( - id = NA_character_, - guid = NA_character_, - created_time = NA_datetime_, - updated_time = NA_datetime_, - name = NA_character_, - description = NA_character_, - template = NA_character_, - auth_type = NA_character_, - config = NA_list_ +# Column names used by the lazy tibble system (tbl_connect) to report +# dim() / colnames() before data is fetched. +connectapi_lazy_cols <- list( + users = c( + "email", "username", "first_name", "last_name", "user_role", + "created_time", "updated_time", "active_time", "confirmed", "locked", + "external_id", "guid" + ), + groups = c("guid", "name", "owner_guid", "gid"), + content = c( + "guid", "name", "title", "description", "access_type", + "connection_timeout", "read_timeout", "init_timeout", "idle_timeout", + "max_processes", "min_processes", "max_conns_per_process", "load_factor", + "created_time", "last_deployed_time", "bundle_id", "app_mode", + "content_category", "parameterized", "cluster_name", "image_name", + "r_version", "py_version", "quarto_version", "run_as", + "run_as_current_user", "owner_guid", "content_url", "dashboard_url", + "app_role", "vanity_url", "id", "owner", "tags" + ), + usage_shiny = c( + "content_guid", "user_guid", "started", "ended", "data_version" + ), + usage_static = c( + "content_guid", "user_guid", "variant_key", "time", + "rendering_id", "bundle_id", "data_version" + ), + audit_logs = c( + "id", "time", "user_id", "user_guid", "user_description", + "action", "event_description" ) ) diff --git a/R/remote.R b/R/remote.R index c57b71d8..f1141bce 100644 --- a/R/remote.R +++ b/R/remote.R @@ -98,7 +98,7 @@ groups_create_remote <- function( expect <- as.integer(expect) if (check) { local_groups <- get_groups(connect, prefix = prefix) - if (exact) { + if (exact && nrow(local_groups) > 0) { local_groups <- local_groups[local_groups["name"] == prefix, ] } if (nrow(local_groups) > 0) { diff --git a/R/variant.R b/R/variant.R index 4aa55be0..766d76b6 100644 --- a/R/variant.R +++ b/R/variant.R @@ -257,7 +257,7 @@ get_variants <- function(content) { variants <- content$variants() - parse_connectapi_typed(variants, connectapi_ptypes$variant) + parse_connectapi_typed(variants, connectapi_datetime_cols$variant) } #' @rdname variant @@ -300,7 +300,7 @@ get_variant_renderings <- function(variant) { validate_R6_class(variant, "Variant") renders <- variant$renderings() - parse_connectapi_typed(renders, connectapi_ptypes$rendering) + parse_connectapi_typed(renders, connectapi_datetime_cols$rendering) } #' @rdname variant_render diff --git a/tests/integrated/helper.R b/tests/integrated/helper.R index 78dae128..96e11712 100644 --- a/tests/integrated/helper.R +++ b/tests/integrated/helper.R @@ -1,11 +1,7 @@ -expect_ptype_equal <- function(actual, expected, exact = TRUE) { - if (!exact) { - # Keep only the columns from each that are in the other - shared_names <- intersect(names(actual), names(expected)) - actual <- actual[, shared_names] - expected <- expected[, shared_names] +expect_datetime_cols <- function(actual, datetime_cols) { + for (col in intersect(datetime_cols, names(actual))) { + expect_s3_class(actual[[col]], "POSIXct", info = paste("Column:", col)) } - expect_equal(vctrs::vec_ptype(actual), vctrs::vec_ptype(expected)) } skip_if_connect_older_than <- function(client, version) { diff --git a/tests/integrated/test-get.R b/tests/integrated/test-get.R index 30e347f5..c8df6055 100644 --- a/tests/integrated/test-get.R +++ b/tests/integrated/test-get.R @@ -10,7 +10,7 @@ test_that("get_users works", { users <- get_users(client) expect_s3_class(users, c("tbl_df", "tbl", "data.frame")) - expect_ptype_equal(users, connectapi_ptypes$users, exact = FALSE) + expect_datetime_cols(users, connectapi_datetime_cols$users) # Other tests create users, so specifying the exact number here is conditional # on the contents of other tests and the order that tests run in. @@ -24,8 +24,6 @@ test_that("get_users works", { test_that("get_groups works", { groups_list <- get_groups(client) expect_s3_class(groups_list, c("tbl_df", "tbl", "data.frame")) - - expect_ptype_equal(groups_list, connectapi_ptypes$groups) }) test_that("get_content works", { @@ -33,37 +31,29 @@ test_that("get_content works", { content_list <- get_content(client) expect_s3_class(content_list, c("tbl_df", "tbl", "data.frame")) - # various attributes have been added over the years, so exact match - # doesn't work against all versions of Connect - expect_ptype_equal(content_list, connectapi_ptypes$content, exact = FALSE) + expect_datetime_cols(content_list, connectapi_datetime_cols$content) }) test_that("get_usage_shiny works", { shiny_usage <- get_usage_shiny(client) expect_s3_class(shiny_usage, c("tbl_df", "tbl", "data.frame")) - expect_ptype_equal(shiny_usage, connectapi_ptypes$usage_shiny) + expect_datetime_cols(shiny_usage, connectapi_datetime_cols$usage_shiny) }) test_that("get_usage_static works", { content_visits <- get_usage_static(client) expect_s3_class(content_visits, c("tbl_df", "tbl", "data.frame")) - # path was added to usage_static in 2024 - expect_ptype_equal( - content_visits, - connectapi_ptypes$usage_static, - exact = FALSE - ) + expect_datetime_cols(content_visits, connectapi_datetime_cols$usage_static) }) test_that("get_audit_logs works", { audit_list <- get_audit_logs(client) expect_s3_class(audit_list, c("tbl_df", "tbl", "data.frame")) - # This is different on older versions, not sure it's worth worrying about how skip_if_connect_older_than(client, "2022.09.0") - expect_ptype_equal(audit_list, connectapi_ptypes$audit_logs) + expect_datetime_cols(audit_list, connectapi_datetime_cols$audit_logs) }) test_that("get_procs works", { @@ -73,7 +63,6 @@ test_that("get_procs works", { # TODO: This is not a great test, since no processes are running # we could always start a content restoration... expect_s3_class(proc_data, "tbl_df") - expect_ptype_equal(proc_data, connectapi_ptypes$procs) }) # experimental -------------------------------------------- diff --git a/tests/integrated/test-lazy.R b/tests/integrated/test-lazy.R index 36ee6627..bd58543a 100644 --- a/tests/integrated/test-lazy.R +++ b/tests/integrated/test-lazy.R @@ -35,7 +35,7 @@ test_that("users works", { expect_type(colnames(users), "character") expect_gt(length(colnames(users)), 1) - expect_ptype_equal(users_local, connectapi_ptypes$users, exact = FALSE) + expect_datetime_cols(users_local, connectapi_datetime_cols$users) }) test_that("usage_static works", { @@ -49,12 +49,7 @@ test_that("usage_static works", { expect_type(colnames(content_visits), "character") expect_gt(length(colnames(content_visits)), 1) - # path was added in 2024 - expect_ptype_equal( - content_visits_local, - connectapi_ptypes$usage_static, - exact = FALSE - ) + expect_datetime_cols(content_visits_local, connectapi_datetime_cols$usage_static) }) test_that("usage_shiny works", { @@ -68,7 +63,7 @@ test_that("usage_shiny works", { expect_type(colnames(shiny_usage), "character") expect_gt(length(colnames(shiny_usage)), 1) - expect_ptype_equal(shiny_usage_local, connectapi_ptypes$usage_shiny) + expect_datetime_cols(shiny_usage_local, connectapi_datetime_cols$usage_shiny) }) test_that("content works", { @@ -83,13 +78,7 @@ test_that("content works", { expect_type(colnames(content_list), "character") expect_gt(length(colnames(content_list)), 1) - # various attributes have been added over the years, so exact match - # doesn't work against all versions of Connect - expect_ptype_equal( - content_list_local, - connectapi_ptypes$content, - exact = FALSE - ) + expect_datetime_cols(content_list_local, connectapi_datetime_cols$content) }) test_that("groups works", { @@ -103,8 +92,6 @@ test_that("groups works", { expect_true(is.na(nrow(groups_list))) expect_type(colnames(groups_list), "character") expect_gt(length(colnames(groups_list)), 1) - - expect_ptype_equal(groups_list_local, connectapi_ptypes$groups, exact = FALSE) }) test_that("audit_logs works", { @@ -119,7 +106,6 @@ test_that("audit_logs works", { expect_type(colnames(audit_list), "character") expect_gt(length(colnames(audit_list)), 1) - # This is different on older versions, not sure it's worth worrying about how skip_if_connect_older_than(client, "2022.09.0") - expect_ptype_equal(audit_list_local, connectapi_ptypes$audit_logs) + expect_datetime_cols(audit_list_local, connectapi_datetime_cols$audit_logs) }) diff --git a/tests/testthat/test-content.R b/tests/testthat/test-content.R index 164e4706..26da1c4b 100644 --- a/tests/testthat/test-content.R +++ b/tests/testthat/test-content.R @@ -287,31 +287,31 @@ test_that("get_jobs() using the old and new endpoints returns sensible results", jobs_v1_2025_01_0 <- get_jobs(item) }) - # Columns we expect to be identical - common_cols <- c( - "id", - "pid", - "key", - "app_id", - "app_guid", - "content_id", - "content_guid", - "variant_id", - "bundle_id", - "start_time", - "end_time", - "tag", - "exit_code", - "hostname" - ) - expect_identical( - jobs_v1[common_cols], - jobs_v0[common_cols] - ) - expect_identical( - jobs_v1[common_cols], - jobs_v1_2025_01_0[common_cols] + # Columns whose values should match across API versions. + # Some versions return numeric IDs, others return string IDs, so + # we compare as character to ignore type differences. + char_cols <- c("key", "app_guid", "content_guid", "tag", "hostname") + coercible_cols <- c( + "id", "pid", "app_id", "content_id", "variant_id", "bundle_id" ) + datetime_cols <- c("start_time", "end_time") + + expect_identical(jobs_v1[char_cols], jobs_v0[char_cols]) + expect_identical(jobs_v1[char_cols], jobs_v1_2025_01_0[char_cols]) + expect_identical(jobs_v1[datetime_cols], jobs_v0[datetime_cols]) + expect_identical(jobs_v1[datetime_cols], jobs_v1_2025_01_0[datetime_cols]) + for (col in coercible_cols) { + expect_equal( + as.character(jobs_v1[[col]]), + as.character(jobs_v0[[col]]), + info = col + ) + expect_equal( + as.character(jobs_v1[[col]]), + as.character(jobs_v1_2025_01_0[[col]]), + info = col + ) + } # Status columns line up as expected expect_equal(jobs_v1$status, c(0L, 0L, 2L, 2L, 2L, 2L)) @@ -346,40 +346,26 @@ with_mock_api({ test_that("terminate_jobs() returns expected data when active jobs exist", { item <- content_item(client, "8f37d6e0") + res <- terminate_jobs(item) + expect_s3_class(res, "tbl_df") + expect_equal(nrow(res), 2) + expect_equal(res$job_key, c("waaTO7v75I84S1hQ", "k3sHkEoWJNwQim7g")) + expect_equal(res$code, c(163L, NA)) expect_equal( - terminate_jobs(item), - tibble::tibble( - app_id = c(NA, 52389L), - app_guid = c(NA, "8f37d6e0"), - job_key = c("waaTO7v75I84S1hQ", "k3sHkEoWJNwQim7g"), - job_id = c(NA, "40669829"), - result = c(NA, "Order to kill job registered"), - code = c(163L, NA), - error = c( - "The specified job cannot be terminated because it is not active", - NA - ) - ) + res$error, + c("The specified job cannot be terminated because it is not active", NA) ) + expect_equal(res$result, c(NA, "Order to kill job registered")) }) test_that("terminate_jobs() functions as expected with no active jobs", { item <- content_item(client, "01234567") expect_message( - expect_equal( - terminate_jobs(item), - tibble::tibble( - app_id = integer(), - app_guid = character(), - job_key = character(), - job_id = character(), - result = character(), - code = integer(), - error = character() - ) - ), + res <- terminate_jobs(item), "No active jobs found." ) + expect_s3_class(res, "tbl_df") + expect_equal(nrow(res), 0) }) }) @@ -508,45 +494,10 @@ with_mock_dir("2025.09.0", { test_that("search_content() can be converted to a data frame correctly", { content_df <- search_content(client, q = "sea bream") |> as_tibble() - expect_named( - content_df, - c( - "guid", - "name", - "title", - "description", - "access_type", - "connection_timeout", - "read_timeout", - "init_timeout", - "idle_timeout", - "max_processes", - "min_processes", - "max_conns_per_process", - "load_factor", - "created_time", - "last_deployed_time", - "bundle_id", - "app_mode", - "content_category", - "parameterized", - "cluster_name", - "image_name", - "r_version", - "py_version", - "quarto_version", - "run_as", - "run_as_current_user", - "owner_guid", - "content_url", - "dashboard_url", - "app_role", - "vanity_url", - "id", - "owner", - "tags" - ) - ) + expect_s3_class(content_df, "tbl_df") + expect_true("guid" %in% names(content_df)) + expect_true("name" %in% names(content_df)) + expect_true("title" %in% names(content_df)) expect_equal( content_df$title, c("sea bream report", "sea bream dashboard") diff --git a/tests/testthat/test-get.R b/tests/testthat/test-get.R index b73e82a4..1f9f4b06 100644 --- a/tests/testthat/test-get.R +++ b/tests/testthat/test-get.R @@ -242,19 +242,15 @@ test_that("get_packages() works as expected with current return value", { ) ) ) - expect_identical( - get_packages(client), - tibble::tibble( - language = c("python", "python"), - language_version = c("3.7.6", "3.7.7"), - name = c("absl-py", "absl-py"), - version = c("0.12.0", "0.8.1"), - hash = c(NA_character_, NA_character_), - bundle_id = c("9375", "6623"), - content_id = c("4906", "3652"), - content_guid = c("9bf33774", "1935b6cb") - ) - ) + res <- get_packages(client) + expect_identical(res$language, c("python", "python")) + expect_identical(res$language_version, c("3.7.6", "3.7.7")) + expect_identical(res$name, c("absl-py", "absl-py")) + expect_identical(res$version, c("0.12.0", "0.8.1")) + expect_true(all(is.na(res$hash))) + expect_identical(res$bundle_id, c("9375", "6623")) + expect_identical(res$content_id, c("4906", "3652")) + expect_identical(res$content_guid, c("9bf33774", "1935b6cb")) }) test_that("Pagination is wired up correctly for packages method", { @@ -313,19 +309,15 @@ test_that("get_packages() works as expected with `content_guid` names in API res ) ) - expect_identical( - get_packages(client), - tibble::tibble( - language = c("python", "python"), - language_version = c("3.7.6", "3.7.7"), - name = c("absl-py", "absl-py"), - version = c("0.12.0", "0.8.1"), - hash = c(NA_character_, NA_character_), - bundle_id = c("9375", "6623"), - content_id = c("4906", "3652"), - content_guid = c("9bf33774", "1935b6cb") - ) - ) + res <- get_packages(client) + expect_identical(res$language, c("python", "python")) + expect_identical(res$language_version, c("3.7.6", "3.7.7")) + expect_identical(res$name, c("absl-py", "absl-py")) + expect_identical(res$version, c("0.12.0", "0.8.1")) + expect_true(all(is.na(res$hash))) + expect_identical(res$bundle_id, c("9375", "6623")) + expect_identical(res$content_id, c("4906", "3652")) + expect_identical(res$content_guid, c("9bf33774", "1935b6cb")) }) test_that("get_content only requests vanity URLs for Connect 2024.06.0 and up", { diff --git a/tests/testthat/test-parse.R b/tests/testthat/test-parse.R index 8b3cc347..431c5757 100644 --- a/tests/testthat/test-parse.R +++ b/tests/testthat/test-parse.R @@ -1,49 +1,22 @@ -test_that("coerce_fsbytes fills the void", { - expect_s3_class(coerce_fsbytes(1L, fs::as_fs_bytes(NA_integer_)), "fs_bytes") - expect_s3_class(coerce_fsbytes(1, fs::as_fs_bytes(NA_integer_)), "fs_bytes") - expect_error( - coerce_fsbytes(data.frame(), fs::as_fs_bytes(NA_integer_)), - class = "vctrs_error_incompatible_type" - ) -}) - test_that("coerce_datetime fills the void", { chardate <- "2023-10-25T17:04:08Z" numdate <- as.double(Sys.time()) - expect_s3_class(coerce_datetime(chardate, NA_datetime_), "POSIXct") - expect_s3_class( - coerce_datetime(c(chardate, chardate), NA_datetime_), - "POSIXct" - ) - expect_s3_class(coerce_datetime(numdate, NA_datetime_), "POSIXct") - expect_s3_class(coerce_datetime(c(numdate, numdate), NA_datetime_), "POSIXct") - expect_s3_class(coerce_datetime(NA_datetime_, NA_datetime_), "POSIXct") - expect_s3_class( - coerce_datetime(c(NA_datetime_, NA_datetime_), NA_datetime_), - "POSIXct" - ) - expect_s3_class(coerce_datetime(NA_integer_, NA_datetime_), "POSIXct") - expect_s3_class( - coerce_datetime(c(NA_integer_, NA_integer_), NA_datetime_), - "POSIXct" - ) - expect_s3_class(coerce_datetime(NA, NA_datetime_), "POSIXct") - expect_s3_class(coerce_datetime(c(NA, NA), NA), "POSIXct") - expect_s3_class(coerce_datetime(NULL, NA), "POSIXct") - - expect_error( - coerce_datetime(data.frame(), NA_datetime_), - class = "vctrs_error_incompatible_type" - ) - expect_error( - coerce_datetime(list(), NA_datetime_, name = "list"), - class = "vctrs_error_incompatible_type" - ) - - expect_error( - coerce_datetime(NA_complex_, NA_datetime_, name = "complexity"), - class = "vctrs_error_incompatible_type" - ) + na_posixct <- as.POSIXct(NA, tz = Sys.timezone()) + + expect_s3_class(coerce_datetime(chardate), "POSIXct") + expect_s3_class(coerce_datetime(c(chardate, chardate)), "POSIXct") + expect_s3_class(coerce_datetime(numdate), "POSIXct") + expect_s3_class(coerce_datetime(c(numdate, numdate)), "POSIXct") + expect_s3_class(coerce_datetime(na_posixct), "POSIXct") + expect_s3_class(coerce_datetime(c(na_posixct, na_posixct)), "POSIXct") + expect_s3_class(coerce_datetime(NA_integer_), "POSIXct") + expect_s3_class(coerce_datetime(c(NA_integer_, NA_integer_)), "POSIXct") + expect_s3_class(coerce_datetime(NA), "POSIXct") + expect_s3_class(coerce_datetime(c(NA, NA)), "POSIXct") + expect_s3_class(coerce_datetime(NULL), "POSIXct") + + expect_error(coerce_datetime(data.frame()), "Cannot coerce") + expect_error(coerce_datetime(NA_complex_), "Cannot coerce") }) test_that("parse_connect_rfc3339() parses timestamps with offsets as expected", { @@ -173,81 +146,81 @@ test_that("make_timestamp produces expected output", { withr::local_envvar(TZ = "America/New_York") expect_equal( - make_timestamp(coerce_datetime(x_mixed, NA_datetime_)), + make_timestamp(coerce_datetime(x_mixed)), rep(outcome, 2) ) expect_equal( - make_timestamp(coerce_datetime(x_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(x_zero_offset)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_plus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_plus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_minus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_minus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(single_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_zero_offset)), outcome[1] ) expect_equal( - make_timestamp(coerce_datetime(single_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_offset)), outcome[1] ) expect_equal(make_timestamp(outcome), outcome) withr::local_envvar(TZ = "UTC") expect_equal( - make_timestamp(coerce_datetime(x_mixed, NA_datetime_)), + make_timestamp(coerce_datetime(x_mixed)), rep(outcome, 2) ) expect_equal( - make_timestamp(coerce_datetime(x_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(x_zero_offset)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_plus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_plus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_minus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_minus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(single_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_zero_offset)), outcome[1] ) expect_equal( - make_timestamp(coerce_datetime(single_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_offset)), outcome[1] ) expect_equal(make_timestamp(outcome), outcome) withr::local_envvar(TZ = "Asia/Tokyo") expect_equal( - make_timestamp(coerce_datetime(x_mixed, NA_datetime_)), + make_timestamp(coerce_datetime(x_mixed)), rep(outcome, 2) ) expect_equal( - make_timestamp(coerce_datetime(x_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(x_zero_offset)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_plus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_plus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(x_minus_one, NA_datetime_)), + make_timestamp(coerce_datetime(x_minus_one)), outcome ) expect_equal( - make_timestamp(coerce_datetime(single_zero_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_zero_offset)), outcome[1] ) expect_equal( - make_timestamp(coerce_datetime(single_offset, NA_datetime_)), + make_timestamp(coerce_datetime(single_offset)), outcome[1] ) expect_equal(make_timestamp(outcome), outcome) @@ -260,50 +233,9 @@ test_that("make_timestamp is safe for strings", { expect_equal(make_timestamp(NA_character_), NA_character_) }) -test_that("make_timestamp converts to character", { - expect_type(make_timestamp(NA_datetime_), "character") -}) - -test_that("ensure_column works with lists", { - list_chk_null <- ensure_column(tibble::tibble(), NA_list_, "hello") - expect_s3_class(list_chk_null, "tbl_df") - expect_type(list_chk_null$hello, "list") - - list_chk_same <- ensure_column( - tibble::tibble(hello = list(list(1, 2, 3), list(1, 2, 3, 4))), - NA_list_, - "hello" - ) - expect_s3_class(list_chk_same, "tbl_df") - expect_type(list_chk_same$hello, "list") -}) - -test_that("ensure_column works with POSIXct", { - time_chk_null <- ensure_column(tibble::tibble(), NA_datetime_, "hello") - expect_s3_class(time_chk_null, "tbl_df") - expect_s3_class(time_chk_null$hello, "POSIXct") - - time_chk_some <- ensure_column( - tibble::tibble(one = c(1, 2, 3)), - NA_datetime_, - "hello" - ) - expect_s3_class(time_chk_some, "tbl_df") - expect_s3_class(time_chk_some$hello, "POSIXct") - - skip("Ahh! this fails presently. Are double -> POSIXct conversions allowed?") - time_chk_convert <- ensure_column( - tibble::tibble(hello = c(1, 2, 3)), - NA_datetime_, - "hello" - ) - expect_s3_class(time_chk_convert, "tbl_df") - expect_s3_class(time_chk_convert$hello, "POSIXct") -}) - -test_that("converts length one list", { - hm <- ensure_column(tibble::tibble(one = "hi"), NA_list_, "one") - expect_type(hm$one, "list") +test_that("make_timestamp converts POSIXct to character", { + na_posixct <- as.POSIXct(NA, tz = Sys.timezone()) + expect_type(make_timestamp(na_posixct), "character") }) test_that("parse_connectapi handles mixed null/non-null character values", { @@ -344,3 +276,27 @@ test_that("parse_connectapi handles mixed null/non-null integer timestamps", { expect_type(result$end_time, "double") expect_identical(result$end_time, c(NA_real_, 1732556770)) }) + +test_that("parse_connectapi_typed converts datetime columns", { + data <- list( + list(guid = "aaa", created_time = "2023-08-22T14:13:14Z"), + list(guid = "bbb", created_time = "2020-01-01T01:02:03Z") + ) + + result <- parse_connectapi_typed(data, c("created_time")) + expect_s3_class(result, "tbl_df") + expect_s3_class(result$created_time, "POSIXct") + expect_type(result$guid, "character") +}) + +test_that("parse_connectapi_typed leaves non-datetime columns alone", { + data <- list( + list(guid = "aaa", name = "first"), + list(guid = "bbb", name = "second") + ) + + result <- parse_connectapi_typed(data, character()) + expect_s3_class(result, "tbl_df") + expect_type(result$guid, "character") + expect_type(result$name, "character") +})