Skip to content

Commit c08db3b

Browse files
committed
Conform spatialMap via a spatial-element registry + bridge
Bump to 0.99.10. serialize a MultiAssaySpatialExperiment's spatialMap losslessly by monomorphizing its polymorphic (element_type, region, instance_id) reference through a conformed element-instance registry, so the observation-to-element association becomes real, single-target foreign keys instead of opaque string data. writeParquet (MultiAssaySpatialExperiment method): - Emit a `spatial_element_registry` resource enumerating every points/shapes instance across all layers, with an integer `__element__` spine and the object model's (element_type, region, instance_id) natural key. - Each spatial_points / spatial_shapes layer foreign-keys the `__element__` spine into the registry (the CTI subtype -> base link). - `spatial_map` becomes a monomorphic bridge with two single-target FKs: the element side (`__element__` -> spatial_element_registry) and the observation side ((assay, colname) -> sample_map, which reuses sample_map as the observation registry and encodes the spatialMap validity rule as a real composite FK). assay/element_type/region remain degenerate on the bridge. readParquet: `__element__` is a serialization-internal spine (excluded from .schema_datacols and dropped from the reconstructed spatialMap), so the layers, the spatialMap, and the query layer round-trip unchanged. Profile: add `spatial_element_registry` to the layout enum (+ its cross-check). Bug fix: a coord_array index column's category `label` was emitted as a one-element array rather than a string (an errant I() wrap; the value is always length 1), so any datapackage carrying an assay with dimnames -- e.g. a MultiAssaySpatialExperiment member -- failed validation against the bundled Frictionless profile. The label is now a string and such packages conform. Adds a drift-guard test asserting the registry/bridge FK contract, full profile conformance, and round-trip stripping. Mirrors a scibis read-contract update (kept at parity). Full test suite green.
1 parent c8a38a5 commit c08db3b

7 files changed

Lines changed: 205 additions & 9 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: BiocDuckDB
2-
Version: 0.99.9
3-
Date: 2026-07-27
2+
Version: 0.99.10
3+
Date: 2026-07-28
44
Title: Bioconductor DuckDB Integration and High-Level I/O
55
Description:
66
Integration package providing high-level Parquet I/O functions and optimized

NEWS.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
# BiocDuckDB 0.99.10
2+
3+
## Enhancements
4+
5+
- `writeParquet()` now serializes a `MultiAssaySpatialExperiment`'s `spatialMap`
6+
as a monomorphic bridge over a conformed **element-instance registry**
7+
(`spatial_element_registry`), so the observation-to-spatial-element association
8+
becomes real, single-target foreign keys instead of an opaque polymorphic
9+
reference. A new `spatial_element_registry` resource enumerates every
10+
points/shapes instance across all layers with an integer `__element__` spine;
11+
each typed spatial layer foreign-keys that spine; and `spatial_map` declares
12+
two foreign keys -- the element side (`__element__` to `spatial_element_registry`)
13+
and the observation side (`(assay, colname)` to `sample_map`, which encodes
14+
the spatialMap validity rule as a real composite FK). The `__element__` spine
15+
is a serialization-internal column: `readParquet()` reconstructs the
16+
object-model `spatialMap` and the spatial layers without it, so the round-trip
17+
is unchanged.
18+
19+
## Bug fixes
20+
21+
- `writeParquet()` emitted a `coord_array` index column's category `label` as a
22+
one-element array rather than a string, so a datapackage carrying an assay with
23+
dimnames failed validation against the bundled Frictionless profile. The label
24+
is now a string, and such packages conform.
25+
126
# BiocDuckDB 0.99.9
227

328
## Enhancements

R/readParquet.R

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ function(path,
224224
keycol <- .schema_keycols(schema)
225225
pkey <- schema[["primaryKey"]]
226226
exclude <- c(keycol,
227+
"__element__",
227228
if (!is.null(pkey) && !identical(pkey, keycol)) pkey,
228229
.schema_partitions(schema),
229230
unlist(schema[["genomicCoords"]]),
@@ -886,6 +887,12 @@ function(path,
886887
x <- .readParquetDataFrame(fullpath, resource = spatial_map_res,
887888
keycol = index, ...)
888889
spatial_map <- as.data.frame(x, optional = TRUE)
890+
drop_cols <- intersect(c("__index__", "__element__"),
891+
colnames(spatial_map))
892+
if (length(drop_cols)) {
893+
spatial_map <- spatial_map[,
894+
setdiff(colnames(spatial_map), drop_cols), drop = FALSE]
895+
}
889896
spatial_map <- DataFrame(spatial_map, check.names = FALSE)
890897
}
891898

R/writeParquet.R

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ function(x,
587587
lapply(seq_along(dimnames_x[[i]]),
588588
function(j) {
589589
list(value = j,
590-
label = I(dimnames_x[[i]][[j]]))
590+
label = dimnames_x[[i]][[j]])
591591
}),
592592
categoriesOrdered = TRUE)
593593
}
@@ -1834,6 +1834,70 @@ setMethod("writeParquet", "ShapesLayerList", function(x, path, ...)
18341834
invisible(resources)
18351835
})
18361836

1837+
### - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1838+
### Spatial element-instance registry
1839+
###
1840+
1841+
# Enumerate every points/shapes instance across all layers into one conformed
1842+
# registry, so spatialMap's polymorphic (element_type, region, instance_id)
1843+
# reference monomorphizes to a single-target foreign key.
1844+
#' @importFrom S4Vectors DataFrame
1845+
.spatialElementRegistry <- function(pts, shps) {
1846+
parts <- list()
1847+
collect <- function(ll, etype) {
1848+
for (i in seq_along(ll)) {
1849+
iid <- ll[[i]][["instance_id"]]
1850+
if (!is.null(iid) && length(iid)) {
1851+
parts[[length(parts) + 1L]] <<- DataFrame(
1852+
element_type = etype,
1853+
region = names(ll)[i],
1854+
instance_id = as.character(iid))
1855+
}
1856+
}
1857+
}
1858+
collect(pts, "points")
1859+
collect(shps, "shapes")
1860+
if (!length(parts))
1861+
return(NULL)
1862+
do.call(rbind, parts)
1863+
}
1864+
1865+
.spatialElementKey <- function(reg) {
1866+
keys <- paste(reg[["element_type"]], reg[["region"]],
1867+
as.character(reg[["instance_id"]]), sep = "\r")
1868+
stats::setNames(seq_len(nrow(reg)), keys)
1869+
}
1870+
1871+
.elementRegistryRef <- function() {
1872+
list(list(fields = "__element__",
1873+
reference = list(fields = "__element__",
1874+
resource = "spatial_element_registry")))
1875+
}
1876+
1877+
.writeSpatialLayersWithRegistry <-
1878+
function(ll, etype, path, elem_key, ...)
1879+
{
1880+
layout <- sprintf("spatial_%s", etype)
1881+
prefix <- sprintf("sample_%s_", etype)
1882+
ref <- if (is.null(elem_key)) NULL else .elementRegistryRef()
1883+
resources <- list()
1884+
for (i in seq_along(ll)) {
1885+
region <- names(ll)[i]
1886+
lyr <- ll[[i]]
1887+
if (!is.null(elem_key)) {
1888+
k <- paste(etype, region, as.character(lyr[["instance_id"]]),
1889+
sep = "\r")
1890+
lyr[["__element__"]] <- as.integer(elem_key[k])
1891+
}
1892+
resources <- c(resources,
1893+
writeParquet(lyr,
1894+
path = file.path(path, paste0(prefix, region)),
1895+
name = region, dimension = "sample",
1896+
layout = layout, refs = ref, ...))
1897+
}
1898+
resources
1899+
}
1900+
18371901
### - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
18381902
### MultiAssaySpatialExperiment objects
18391903
###
@@ -1850,17 +1914,33 @@ function(x,
18501914
resources = list()),
18511915
...)
18521916
{
1853-
# Points
18541917
pts <- MultiAssaySpatialExperiment::spatialPoints(x)
1918+
shps <- MultiAssaySpatialExperiment::spatialShapes(x)
1919+
1920+
# Element-instance registry
1921+
reg <- .spatialElementRegistry(pts, shps)
1922+
elem_key <- NULL
1923+
if (!is.null(reg)) {
1924+
elem_key <- .spatialElementKey(reg)
1925+
resources <- callGeneric(as.data.frame(reg, optional = TRUE),
1926+
path = file.path(path, "spatial_element_registry"),
1927+
indexcol = "__element__", keycol = NULL,
1928+
name = "spatial_element_registry", dimension = "sample",
1929+
layout = "spatial_element_registry", ...)
1930+
package[["resources"]] <- c(package[["resources"]], resources)
1931+
}
1932+
1933+
# Points
18551934
if (length(pts) > 0L) {
1856-
resources <- callGeneric(pts, path = path, ...)
1935+
resources <- .writeSpatialLayersWithRegistry(pts, "points", path,
1936+
elem_key, ...)
18571937
package[["resources"]] <- c(package[["resources"]], resources)
18581938
}
18591939

18601940
# Shapes
1861-
shps <- MultiAssaySpatialExperiment::spatialShapes(x)
18621941
if (length(shps) > 0L) {
1863-
resources <- callGeneric(shps, path = path, ...)
1942+
resources <- .writeSpatialLayersWithRegistry(shps, "shapes", path,
1943+
elem_key, ...)
18641944
package[["resources"]] <- c(package[["resources"]], resources)
18651945
}
18661946

@@ -2003,11 +2083,23 @@ function(x,
20032083
package[["resources"]] <- c(package[["resources"]], resources_img)
20042084
}
20052085

2006-
# Spatial Map
2086+
# Spatial Map: a monomorphic bridge with two single-target FKs
20072087
spatial_map <- MultiAssaySpatialExperiment::spatialMap(x)
20082088
if (!is.null(spatial_map) && nrow(spatial_map) > 0L) {
2089+
sm_refs <- list()
2090+
if (!is.null(elem_key)) {
2091+
k <- paste(as.character(spatial_map[["element_type"]]),
2092+
as.character(spatial_map[["region"]]),
2093+
as.character(spatial_map[["instance_id"]]), sep = "\r")
2094+
spatial_map[["__element__"]] <- as.integer(elem_key[k])
2095+
sm_refs <- c(sm_refs, .elementRegistryRef())
2096+
}
2097+
sm_refs <- c(sm_refs, list(list(
2098+
fields = c("assay", "colname"),
2099+
reference = list(fields = c("assay", "colname"),
2100+
resource = "sample_map"))))
20092101
resources_sm <- callGeneric(spatial_map, path = file.path(path, "spatial_map"),
2010-
dimension = "unbound", ...)
2102+
dimension = "unbound", refs = sm_refs, ...)
20112103
package[["resources"]] <- c(package[["resources"]], resources_sm)
20122104
}
20132105

inst/schema/biocduckdb-profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"spatial_shapes",
5757
"spatial_raster_ref",
5858
"spatial_label_coord",
59+
"spatial_element_registry",
5960
"nested_data_frame",
6061
"nested_experiment"
6162
],

tests/testthat/test-datapackage-schema-enums.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"spatial_shapes",
2121
"spatial_raster_ref",
2222
"spatial_label_coord",
23+
"spatial_element_registry",
2324
"nested_data_frame",
2425
"nested_experiment"
2526
)

tests/testthat/test-datapackage-schema.R

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,73 @@ test_that("MultiAssayExperiment sample_map declares the primary -> subjects fore
162162

163163
unlink(tmpdir, recursive = TRUE)
164164
})
165+
166+
test_that("MASE conforms spatialMap via an spatial_element_registry + monomorphic bridge (ADR-067)", {
167+
skip_if_not_installed("MultiAssaySpatialExperiment")
168+
validator <- .profileValidator()
169+
suppressPackageStartupMessages(library(MultiAssaySpatialExperiment))
170+
171+
mat <- matrix(rpois(20, 5), 5, 4,
172+
dimnames = list(paste0("G", 1:5), paste0("obs", 1:4)))
173+
pts <- S4Vectors::DataFrame(x = 1:4, y = 1:4, instance_id = paste0("obs", 1:4))
174+
mase <- MultiAssaySpatialExperiment(
175+
experiments = ExperimentList(rna = mat),
176+
colData = S4Vectors::DataFrame(tissue = c("core", "margin"),
177+
row.names = c("specimen_A", "specimen_B")),
178+
sampleMap = S4Vectors::DataFrame(
179+
assay = factor(rep("rna", 4), "rna"),
180+
primary = rep(c("specimen_A", "specimen_B"), each = 2),
181+
colname = paste0("obs", 1:4)),
182+
points = PointsLayerList(coords = pts),
183+
spatialMap = S4Vectors::DataFrame(
184+
assay = factor(rep("rna", 4), "rna"),
185+
colname = paste0("obs", 1:4), element_type = "points",
186+
region = "coords", instance_id = paste0("obs", 1:4)))
187+
188+
tmpdir <- tempfile()
189+
writeParquet(mase, tmpdir)
190+
191+
# Conforms to the profile (new spatial_element_registry layout + composite FK).
192+
expect_true(.validateDataPackage(validator, tmpdir))
193+
194+
dp <- jsonlite::fromJSON(file.path(tmpdir, "datapackage.json"),
195+
simplifyVector = FALSE)
196+
get_res <- function(nm) Find(function(r) identical(r[["name"]], nm),
197+
dp[["resources"]])
198+
fk_map <- function(res) {
199+
fks <- res[["schema"]][["foreignKeys"]]
200+
stats::setNames(lapply(fks, function(k)
201+
c(resource = k[["reference"]][["resource"]],
202+
fields = paste(unlist(k[["reference"]][["fields"]]), collapse = "+"))),
203+
vapply(fks, function(k) paste(unlist(k[["fields"]]), collapse = "+"), ""))
204+
}
205+
206+
# The conformed registry dimension.
207+
reg <- get_res("spatial_element_registry")
208+
expect_false(is.null(reg))
209+
expect_identical(reg[["layout"]], "spatial_element_registry")
210+
expect_true(all(c("__element__", "element_type", "region", "instance_id") %in%
211+
vapply(reg[["schema"]][["fields"]], `[[`, "", "name")))
212+
213+
# The typed layer foreign-keys the registry's integer spine.
214+
coords_fk <- fk_map(get_res("coords"))
215+
expect_identical(coords_fk[["__element__"]][["resource"]], "spatial_element_registry")
216+
217+
# The bridge carries both single-target FKs.
218+
sm_fk <- fk_map(get_res("spatial_map"))
219+
expect_identical(sm_fk[["__element__"]][["resource"]], "spatial_element_registry")
220+
expect_identical(sm_fk[["assay+colname"]][["resource"]], "sample_map")
221+
expect_identical(sm_fk[["assay+colname"]][["fields"]], "assay+colname")
222+
223+
# Round-trip: the internal spine does not leak back into the object model.
224+
m2 <- readParquet(tmpdir)
225+
sm2 <- MultiAssaySpatialExperiment::spatialMap(m2)
226+
expect_false(any(c("__element__", "__index__") %in% colnames(sm2)))
227+
expect_true(all(c("assay", "colname", "element_type", "region",
228+
"instance_id") %in% colnames(sm2)))
229+
layer2 <- as.data.frame(
230+
MultiAssaySpatialExperiment::spatialPoints(m2)[["coords"]])
231+
expect_false("__element__" %in% colnames(layer2))
232+
233+
unlink(tmpdir, recursive = TRUE)
234+
})

0 commit comments

Comments
 (0)