diff --git a/CHANGELOG.md b/CHANGELOG.md index efe83e1..277fac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,27 @@ ## NEW FUNCTIONALITY -* Add support for more loaders (PR #81). +- Add support for more loaders (PR #81). Currently supported: `.csv`, `.h5ad`, `.html`, `.jpg`, `.json`, `.parquet`, `.png`, `.rds`, `.svg`, `.tsv`, `.yaml`. - Planned: `.fcs`, `.h5mu`, `.zarr`. +- Add a `from_df()` method to the `Registry` class to create new artifacts from data frames (PR #78) +- Create `TemporaryRecord` classes for new artifacts before they have been saved to the database (PR #78) +- Add a `delete()` method to the `Record` class (PR #78) + +## MAJOR CHANGES + +- Running `connect(slug = NULL)` now connects to the default instance that is allowed to create records. + The default instance must be changed using the Lamin CLI. (PR #78) +- User setting are stored in a global option the first time `connect()` is run (PR #78) + +## TESTING + +- Add a test for creating artifacts from data frames (PR #78). ## DOCUMENTATION -* Updated installation instructions after **{laminr}** was released on CRAN (PR #74). +- Updated installation instructions after **{laminr}** was released on CRAN (PR #74). +- Updated the architecture vignette to include new methods and the new `TemporaryRecord` class (PR #78) +- Updated the development vignette with new functionality (PR #78) # laminr v0.1.0 diff --git a/NAMESPACE b/NAMESPACE index a839566..0779ac4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -24,7 +24,7 @@ importFrom(purrr,map_lgl) importFrom(purrr,modify_depth) importFrom(purrr,pmap) importFrom(purrr,reduce) -importFrom(purrr,set_names) importFrom(purrr,transpose) importFrom(purrr,walk) +importFrom(rlang,set_names) importFrom(tools,file_ext) diff --git a/R/Instance.R b/R/Instance.R index f015da5..6f4b111 100644 --- a/R/Instance.R +++ b/R/Instance.R @@ -1,4 +1,4 @@ -create_instance <- function(instance_settings) { +create_instance <- function(instance_settings, is_default = FALSE) { super <- NULL # satisfy linter api <- InstanceAPI$new(instance_settings = instance_settings) @@ -46,19 +46,46 @@ create_instance <- function(instance_settings) { cloneable = FALSE, inherit = Instance, public = list( - initialize = function(settings, api, schema) { + initialize = function(settings, api, schema, is_default, py_lamin) { super$initialize( settings = settings, api = api, - schema = schema + schema = schema, + is_default = is_default, + py_lamin = py_lamin ) } ), active = active ) + py_lamin <- NULL + if (isTRUE(is_default)) { + check_requires("Connecting to Python", "reticulate", type = "warning") + + py_lamin <- tryCatch( + reticulate::import("lamindb"), + error = function(err) { + cli::cli_warn(c( + paste( + "Failed to connect to the Python {.pkg lamindb} package,", + "you will not be able to create records" + ), + "i" = "See {.run reticulate::py_config()} for more information" + )) + NULL + } + ) + } + # create the instance - RichInstance$new(settings = instance_settings, api = api, schema = schema) + RichInstance$new( + settings = instance_settings, + api = api, + schema = schema, + is_default = is_default, + py_lamin = py_lamin + ) } #' @title Instance @@ -103,9 +130,13 @@ Instance <- R6::R6Class( # nolint object_name_linter #' @param settings The settings for the instance #' @param api The API for the instance #' @param schema The schema for the instance - initialize = function(settings, api, schema) { + #' @param is_default Logical, whether this is the default instance + #' @param py_lamin A Python `lamindb` module object + initialize = function(settings, api, schema, is_default, py_lamin) { private$.settings <- settings private$.api <- api + private$.is_default <- is_default + private$.py_lamin <- py_lamin # create module classes from the schema private$.module_classes <- map( @@ -158,6 +189,12 @@ Instance <- R6::R6Class( # nolint object_name_linter get_api = function() { private$.api }, + #' @description Get the Python lamindb module + #' + #' @return Python lamindb module. + get_py_lamin = function() { + private$.py_lamin + }, #' @description #' Print an `Instance` #' @@ -246,9 +283,18 @@ Instance <- R6::R6Class( # nolint object_name_linter ) } ), + active = list( + #' @field is_default (`logical(1)`)\cr + #' Whether this is the default instance. + is_default = function() { + private$.is_default + } + ), private = list( .settings = NULL, .api = NULL, - .module_classes = NULL + .module_classes = NULL, + .is_default = NULL, + .py_lamin = NULL ) ) diff --git a/R/InstanceAPI.R b/R/InstanceAPI.R index ff4dd9b..1038b53 100644 --- a/R/InstanceAPI.R +++ b/R/InstanceAPI.R @@ -197,6 +197,49 @@ InstanceAPI <- R6::R6Class( # nolint object_name_linter private$process_response(response, "get record") }, #' @description + #' Delete a record from the instance. + delete_record = function(module_name, + registry_name, + id_or_uid, + verbose = FALSE) { + user_settings <- .get_user_settings() + if (is.null(user_settings$access_token)) { + cli::cli_abort(c( + "There is no access token for the current user", + "i" = "Run {.code lamin login} and reconnect to the database in a new R session" + )) + } + + url <- paste0( + private$.instance_settings$api_url, + "/instances/", + private$.instance_settings$id, + "/modules/", + module_name, + "/", + registry_name, + "/", + id_or_uid, + "?schema_id=", + private$.instance_settings$schema_id + ) + + if (verbose) { + cli_inform("URL: {url}") + } + + response <- httr::DELETE( + url, + httr::add_headers( + accept = "application/json", + `Content-Type` = "application/json", + Authorization = paste("Bearer", user_settings$access_token) + ) + ) + + private$process_response(response, "delete record") + }, + #' @description #' Print an `API` #' #' @param style Logical, whether the output is styled using ANSI codes diff --git a/R/Record.R b/R/Record.R index 605ea32..4989f68 100644 --- a/R/Record.R +++ b/R/Record.R @@ -45,6 +45,78 @@ create_record_class <- function(instance, registry, api) { RichRecordClass } +#' Create a temporary record class +#' +#' @param record_class A generator for a standard record class +#' +#' @details +#' The classes generated by this function inherit from a standard record class +#' and represent the situation where a new record has being created using Python +#' `lamindb` but has not yet been saved to the database. It should behave the +#' same as the standard class but indicate to the user that it has not yet been +#' saved. Saving is performed using the Python record object stored in the R +#' temporary record. After saving, the data in the object is replaced with that +#' in the database, indications that it has not been saved are removed and +#' further saving is prevented. In examples etc. the `$save()` should usually be +#' called immediately to avoid users seeing the temporary record in it's unsaved +#' state. +#' +#' @return The temporary record class R6 generator +#' @noRd +create_temporary_record_class <- function(record_class) { + super <- NULL # Satisfy checks + self <- NULL # Satisfy checks + private <- NULL # Satisfy checks + + R6::R6Class( + paste0("Temporary", record_class$classname), + cloneable = FALSE, + inherit = record_class, + public = list( + initialize = function(py_record, data) { + private$.record_class <- record_class + private$.py_record <- py_record + + super$initialize(data) + }, + save = function() { + if (isTRUE(private$.saved)) { + cli::cli_abort("This record has already been saved to the database") + } + + private$.py_record$save() + + # Replace temporary data with data saved to the database + private$.data <- private$.api$get_record( + module_name = private$.registry$module$name, + registry_name = private$.registry$name, + id_or_uid = self$uid + ) + + private$.saved <- TRUE + }, + print = function(style = TRUE) { + if (isFALSE(private$.saved)) { + cli::cat_line(paste( + cli::bg_red(cli::col_black("TEMPORARY")), + cli::format_message(paste( + "This record has not been saved to the database.", + "Save it using {.code $save()}." + )) + )) + } + + super$print() + } + ), + private = list( + .record_class = NULL, + .py_record = NULL, + .saved = FALSE + ) + ) +} + #' @title Record #' #' @description @@ -98,6 +170,22 @@ Record <- R6::R6Class( # nolint object_name_linter } }, #' @description + #' Delete a `Record` + #' + #' @param verbose Whether to print details of the API call + #' + #' @return `TRUE` invisibly if the deletion is successful + delete = function(verbose = FALSE) { + response <- private$.api$delete_record( + module_name = private$.registry$module$name, + registry_name = private$.registry$name, + id_or_uid = self$uid, + verbose = verbose + ) + + invisible(TRUE) + }, + #' @description #' Print a `Record` #' #' @param style Logical, whether the output is styled using ANSI codes @@ -120,12 +208,28 @@ Record <- R6::R6Class( # nolint object_name_linter "key" ) - record_fields <- private$.api$get_record( - module_name = private$.registry$module$name, - registry_name = private$.registry$name, - id_or_uid = private$.data[["uid"]], - include_foreign_keys = TRUE - ) + expected_fields <- private$.registry$get_fields() |> + discard(~ is.null(.x$column_name)) |> + map_chr("column_name") + + record_fields <- map(names(expected_fields), function(.field) { + value <- tryCatch( + self[[.field]], + error = function(err) { + if (!grepl("status code 404", conditionMessage(err))) { + cli::cli_abort(conditionMessage(err)) + } + NULL + } + ) + + if (inherits(value, "Record")) { + value <- value$id + } + + value + }) |> + set_names(expected_fields) # Get the important fields that are in the record important_fields <- intersect(important_fields, names(record_fields)) diff --git a/R/Registry.R b/R/Registry.R index be0c1b8..92f7fa1 100644 --- a/R/Registry.R +++ b/R/Registry.R @@ -139,6 +139,51 @@ Registry <- R6::R6Class( # nolint object_name_linter list_rbind() }, #' @description + #' Create a record from a data frame + #' + #' @param dataframe The `data.frame` to create a record from + #' @param key A relative path within the default storage + #' @param description A string describing the record + #' @param run A `Run` object that creates the record + #' + #' @details + #' Creating records is only possible for the default instance, requires the + #' Python `lamindb` module and is only implemented for the core `Artifact` + #' registry. + #' + #' @return A `TemporaryRecord` object containing the new record. This is not + #' saved to the database until `temp_record$save()` is called. + from_df = function(dataframe, key = NULL, description = NULL, run = NULL) { + if (isFALSE(private$.instance$is_default)) { + cli::cli_abort(c( + "Only the default instance can create records", + "i" = "Use {.code connect(slug = NULL)} to connect to the default instance" + )) + } + + if (is.null(private$.instance$get_py_lamin())) { + cli::cli_abort(c( + "Creating records requires the Python lamindb package", + "i" = "Check the output of {.code connect()} for warnings" + )) + } + + if (private$.registry_name != "artifact") { + cli::cli_abort( + "Creating records from data frames is only supported for the Artifact registry" + ) + } + + py_lamin <- private$.instance$get_py_lamin() + + py_record <- py_lamin$Artifact$from_df( + dataframe, + key = key, description = description, run = run + ) + + create_record_from_python(py_record, private$.instance) + }, + #' @description #' Get the fields in the registry. #' #' @return A list of [Field] objects. @@ -171,6 +216,21 @@ Registry <- R6::R6Class( # nolint object_name_linter private$.record_class }, #' @description + #' Get the temporary record class for the registry. + #' + #' Note: This method is intended for internal use only and may be removed in the future. + #' + #' @return A `TemporaryRecord` class. + get_temporary_record_class = function() { + if (is.null(private$.temporary_record_class)) { + private$.temporary_record_class <- create_temporary_record_class( + private$.record_class + ) + } + + private$.temporary_record_class + }, + #' @description #' Print a `Registry` #' #' @param style Logical, whether the output is styled using ANSI codes @@ -301,7 +361,7 @@ Registry <- R6::R6Class( # nolint object_name_linter list( paste0("[", paste(map_chr(module_fields, "field_name"), collapse = ", "), "]") ) |> - setNames(module_heading) |> + set_names(module_heading) |> make_key_value_strings(quote_strings = FALSE) }) @@ -321,7 +381,8 @@ Registry <- R6::R6Class( # nolint object_name_linter .class_name = NULL, .is_link_table = NULL, .fields = NULL, - .record_class = NULL + .record_class = NULL, + .temporary_record_class = NULL ), active = list( #' @field module ([Module])\cr @@ -346,3 +407,58 @@ Registry <- R6::R6Class( # nolint object_name_linter } ) ) + +#' Create record from Python +#' +#' @param py_record A Python record object +#' @param instance `Instance` object to create the record for +#' +#' @details +#' The new record is created by: +#' +#' 1. Getting the module and registry from the Python class +#' 2. Getting the fields for this registry +#' 3. Iteratively getting the data for each field. Values that are records are +#' converted by calling this function. +#' 4. Get the matching temporary record class +#' 5. Return the temporary record +#' +#' @return The created `TemporaryRecord` object +#' @noRd +create_record_from_python <- function(py_record, instance) { + py_classes <- class(py_record) + + # Skip related fields for now + if ("django.db.models.manager.Manager" %in% py_classes) { + return(NULL) + } + + class_split <- strsplit(py_classes[1], "\\.")[[1]] + module_name <- class_split[1] + if (module_name == "lnschema_core") { + module_name <- "core" + } + registry_name <- tolower(class_split[3]) + + registry <- instance$get_module(module_name)$get_registry(registry_name) + fields <- registry$get_field_names() + + record_list <- map(fields, function(.field) { + value <- tryCatch( + py_record[[.field]], + error = function(err) { + NULL + } + ) + if (inherits(value, "lnschema_core.models.Record")) { + value <- create_record_from_python(value, instance) + } + value + }) |> + set_names(fields) + + temp_record_class <- registry$get_temporary_record_class() + + # Suppress warnings because we deliberately add unexpected data fields + suppressWarnings(temp_record_class$new(py_record, record_list)) +} diff --git a/R/connect.R b/R/connect.R index 7fc5594..23720fb 100644 --- a/R/connect.R +++ b/R/connect.R @@ -20,6 +20,8 @@ #' instance #' } connect <- function(slug = NULL) { + user_settings <- .get_user_settings() + instance_file <- if (is.null(slug)) { # if the slug is null, see if we can load the default instance @@ -80,7 +82,27 @@ connect <- function(slug = NULL) { } } - create_instance(instance_settings = instance_settings) + is_default <- FALSE + if (is.null(slug)) { + instance_slug <- paste0( + instance_settings$owner, "/", + name = instance_settings$name + ) + current_default <- getOption("LAMINR_DEFAULT_INSTANCE") + if (!is.null(current_default)) { + if (!identical(instance_slug, current_default)) { + cli::cli_abort(c( + "There is already a default instance {.field {current_default}}", + "i" = "To connect to another instance provide a slug" + )) + } + } else { + options(LAMINR_DEFAULT_INSTANCE = instance_slug) + } + is_default <- TRUE + } + + create_instance(instance_settings, is_default) } # nolint start: object_length_linter @@ -101,7 +123,7 @@ connect <- function(slug = NULL) { owner <- split[[1]] name <- split[[2]] } else { - user_settings <- .settings_load__load_or_create_user_settings() + user_settings <- .get_user_settings() owner <- user_settings$handle name <- identifier diff --git a/R/laminr-package.R b/R/laminr-package.R index 2b04b2d..1e2c4f5 100644 --- a/R/laminr-package.R +++ b/R/laminr-package.R @@ -3,8 +3,9 @@ ## usethis namespace: start #' @importFrom cli cli_abort cli_warn cli_inform -#' @importFrom R6 R6Class #' @importFrom httr GET POST content add_headers -#' @importFrom purrr map map_chr map_lgl map2 pmap set_names list_flatten transpose discard keep list_c walk reduce +#' @importFrom purrr map map_chr map_lgl map2 pmap list_flatten transpose discard keep list_c walk reduce +#' @importFrom R6 R6Class +#' @importFrom rlang set_names ## usethis namespace: end NULL diff --git a/R/settings_load.R b/R/settings_load.R index 444cc25..1a088ed 100644 --- a/R/settings_load.R +++ b/R/settings_load.R @@ -77,3 +77,14 @@ .settings_load__setup_user_from_store <- function(store) { # nolint object_length_linter UserSettings$new(store) } + +.get_user_settings <- function() { + user_settings <- getOption("LAMINR_USER_SETTINGS") + + if (is.null(user_settings)) { + user_settings <- .settings_load__load_or_create_user_settings() + options("LAMINR_USER_SETTINGS" = user_settings) + } + + user_settings +} diff --git a/R/utils.R b/R/utils.R index 96bcd21..f6de669 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,34 +1,42 @@ #' Check required packages #' -#' Check that required packages are available and give a nice error message with +#' Check that required packages are available and give a nice message with #' install instructions if not #' #' @param what A message stating what the packages are required for. Used at the #' start of the error message e.g. "{what} requires...". #' @param requires Character vector of required package names +#' @param type Type of message to give if packages are missing #' -#' @return `TRUE` invisibly if all packages are available, otherwise calls -#' [cli::cli_abort()] +#' @return Invisibly, Boolean whether or not all packages are available or +#' raises an error if any are missing and `type = "error"` #' @noRd -check_requires <- function(what, requires) { +check_requires <- function(what, requires, type = c("error", "warning")) { + type <- match.arg(type) + is_available <- map_lgl(requires, requireNamespace, quietly = TRUE) + msg_fun <- switch(type, + error = cli::cli_abort, + warning = cli::cli_warn + ) + if (any(!is_available)) { missing <- requires[!is_available] missing_str <- paste0("'", paste(missing, collapse = "', '"), "'") # nolint object_usage_linter - cli_abort( + msg_fun( c( "{what} requires the {.pkg {missing}} package{?s}", "i" = paste( - "To continue, install {cli::qty(missing)}{?it/them} using", - "{.code install.packages(c({missing_str}))}" + "Install {cli::qty(missing)}{?it/them} using", + "{.run install.packages(c({missing_str}))}" ) ), call = rlang::caller_env() ) } - invisible(TRUE) + invisible(all(is_available)) } #' Check if we are in a knitr notebook diff --git a/README.md b/README.md index 745cbcd..c41ba00 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ LaminDB is accompanied by LaminHub which is a data collaboration hub built on La - Load artifacts into memory. - Currently supported file formats: `.csv`, `.h5ad`, `.html`, `.jpg`, `.json`, `.parquet`, `.png`, `.rds`, `.svg`, `.tsv`, `.yaml`. - Planned: `.fcs`, `.h5mu`, `.zarr`. +- Create records from data frames. +- Delete records. See the development roadmap for more details (`vignette("development", package = "laminr")`). diff --git a/man/Instance.Rd b/man/Instance.Rd index 2d1e7c6..7d09979 100644 --- a/man/Instance.Rd +++ b/man/Instance.Rd @@ -33,6 +33,14 @@ artifact$id artifact$load() } } +\section{Active bindings}{ +\if{html}{\out{
}} +\describe{ +\item{\code{is_default}}{(\code{logical(1)})\cr +Whether this is the default instance.} +} +\if{html}{\out{
}} +} \section{Methods}{ \subsection{Public methods}{ \itemize{ @@ -42,6 +50,7 @@ artifact$load() \item \href{#method-Instance-get_module_names}{\code{Instance$get_module_names()}} \item \href{#method-Instance-get_settings}{\code{Instance$get_settings()}} \item \href{#method-Instance-get_api}{\code{Instance$get_api()}} +\item \href{#method-Instance-get_py_lamin}{\code{Instance$get_py_lamin()}} \item \href{#method-Instance-print}{\code{Instance$print()}} \item \href{#method-Instance-to_string}{\code{Instance$to_string()}} } @@ -53,7 +62,7 @@ artifact$load() Creates an instance of this R6 class. This class should not be instantiated directly, but rather by connecting to a LaminDB instance using the \code{\link[=connect]{connect()}} function. \subsection{Usage}{ -\if{html}{\out{
}}\preformatted{Instance$new(settings, api, schema)}\if{html}{\out{
}} +\if{html}{\out{
}}\preformatted{Instance$new(settings, api, schema, is_default, py_lamin)}\if{html}{\out{
}} } \subsection{Arguments}{ @@ -64,6 +73,10 @@ but rather by connecting to a LaminDB instance using the \code{\link[=connect]{c \item{\code{api}}{The API for the instance} \item{\code{schema}}{The schema for the instance} + +\item{\code{is_default}}{Logical, whether this is the default instance} + +\item{\code{py_lamin}}{A Python \code{lamindb} module object} } \if{html}{\out{}} } @@ -145,6 +158,19 @@ The API for the instance. } } \if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Instance-get_py_lamin}{}}} +\subsection{Method \code{get_py_lamin()}}{ +Get the Python lamindb module +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Instance$get_py_lamin()}\if{html}{\out{
}} +} + +\subsection{Returns}{ +Python lamindb module. +} +} +\if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Instance-print}{}}} \subsection{Method \code{print()}}{ diff --git a/man/Record.Rd b/man/Record.Rd index 133a9a1..b414a80 100644 --- a/man/Record.Rd +++ b/man/Record.Rd @@ -10,6 +10,7 @@ A record from a registry. \subsection{Public methods}{ \itemize{ \item \href{#method-Record-new}{\code{Record$new()}} +\item \href{#method-Record-delete}{\code{Record$delete()}} \item \href{#method-Record-print}{\code{Record$print()}} \item \href{#method-Record-to_string}{\code{Record$to_string()}} } @@ -39,6 +40,26 @@ but rather by connecting to a LaminDB instance using the \code{\link[=connect]{c } } \if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Record-delete}{}}} +\subsection{Method \code{delete()}}{ +Delete a \code{Record} +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Record$delete(verbose = FALSE)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{verbose}}{Whether to print details of the API call} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +\code{TRUE} invisibly if the deletion is successful +} +} +\if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Record-print}{}}} \subsection{Method \code{print()}}{ diff --git a/man/Registry.Rd b/man/Registry.Rd index aade740..d1d3c9c 100644 --- a/man/Registry.Rd +++ b/man/Registry.Rd @@ -29,10 +29,12 @@ Whether the registry is a link table.} \item \href{#method-Registry-new}{\code{Registry$new()}} \item \href{#method-Registry-get}{\code{Registry$get()}} \item \href{#method-Registry-df}{\code{Registry$df()}} +\item \href{#method-Registry-from_df}{\code{Registry$from_df()}} \item \href{#method-Registry-get_fields}{\code{Registry$get_fields()}} \item \href{#method-Registry-get_field}{\code{Registry$get_field()}} \item \href{#method-Registry-get_field_names}{\code{Registry$get_field_names()}} \item \href{#method-Registry-get_record_class}{\code{Registry$get_record_class()}} +\item \href{#method-Registry-get_temporary_record_class}{\code{Registry$get_temporary_record_class()}} \item \href{#method-Registry-print}{\code{Registry$print()}} \item \href{#method-Registry-to_string}{\code{Registry$to_string()}} } @@ -110,6 +112,39 @@ A data.frame containing the available records } } \if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Registry-from_df}{}}} +\subsection{Method \code{from_df()}}{ +Create a record from a data frame +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Registry$from_df(dataframe, key = NULL, description = NULL, run = NULL)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{dataframe}}{The \code{data.frame} to create a record from} + +\item{\code{key}}{A relative path within the default storage} + +\item{\code{description}}{A string describing the record} + +\item{\code{run}}{A \code{Run} object that creates the record} +} +\if{html}{\out{
}} +} +\subsection{Details}{ +Creating records is only possible for the default instance, requires the +Python \code{lamindb} module and is only implemented for the core \code{Artifact} +registry. +} + +\subsection{Returns}{ +A \code{TemporaryRecord} object containing the new record. This is not +saved to the database until \code{temp_record$save()} is called. +} +} +\if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Registry-get_fields}{}}} \subsection{Method \code{get_fields()}}{ @@ -171,6 +206,21 @@ A \link{Record} class. } } \if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Registry-get_temporary_record_class}{}}} +\subsection{Method \code{get_temporary_record_class()}}{ +Get the temporary record class for the registry. + +Note: This method is intended for internal use only and may be removed in the future. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{Registry$get_temporary_record_class()}\if{html}{\out{
}} +} + +\subsection{Returns}{ +A \code{TemporaryRecord} class. +} +} +\if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Registry-print}{}}} \subsection{Method \code{print()}}{ diff --git a/man/laminr-package.Rd b/man/laminr-package.Rd index ea87da4..0f2dd61 100644 --- a/man/laminr-package.Rd +++ b/man/laminr-package.Rd @@ -4,9 +4,9 @@ \name{laminr-package} \alias{laminr} \alias{laminr-package} -\title{laminr: 'LaminDB' Interface in R} +\title{laminr: Interface for 'LaminDB'} \description{ -Interact with 'LaminDB' from R. 'LaminDB' is an open-source data framework for biology. This package allows you to query and download data from 'LaminDB' instances. +Interact with 'LaminDB'. 'LaminDB' is an open-source data framework for biology. This package allows you to query and download data from 'LaminDB' instances. } \seealso{ Useful links: diff --git a/tests/testthat/test-Artifact.R b/tests/testthat/test-Artifact.R new file mode 100644 index 0000000..acf0747 --- /dev/null +++ b/tests/testthat/test-Artifact.R @@ -0,0 +1,22 @@ +skip_if_offline() + +test_that("creating an artifact from a data frame works", { + skip_if_not_installed("reticulate") + skip_if_not(reticulate::py_module_available("lamindb")) + + local_setup_lamindata_instance() + + db <- connect() + + dataframe <- data.frame( + Description = "laminr test data frame", + Timestamp = Sys.time() + ) + + new_artifact <- db$Artifact$from_df( + dataframe, + description = dataframe$Description + ) + + expect_s3_class(new_artifact, "TemporaryArtifact") +}) diff --git a/tests/testthat/test-core_loaders.R b/tests/testthat/test-core_loaders.R index 7bc3137..0107882 100644 --- a/tests/testthat/test-core_loaders.R +++ b/tests/testthat/test-core_loaders.R @@ -39,6 +39,7 @@ test_that("load_file with a .tsv works", { test_that("load_file with an .h5ad works", { skip_if_not_installed("anndata") skip_if_not_installed("reticulate") + skip_if_not(reticulate::py_module_available("anndata")) file <- withr::local_file(tempfile(fileext = ".h5ad")) diff --git a/vignettes/architecture.qmd b/vignettes/architecture.qmd index ba945f5..41e5fff 100644 --- a/vignettes/architecture.qmd +++ b/vignettes/architecture.qmd @@ -75,7 +75,6 @@ Fields define the type of data that can be stored in a registry and provide a wa For more information about fields, see `?Field`. The fields of core registries are documented in the `module_core` vignette: `vignette("module_core", package = "laminr")`. - ### Record A **record** is a single entry within a registry. @@ -88,7 +87,6 @@ In essence, you have **instances** that contain **modules**. Each module contains **registries**, which in turn hold **records**. Every record is composed of multiple **fields**. This hierarchical structure allows for flexible and organized management of data and metadata within LaminDB. - ## Class structure The `laminr` package provides a set of classes that mirror the core concepts of LaminDB. @@ -164,6 +162,8 @@ classDiagram +initialize(InstanceSettings Instance_settings) +get_schema(): Map +get_record(...): Map + +get_records(...): Map + +delete_record(...): NULL } class Module{ +initialize( @@ -187,8 +187,10 @@ classDiagram +get_field(String field_name): Field +get_field_names(): String[] +get(String id_or_uid, Bool include_foreign_keys, List~String~ select, Bool verbose): RichRecord - +get_registry_class(): RichRecordClass + +get_record_class(): RichRecordClass + +get_temporary_record_class(): TemporaryRecordClass +df(Integer limit, Bool verbose): DataFrame + +from_df(DataFrame dataframe, String key, String description, String run)): TemporaryRecord } class Field{ +initialize( @@ -211,6 +213,7 @@ classDiagram class Record{ +initialize(Instance Instance, Registry registry, API api, Map data): Record +get_value(String field_name): Any + +delete(): NULL } class RelatedRecords{ +initialize( @@ -285,9 +288,11 @@ classDiagram Bionty --|> Module RichInstance --> Bionty Registry --> RichRecord + Registry --> TemporaryRecord RichRecord --|> Record + TemporaryRecord --|> RichRecord Registry --> Artifact - Artifact --|> Record + Artifact --|> RichRecord %% ------------------------------------------------------------------------- %% --- Copied from base diagram -------------------------------------------- @@ -324,6 +329,8 @@ classDiagram +initialize(InstanceSettings Instance_settings) +get_schema(): Map +get_record(...): Map + +get_records(...): Map + +delete_record(...): NULL } class Module{ +initialize( @@ -347,8 +354,10 @@ classDiagram +get_field(String field_name): Field +get_field_names(): String[] +get(String id_or_uid, Bool include_foreign_keys, List~String~ select, Bool verbose): RichRecord - +get_registry_class(): RichRecordClass + +get_record_class(): RichRecordClass + +get_temporary_record_class(): TemporaryRecordClass +df(Integer limit, Bool verbose): DataFrame + +from_df(DataFrame dataframe, String key, String description, String run)): TemporaryRecord } class Field{ +initialize( @@ -371,6 +380,7 @@ classDiagram class Record{ +initialize(Instance Instance, Registry registry, API api, Map data): Record +get_value(String field_name): Any + +delete(): NULL } class RelatedRecords{ +initialize( @@ -378,6 +388,7 @@ classDiagram #emsp;String related_to, API api ): RelatedRecords +df(): DataFrame + +field: Field } %% ------------------------------------------------------------------------- @@ -412,6 +423,10 @@ classDiagram +...field value accessors... } style RichRecord fill:#ffe1c9 + class TemporaryRecord{ + +save(): NULL + } + style TemporaryRecord fill:#ffe1c9 class Artifact{ +...field value accessors... +cache(): String diff --git a/vignettes/development.qmd b/vignettes/development.qmd index f6d6159..a836d8c 100644 --- a/vignettes/development.qmd +++ b/vignettes/development.qmd @@ -45,7 +45,8 @@ This document outlines the features of the **{laminr}** package and the roadmap ### Manage data & metadata * [ ] **Create artifacts**: Create new artifacts from various data sources (e.g., files, data frames, in-memory objects). -* [ ] **Save artifacts**: Save artifacts to LaminDB with appropriate metadata. + - [x] `$from_df()`: Create an artifact from a data frame. +* [x] **Save artifacts**: Save artifacts to LaminDB with appropriate metadata. * [ ] **Load artifacts**: Load artifacts from LaminDB into R: - [x] `csv`: Load a data frame from a CSV file. - [ ] `fcs`: Load flow cytometry data. @@ -65,6 +66,7 @@ This document outlines the features of the **{laminr}** package and the roadmap - [x] `s3`: Interact with S3 storage. - [ ] `gcp`: Interact with Google Cloud Storage. * [ ] **Version artifacts**: Create new versions of artifacts. +* [x] **Delete artifacts**: Delete an existing artifact. * [ ] **Manage artifact metadata**: Add, update, and delete artifact metadata. * [ ] **Work with collections**: Create, manage, and query collections of artifacts. @@ -103,7 +105,7 @@ This document outlines the features of the **{laminr}** package and the roadmap ### Transfer data -* [ ] **Upload data**: Upload data files to LaminDB storage. +* [x] **Upload data**: Upload data files to LaminDB storage. * [x] **Download data**: Download data files from LaminDB storage. * [ ] **(Advanced) Support zero-copy data transfer**: Implement efficient data transfer mechanisms. @@ -122,7 +124,7 @@ A first version of the package that allows users to: ### Version 0.2.0 * Expand query functionality with comparators, relationships, and pagination. -* Implement basic data and metadata management features (create, save, load artifacts). +* Implement basic data and metadata management features (create, save, load and delete artifacts). * Expand support for different data formats and storage backends. ### Version 0.3.0