Skip to content

Commit e6ab9c0

Browse files
committed
Add exported writeDatapackage() envelope assembler
Factor the datapackage.json envelope step out of the SummarizedExperiment and MultiAssayExperiment writeParquet() methods into a new exported writeDatapackage(model, resources, path, main_exp_name, annotations), and have both methods single-source their manifest assembly through it. This lets producers that build resources incrementally -- streaming a dataset too large to hold in memory, or promoting from another store -- emit a conformant datapackage.json without reconstructing an in-memory object; NULL descriptors from append/streaming parts are dropped. Purely additive -- existing writeParquet()/readParquet() behaviour is unchanged: datapackage-schema conformance, enum, and parquet round-trip tests all pass. Adds test-writeDatapackage.R and a "Targeting the storage contract" vignette section framing the layout as a targetable Frictionless contract (write via writeDatapackage(), read/attach via readParquet() and the DuckDBMatrix()/DuckDBArray()/DuckDBTable() constructors).
1 parent a68caf3 commit e6ab9c0

8 files changed

Lines changed: 350 additions & 15 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Package: BiocDuckDB
2-
Version: 0.99.2
2+
Version: 0.99.3
33
Date: 2026-07-22
44
Title: Bioconductor DuckDB Integration and High-Level I/O
55
Description:
@@ -96,5 +96,6 @@ Collate:
9696
'SingleCellExperiment-rowTables.R'
9797
'fieldtypes.R'
9898
'readParquet.R'
99+
'writeDatapackage.R'
99100
'writeParquet.R'
100101
Config/roxygen2/version: 8.0.0

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export(spatialCoordinateSystems)
2525
export(spatialElementJoin)
2626
export(spatialViews)
2727
export(validateSpatialMap)
28+
export(writeDatapackage)
2829
export(writeParquet)
2930
exportMethods("colPair<-")
3031
exportMethods("colPairs<-")

NEWS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
# BiocDuckDB 0.99.3
2+
3+
## New features
4+
5+
- Added the exported `writeDatapackage()` function, which assembles and writes a
6+
Frictionless `datapackage.json` envelope from a list of resource descriptors.
7+
The experiment-level `writeParquet()` methods (`SummarizedExperiment`,
8+
`MultiAssayExperiment`) now single-source their manifest assembly through it,
9+
and producers that build resources incrementally --- streaming a dataset too
10+
large to hold in memory, or promoting from another store --- can emit a
11+
conformant manifest without reconstructing an in-memory Bioconductor object.
12+
`NULL` descriptors (returned by append/streaming parts) are dropped, so
13+
accumulated resource lists can be passed straight through. This is the write
14+
half of the ingest contract; the read half is `readParquet()` together with
15+
the `DuckDBMatrix()`/`DuckDBArray()`/`DuckDBTable()` constructors, which attach
16+
existing Parquet in place.
17+
18+
## Documentation
19+
20+
- Documented the storage layout as a targetable Frictionless contract in the
21+
package vignette (the new "Targeting the storage contract" section), covering
22+
`writeDatapackage()` for assembling a manifest and the DuckDB-backed
23+
constructors for attaching existing coord-array Parquet in place.
24+
125
# BiocDuckDB 0.99.2
226

327
## Documentation

R/writeDatapackage.R

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
### - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
2+
### Datapackage envelope assembler
3+
###
4+
### The public, source-agnostic seam between "write resources" and "write the
5+
### manifest". The experiment-level writeParquet() methods accumulate a list of
6+
### Frictionless resource descriptors and then assemble the datapackage.json
7+
### envelope; this function is that envelope step, factored out so that (a) the
8+
### SummarizedExperiment and MultiAssayExperiment methods single-source it and
9+
### (b) producers that build resources piecemeal -- streaming a dataset too large
10+
### to hold in memory, or promoting from a foreign store -- can emit a conformant
11+
### datapackage.json without hand-rolling it. It is the write half of the
12+
### ingest contract; the read half is readParquet() (container level) and the
13+
### DuckDBMatrix()/DuckDBArray()/DuckDBTable() constructors (component level),
14+
### which attach existing Parquet in place.
15+
16+
#' Write a Frictionless datapackage.json envelope
17+
#'
18+
#' Assembles and writes the top-level \code{datapackage.json} manifest from a
19+
#' list of already-written Frictionless resource descriptors. This is the
20+
#' envelope step shared by the experiment-level \code{\link{writeParquet}}
21+
#' methods, exposed so that producers who accumulate resources incrementally
22+
#' (streaming large datasets, or promoting from another store) can emit a
23+
#' conformant manifest without reconstructing an in-memory Bioconductor object.
24+
#'
25+
#' Each entry of \code{resources} is a Frictionless resource descriptor -- a list
26+
#' with \code{name}, \code{path}, \code{dimension}, \code{layout}, \code{format},
27+
#' \code{mediatype}, and \code{schema} -- exactly as returned by the primitive
28+
#' \code{\link{writeParquet}} methods (array, \code{data.frame}, \code{DataFrame},
29+
#' \code{SelfHits}). \code{NULL} entries are dropped, so the \code{NULL} returned
30+
#' by append/streaming parts (see \code{\link{writeParquet}}) can be accumulated
31+
#' and passed straight through. Descriptors are written verbatim otherwise; strip
32+
#' any private, non-Frictionless keys before calling.
33+
#'
34+
#' @param model Character(1) package-level schema identifier that selects the
35+
#' \code{\link{readParquet}} reader used to reconstruct the container (e.g.
36+
#' \code{"summarized_experiment"}, \code{"single_cell_experiment"},
37+
#' \code{"multi_assay_experiment"}). See the storage-layout vignette for the
38+
#' documented \code{model} values.
39+
#' @param resources A list of Frictionless resource descriptors (each a list),
40+
#' as returned/accumulated from \code{\link{writeParquet}}. \code{NULL} entries
41+
#' are removed.
42+
#' @param path Character(1) directory to write \code{datapackage.json} into;
43+
#' created recursively if it does not exist.
44+
#' @param main_exp_name Optional character(1) naming the main experiment (used by
45+
#' the \code{single_cell_experiment} reader). Omitted from the manifest when
46+
#' \code{NULL}.
47+
#' @param annotations Optional list of non-relational metadata elements (as
48+
#' produced during metadata serialization). Omitted when \code{NULL}.
49+
#'
50+
#' @return Invisibly, the assembled package list that was written.
51+
#'
52+
#' @examples
53+
#' # Assemble a manifest from a hand-built resource descriptor.
54+
#' tf <- tempfile()
55+
#' resources <- list(list(
56+
#' name = "features", path = "features",
57+
#' dimension = "feature", layout = "data_frame",
58+
#' format = "parquet",
59+
#' mediatype = "application/vnd.apache.parquet",
60+
#' schema = list(fields = list(list(name = "id", type = "integer")))))
61+
#' writeDatapackage("summarized_experiment", resources, tf)
62+
#' cat(readLines(file.path(tf, "datapackage.json")), sep = "\n")
63+
#'
64+
#' @seealso \code{\link{writeParquet}} for writing resources, and
65+
#' \code{\link{readParquet}} for reading a written package (the
66+
#' \code{DuckDBMatrix}/\code{DuckDBArray}/\code{DuckDBTable} constructors attach
67+
#' an existing coord-array in place).
68+
#'
69+
#' @author Patrick Aboyoun
70+
#'
71+
#' @importFrom jsonlite write_json
72+
#' @importFrom S4Vectors isSingleString
73+
#' @export
74+
writeDatapackage <- function(model, resources, path,
75+
main_exp_name = NULL, annotations = NULL)
76+
{
77+
if (!isSingleString(model))
78+
stop("'model' must be a single non-NA string")
79+
if (!isSingleString(path))
80+
stop("'path' must be a single non-NA string")
81+
if (!is.list(resources))
82+
stop("'resources' must be a list of Frictionless resource descriptors")
83+
if (!is.null(main_exp_name) && !isSingleString(main_exp_name))
84+
stop("'main_exp_name' must be NULL or a single string")
85+
86+
# Drop NULL descriptors (append/streaming parts return NULL).
87+
resources <- Filter(Negate(is.null), resources)
88+
89+
package <- list(model = model, resources = resources)
90+
if (!is.null(main_exp_name))
91+
package[["main_exp_name"]] <- main_exp_name
92+
if (!is.null(annotations))
93+
package[["annotations"]] <- annotations
94+
95+
# Declare the Frictionless profile as the leading key.
96+
package <- c(list("$schema" = .BIOCDUCKDB_PROFILE), package)
97+
98+
dir.create(path, recursive = TRUE, showWarnings = FALSE)
99+
write_json(package, path = file.path(path, "datapackage.json"),
100+
auto_unbox = TRUE, pretty = TRUE)
101+
102+
invisible(package)
103+
}

R/writeParquet.R

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,14 +1486,13 @@ function(x,
14861486

14871487
# Metadata — recursive JSON vs Parquet dispatch
14881488
ser <- .serializeMetadata(x, path = path, ...)
1489-
package[["resources"]] <- c(package[["resources"]], ser$resources)
1490-
package[["annotations"]] <- ser[["annotations"]]
14911489

1492-
# Declare the Frictionless profile
1493-
package <- c(list("$schema" = .BIOCDUCKDB_PROFILE), package)
1494-
1495-
write_json(package, path = file.path(path, "datapackage.json"),
1496-
auto_unbox = TRUE, pretty = TRUE)
1490+
# Assemble + write the datapackage.json envelope (SCE sets main_exp_name).
1491+
writeDatapackage(model = package[["model"]],
1492+
resources = c(package[["resources"]], ser$resources),
1493+
path = path,
1494+
main_exp_name = package[["main_exp_name"]],
1495+
annotations = ser[["annotations"]])
14971496

14981497
invisible(NULL)
14991498
})
@@ -1750,14 +1749,12 @@ function(x,
17501749
package[["resources"]] <- c(package[["resources"]], resources)
17511750

17521751
ser <- .serializeMetadata(x, path = path, ...)
1753-
package[["resources"]] <- c(package[["resources"]], ser$resources)
1754-
package[["annotations"]] <- ser[["annotations"]]
1755-
1756-
# Declare the Frictionless profile
1757-
package <- c(list("$schema" = .BIOCDUCKDB_PROFILE), package)
17581752

1759-
write_json(package, path = file.path(path, "datapackage.json"),
1760-
auto_unbox = TRUE, pretty = TRUE)
1753+
# Assemble + write the datapackage.json envelope.
1754+
writeDatapackage(model = package[["model"]],
1755+
resources = c(package[["resources"]], ser$resources),
1756+
path = path,
1757+
annotations = ser[["annotations"]])
17611758

17621759
invisible(NULL)
17631760
})

man/writeDatapackage.Rd

Lines changed: 78 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Public ingest-contract surface: writeDatapackage() (envelope assembler) and
2+
# addGraphMetadata() (resource-level graph-edges helper). writeDatapackage() is
3+
# the seam the SummarizedExperiment / MultiAssayExperiment writeParquet() methods
4+
# now single-source, and the entry point for producers that accumulate resources
5+
# incrementally.
6+
# Run: library(BiocDuckDB); library(testthat); source("setup.R"); source("test-writeDatapackage.R")
7+
8+
library(SummarizedExperiment)
9+
10+
.profileValidator <- function() {
11+
skip_if_not_installed("jsonvalidate")
12+
profile <- system.file("schema", "biocduckdb-profile.json", package = "BiocDuckDB")
13+
if (!nzchar(profile) || !file.exists(profile)) {
14+
skip("bundled schema profile not found (package not installed with inst/schema)")
15+
}
16+
jsonvalidate::json_schema$new(profile, engine = "ajv")
17+
}
18+
19+
.readDataPackage <- function(dir) {
20+
jsonlite::fromJSON(file.path(dir, "datapackage.json"), simplifyVector = FALSE)
21+
}
22+
23+
.featureResource <- function(name = "features") {
24+
list(name = name, path = name, dimension = "feature", layout = "data_frame",
25+
format = "parquet", mediatype = "application/vnd.apache.parquet",
26+
schema = list(fields = list(list(name = "__feature__", type = "string"))))
27+
}
28+
29+
test_that("writeDatapackage emits a profile-conformant manifest", {
30+
validator <- .profileValidator()
31+
tmpdir <- tempfile()
32+
writeDatapackage("summarized_experiment", list(.featureResource()), tmpdir)
33+
34+
dp <- file.path(tmpdir, "datapackage.json")
35+
expect_true(file.exists(dp))
36+
json <- paste(readLines(dp, warn = FALSE), collapse = "\n")
37+
expect_true(validator$validate(json))
38+
unlink(tmpdir, recursive = TRUE)
39+
})
40+
41+
test_that("writeDatapackage sets $schema/model and drops NULL resources", {
42+
tmpdir <- tempfile()
43+
res <- list(.featureResource("features"), NULL, .featureResource("samples"))
44+
pkg <- writeDatapackage("summarized_experiment", res, tmpdir)
45+
46+
# $schema is the leading key; model set; NULLs filtered.
47+
expect_identical(names(pkg)[1L], "$schema")
48+
expect_identical(pkg[["model"]], "summarized_experiment")
49+
expect_length(pkg[["resources"]], 2L)
50+
51+
parsed <- .readDataPackage(tmpdir)
52+
expect_identical(parsed[["model"]], "summarized_experiment")
53+
expect_length(parsed[["resources"]], 2L)
54+
unlink(tmpdir, recursive = TRUE)
55+
})
56+
57+
test_that("writeDatapackage includes main_exp_name / annotations only when given", {
58+
tmpdir <- tempfile()
59+
bare <- writeDatapackage("summarized_experiment", list(.featureResource()),
60+
tmpdir)
61+
expect_null(bare[["main_exp_name"]])
62+
expect_null(bare[["annotations"]])
63+
64+
tmpdir2 <- tempfile()
65+
full <- writeDatapackage("single_cell_experiment", list(.featureResource()),
66+
tmpdir2, main_exp_name = "rna",
67+
annotations = list(note = "hi"))
68+
expect_identical(full[["main_exp_name"]], "rna")
69+
expect_identical(full[["annotations"]], list(note = "hi"))
70+
unlink(c(tmpdir, tmpdir2), recursive = TRUE)
71+
})
72+
73+
test_that("writeDatapackage validates its arguments", {
74+
expect_error(writeDatapackage(c("a", "b"), list(), tempfile()), "single")
75+
expect_error(writeDatapackage("m", "not-a-list", tempfile()), "list")
76+
expect_error(writeDatapackage("m", list(), 1L), "single")
77+
})
78+
79+
test_that("writeDatapackage re-assembles a reader-valid manifest (round-trip)", {
80+
set.seed(11)
81+
counts <- matrix(rpois(30L, 5), nrow = 6L, ncol = 5L)
82+
rownames(counts) <- paste0("Gene", seq_len(6L))
83+
colnames(counts) <- paste0("Cell", seq_len(5L))
84+
se <- SummarizedExperiment(assays = list(counts = counts))
85+
86+
dir <- tempfile()
87+
writeParquet(se, dir) # writes resources + manifest
88+
dp <- .readDataPackage(dir)
89+
90+
# Re-emit the manifest from its own parsed resources via the public function,
91+
# then confirm the reader still reconstructs the object.
92+
writeDatapackage(model = dp[["model"]], resources = dp[["resources"]],
93+
path = dir, annotations = dp[["annotations"]])
94+
se2 <- readParquet(dir)
95+
expect_s4_class(se2, "SummarizedExperiment")
96+
expect_identical(dim(se2), dim(se))
97+
expect_identical(assayNames(se2), assayNames(se))
98+
unlink(dir, recursive = TRUE)
99+
})

0 commit comments

Comments
 (0)