Repository: single-cell-data/TileDB-SOMA (language: python)

Issue:
[python/r/c++] Revisit `shape` for component arrays
## PRs

**Merged PRs:**

<details>

* #2909 `kerl/schevo-timestamp-methodize`
* #2913 `kerl/name-neaten`
* #2908 `kerl/ut-soma-exc-simplify`
* #2910 `kerl/test-common-parameterize`
* #2918 `kerl/cpp-test-deadstrip`
* #2919 `kerl/minor-unit-test-helper-mod`
* #2936 `kerl/cpp-ut-helper-neaten`
* #2938 `kerl/more-cur-dom-parameterize`
* #2915 `kerl/cpp-strict-int64-shape`
* #2911 `kerl/arrow-util-current-domain-optional`
* #2939 `kerl/step-two-temp`
* #2947 `kerl/cpp-ndarray-resize-testing`
* #2945 `kerl/dataframe-test-fixture`
* #2944 `kerl/cpp-variant-indexed-dataframes`
* #2916 `kerl/sdf-shape`
* #2917 `kerl/cpp-resizes`
* #2948 `kerl/upgrade-shape-int64`
* #2951 `kerl/sdf-test-accessors`
* #2953 `kerl/py-r-accessor-plumbing`
* #2957 `kerl/sdf-domain-accessors`
* #2960 `kerl/dense-link`
* #2963 `kerl/nightly-fix`
* #2970 `kerl/dense-writeable-after-create`
* #2968 `kerl/minor-trim`
* #2969 `kerl/more-py-domain-name-neaten`
* #2972 `kerl/libtiledbsoma-env-logging-level`
* #2962 `kerl/py-r-creation-paths`
* #2950 `kerl/py-r-test-2`
* #2994 `kerl/nanoarrow-helpers`
* #3011 `kerl/polydom3`
* #3017 `kerl/polydom5`
* #3018 `kerl/polydom6`
* #2990 `kerl/variant-nnz-bug`
* #3019 `kerl/index-swap`
* #3020 `kerl/ut-max-shape`
* #3012 `kerl/polydom4`
* #3025 `kerl/fix-3020-merge`
* #3026 `kerl/one-more-rename`
* #3028 `kerl/ff-not`
* #3029 `kerl/ut-vg`
* #3030 `kerl/table-utils-memory`
* #3067 `kerl/improve-sdf-test-field-names`
* #3068 `kerl/ut-generate`
* #3069 `kerl/cpp-sdf-domain-at-create`
* #3027 `kerl/hll-domainish`
* #3088 `kerl/max-domain-int64`
* #3090 `kerl/maybe-resize-soma-joinid-cpp-tweak`
* #3032 `kerl/sdf-domain-at-create` -- fixes #2967
* #3091 `kerl/maybe-resize-soma-joinid-py-r`
* #3095 `kerl/cpp-exp-resize-prep`
* #3089 `kerl/r-dataframe-shapeable`
* https://github.com/single-cell-data/SOMA/pull/233
* #3125 `kerl/cpp-ut-name-shortens`
* #3127 `kerl/helper-rename`
* #3130 `kerl/cpp-can-resizers-names`
* #3132 `kerl/cpp-dataframe-sizing-helpers`
* #3139 `kerl/cpp-dataframe-upgrade-test`
* #3140 `kerl/py-resizer-connects`
* #3151 `kerl/py-can-upgrade-shape`
* #3152 `kerl/registration-shape-acceessors`
* #3156 `kerl/py-exp-shaping`
* #3157 `kerl/py-exp-shaping2`
* #3148 `kerl/py-exp-resize`
* #3191 `kerl/py-domain-at-create-ut-1`
* #3190 `kerl/py-domain-at-create-ut-2`
* #3192 `kerl/py-domain-at-create-ut-3`
* #3193 `kerl/py-domain-at-create-ut-4`
* #3194 `kerl/py-domain-at-create-ut-5`
* #3203 `kerl/min-size-2`
* #3208 `kerl/r-min-sizing`
* #3211 `kerl/cpp-ugr-dom`
* #3232 `kerl/ff-interop`
* #3230 `kerl/ffon`
* #3234 `kerl/docstring-prune`
* #3236 `kerl/prefixing`
* #3241 `kerl/fix-bad-merge`
* #3235 `kerl/py-r-ugr-dom`
* #3237 `kerl/py-r-ugr-dom-2`
* #3238 `kerl/py-r-ugr-dom-3`
* #3253 `kerl/set-coords-rename`
* #3261 `kerl/pybind11-nda-sizing`
* #3265 `kerl/dense-227-a`
* #3263 `kerl/dense-range-trim`
* #3268 `kerl/dim-explosion`
* #3269 `kerl/python-227-dense-ned-read`
* #3270 `kerl/r-227-dense-fixes`
* #3280 `kerl/r-dense-227-more`
* #3286 `kerl/more-fn4m`
* #3283 `kerl/readthedocs-pre-1.15`
* #3288 `kerl/dense-ugrsh`
* #3289 `kerl/notebook-shape-upgrade`
* #3285 `kerl/new-shape-doc-updates`
* #3295 `kerl/notebook-data-refresh`
* #3290 `kerl/notebook-new-shape-refresh`
* #3301 `kerl/ffena`
* #3303 `kerl/r-data-refresh`
* #3300 `kerl/sdf-sjid-lower-zero`
* #3296 `kerl/dense-example-data-refresh`
* #3294 `kerl/new-shape-notebook-and-vignette`
* #3308 `kerl/upgrade-experiment-resources`
* #3309 `kerl/fix-notebook-merge`
* #3321 `kerl/more-use-shape`
* #3358 `kerl/revert-3300`
* #3368 `kerl/227a`
* #3369 `kerl/ucd1`
* #3370 `kerl/ucd2`
* #3371 `kerl/ucd3`
* #3372 `kerl/ucd4`
* #3396 `kerl/domain-at-create-docstrings`
* #3302 `kerl/new-shape-vignette`
* https://github.com/single-cell-data/SOMA/pull/250
* #3399 `kerl/new-shape-more-docstrings`
* #3400 `kerl/check-only-r`
* https://github.com/TileDB-Inc/somacore-feedstock/pull/27

</details>

**Closed/abandoned PRs:**

<details>

* #2785 -- This was only dogfooding for the core 2.25 release -- not to be merged
* #2952 `kerl/feature-flag-temp` -- folded into 2962
* #2995 `kerl/polydom`
* #2964 `kerl/tiledbsoma-io-test`
* #3189 `kerl/min-size`
* #3220 `kerl/cpp-ugr-dom-2`
* #3244 `kerl/dense-227-fixes`
* #3276 `kerl/dataframe-shape`

</details>

**Issues which are related but non-blocking:**

* https://github.com/single-cell-data/SOMA/pull/216
* https://github.com/TileDB-Inc/TileDB/pull/5303
* #2966
* #3081
* Note: R append mode does not exist yet -- see #1630 -- so an experiment-level resizer is not a priority in R
* #3271 
* #3272
* #3273

**See also:** [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Problem to be solved

Users want to know the `shape` of an array, in the SciPy sense:

* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`.
  * Some users need the ability to grow their datasets later, using either `tiledbsoma.io`'s append mode, or subsequent writes using the `tiledbsoma` API.
  * Note that the cellxgene census doesn't need this: eact week's published census has fixed shape, and any updates will happen in new storage, on a new week.

Using TileDB-SOMA up until the present:

* The TIleDB `domain` is immutable after array creation
  * This does bounds-checking for reads and writes, which is good
  * To leverage this to function as a `shape`, users would need to set the `domain` at array-creation time. However, users lose the ability to grow their datasets later.
* There is a `non_empty_domain` accessor
  * This only indicates min/max coordinates _at which data exists_. Consider an `X` array for 100 cells and 200 genes. If non-zero expression counts exist only for cell join IDs 2-17, then the `non_empty_domain` will indicate `(2,17)` along `soma_dim_0`.
  * Consider an `obms["X_pca"]` within the same experiment. This may be 100 cells by 50 PCA components: we need a placd to store the number 50.
  * Therefore users cannot leverage this to function as a `shape` accessor.
* We have offered a `used_shape` accessor since TileDB-SOMA 1.5.
  * This functions as a `shape` accessor, in the SciPy sense, but it is not multi-writer safe.

New feature for TileDB-SOMA 1.15:

* Arrays will have a  `shape`
* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`
* The `used_shape` accessor will be deprecated in TileDB-SOMA 1.13, and slated for removal in TileDB-SOMA 1.14.

Compatiblity:

This will now require users to do an explicit `resize` before appending/growing TileDB-SOMA Experiments. Guidance in the form of example notebooks will be provided.

##  Tracking

See also: [[sc-41074]](https://app.shortcut.com/tiledb-inc/story/41074) and [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Scheduling

Support arrives in TileDB Core 2.25. Deprecations for TileDB-SOMA will be released with 1.13. Full support within TileDB-SOMA will be release in 1.14.

## Details

SOMA API mods as we've discussed in a Google doc are as follows.

### `SOMADataFrame`

* `create`: Retain the `domain` argument
  * Issue:
    * Core has a `(lo, hi)` tuple per dim, e.g. `(0,99)` or `(10,19)`
    * SOMA has count per dim, with 0 implicit: e.g. 100 or 20
    * For `SparseNDArray` and `DenseNDArray` core can have `(lo, hi)` and SOMA can have `count`
    * For `DataFrame` there can be multiple dims --- default is a single `soma_joinid`
    * That could be treated either in `(lo, hi)` fashion or `count` fashion
    * However additional dims (e.g. `cell_type`) can be on any type, including strings, floats, etc. where there is no implicit lo=0
    * Therefore we need to keep the current SOMA API wherein `DataFrame` takes a `domain` argument (in `(lo, hi)` fashion) and not a `shape` argument (in `count` fashion)

### `SparseNDArray and DenseNDArray`

* `create`
  * Have an optional shape argument which is of type `Tuple[Int,...]` where each element is the cell count of the corresponding dimension
    * If unsupplied, or if supplied but None in any slot: use the minimum 0 in each slot – nothing larger makes sense since we will not support downsize
  * User guidance should make clear that it will not be possible to create an ‘old’ style array with the ‘new style’ API. (See also the upgrade logic below.)

### All three of `SOMADataFrame`, `SparseNDArray`, `DenseNDArray`

* `write`
  * For new arrays, created with the new shape feature:
    * Core will bounds-check that coordinates provided at `write` time are within the current shape
    * Core will raise `tiledb.cc.TileDBError` to TileDB-SOMA, which will catch and raise `IndexError`, and R-standard behavior on the R side
  * For old arrays created before this feature:
    * Core will not bounds-check that coordinates provided at write time are within the current shape
* Existing `used_shape` accessor
  * TileDB-SOMA will deprecate this over a release cycle.
  * For new arrays: raise `NotImplementedError`
  * For old arrays: return what’s currently returned, with a deprecation warning.
  * Mechanism for determining old vs. new: `array.schema.version` (the core storage version).
* Existing `shape` accessor
  * For new arrays:
    * Have this return the new shape as proposed by core, no longer returning the TileDB domain.
  * For old arrays created before this feature:
    * Return the TileDB domain as now.
* Existing `non_empty_domain` accessor
  * Same behavior for old and new arrays (unaffected by this proposal).
  * Keep this accessor supported, but, with user notes that it’s generally non-useful
  * This should return None (or R equivalent) when there is a schema but no data have been written.
* New `maxshape` accessor
  * Maps the core-level `(lo, hi)` accessor for domain to count-style accessor hi+1. E.g. if the core domain is either `(0,99)` or `(50,99)` then TileDB-SOMA `maxshape` will say 100.
  * Same behavior for old and new arrays.
  * Let users query for what the TileDB domain is, with user notes that it’s the maximum that users can reshape to.
  * Issac suggests: maybe `domain` or `maxshape` (see h5py).
* New `resize` mutator
  * Note: `reshape` means something else in the community (numpy, zarr, h5py), e.g. a 5x20 (total 100 cells) being reinterpreted as 4x25 (still 100 cells). The standard name for changing cell-count is `resize`.
  * For old arrays created before this feature: raise `NotImplementedError`.
  * For new arrays:
    * Will raise `ValueError` if the new shape is smaller on any dim than currently in storage
    * Regardless of whether any data have been written whatsoever
    * Will raise `ValueError` if the new shape exceeds the TileDB domain from create time, which will serve TileDB-SOMA in a role of “max possible shape the user can reshape to”
    * Otherwise, any calls to write from this point will bounds-check writes within this new shape
    * **We don’t expect resize to be multi-writer safe with regard to write ; user notes must be clear on this point**
* New `tiledbsoma_upgrade_shape` method for SparseNDArray and DenseNDArray
  * This will leverage `array.schema.version` to see if an upgrade is needed
  * Leverage core support for storage-version updates
  * This will take a shape argument as in `create`
  * For arrays created with “just-right” size: this will succeed
  * For arrays created with “room-for-growth” / “two billion-ish” size: this will succeed
  * If the user passes a shape which exceeds the current TileDB domain: this will fail
* New `tiledbsoma_upgrade_domain method` for `DataFrame`
  * Same as for `SparseNDArray`/`DenseNDArray` except it will take a domain at the SOMA-API level just as `DataFrame`'s create method

### `tiledbsoma.io`

* The user-facing API has no shape arguments and thus won’t need changing.
* Internally to `tiledbsoma.io`, we’ll still ask the tiledbsoma API for the “big domain” (2 billionish)
* Append mode:
  * Will need a new `resize` method at the `Experiment` level
  * Users will need to:
    * Register as now
    * Call the experiment-level `resize`
      * Could be `exp.resize(...)`, or (better) this could be `tiledbsoma.io.reshape_experiment`
  * In either case: this method will take the new `obs` and `var` counts as inputs:
    * `exp.obs.reshape` to new `obs` count
    * `exp.ms[name].var.reshape` to new `var` count
    * `exp.ms[name].X[name].reshape` to new `obs` count x `var` count
    * `exp.ms[name].obsm[name].reshape` to new `obs` count x same width
    * `exp.ms[name].obsp[name].reshape` to new `obs` count x `obs` count
    * `exp.ms[name].varm[name].reshape` to new `var` count x same width
    * `exp.ms[name].varp[name].reshape` to new `var` count x `var` count
  * Do the individual append-mode writes as now

[python/r/c++] Revisit `shape` for component arrays
## PRs

**Merged PRs:**

<details>

* #2909 `kerl/schevo-timestamp-methodize`
* #2913 `kerl/name-neaten`
* #2908 `kerl/ut-soma-exc-simplify`
* #2910 `kerl/test-common-parameterize`
* #2918 `kerl/cpp-test-deadstrip`
* #2919 `kerl/minor-unit-test-helper-mod`
* #2936 `kerl/cpp-ut-helper-neaten`
* #2938 `kerl/more-cur-dom-parameterize`
* #2915 `kerl/cpp-strict-int64-shape`
* #2911 `kerl/arrow-util-current-domain-optional`
* #2939 `kerl/step-two-temp`
* #2947 `kerl/cpp-ndarray-resize-testing`
* #2945 `kerl/dataframe-test-fixture`
* #2944 `kerl/cpp-variant-indexed-dataframes`
* #2916 `kerl/sdf-shape`
* #2917 `kerl/cpp-resizes`
* #2948 `kerl/upgrade-shape-int64`
* #2951 `kerl/sdf-test-accessors`
* #2953 `kerl/py-r-accessor-plumbing`
* #2957 `kerl/sdf-domain-accessors`
* #2960 `kerl/dense-link`
* #2963 `kerl/nightly-fix`
* #2970 `kerl/dense-writeable-after-create`
* #2968 `kerl/minor-trim`
* #2969 `kerl/more-py-domain-name-neaten`
* #2972 `kerl/libtiledbsoma-env-logging-level`
* #2962 `kerl/py-r-creation-paths`
* #2950 `kerl/py-r-test-2`
* #2994 `kerl/nanoarrow-helpers`
* #3011 `kerl/polydom3`
* #3017 `kerl/polydom5`
* #3018 `kerl/polydom6`
* #2990 `kerl/variant-nnz-bug`
* #3019 `kerl/index-swap`
* #3020 `kerl/ut-max-shape`
* #3012 `kerl/polydom4`
* #3025 `kerl/fix-3020-merge`
* #3026 `kerl/one-more-rename`
* #3028 `kerl/ff-not`
* #3029 `kerl/ut-vg`
* #3030 `kerl/table-utils-memory`
* #3067 `kerl/improve-sdf-test-field-names`
* #3068 `kerl/ut-generate`
* #3069 `kerl/cpp-sdf-domain-at-create`
* #3027 `kerl/hll-domainish`
* #3088 `kerl/max-domain-int64`
* #3090 `kerl/maybe-resize-soma-joinid-cpp-tweak`
* #3032 `kerl/sdf-domain-at-create` -- fixes #2967
* #3091 `kerl/maybe-resize-soma-joinid-py-r`
* #3095 `kerl/cpp-exp-resize-prep`
* #3089 `kerl/r-dataframe-shapeable`
* https://github.com/single-cell-data/SOMA/pull/233
* #3125 `kerl/cpp-ut-name-shortens`
* #3127 `kerl/helper-rename`
* #3130 `kerl/cpp-can-resizers-names`
* #3132 `kerl/cpp-dataframe-sizing-helpers`
* #3139 `kerl/cpp-dataframe-upgrade-test`
* #3140 `kerl/py-resizer-connects`
* #3151 `kerl/py-can-upgrade-shape`
* #3152 `kerl/registration-shape-acceessors`
* #3156 `kerl/py-exp-shaping`
* #3157 `kerl/py-exp-shaping2`
* #3148 `kerl/py-exp-resize`
* #3191 `kerl/py-domain-at-create-ut-1`
* #3190 `kerl/py-domain-at-create-ut-2`
* #3192 `kerl/py-domain-at-create-ut-3`
* #3193 `kerl/py-domain-at-create-ut-4`
* #3194 `kerl/py-domain-at-create-ut-5`
* #3203 `kerl/min-size-2`
* #3208 `kerl/r-min-sizing`
* #3211 `kerl/cpp-ugr-dom`
* #3232 `kerl/ff-interop`
* #3230 `kerl/ffon`
* #3234 `kerl/docstring-prune`
* #3236 `kerl/prefixing`
* #3241 `kerl/fix-bad-merge`
* #3235 `kerl/py-r-ugr-dom`
* #3237 `kerl/py-r-ugr-dom-2`
* #3238 `kerl/py-r-ugr-dom-3`
* #3253 `kerl/set-coords-rename`
* #3261 `kerl/pybind11-nda-sizing`
* #3265 `kerl/dense-227-a`
* #3263 `kerl/dense-range-trim`
* #3268 `kerl/dim-explosion`
* #3269 `kerl/python-227-dense-ned-read`
* #3270 `kerl/r-227-dense-fixes`
* #3280 `kerl/r-dense-227-more`
* #3286 `kerl/more-fn4m`
* #3283 `kerl/readthedocs-pre-1.15`
* #3288 `kerl/dense-ugrsh`
* #3289 `kerl/notebook-shape-upgrade`
* #3285 `kerl/new-shape-doc-updates`
* #3295 `kerl/notebook-data-refresh`
* #3290 `kerl/notebook-new-shape-refresh`
* #3301 `kerl/ffena`
* #3303 `kerl/r-data-refresh`
* #3300 `kerl/sdf-sjid-lower-zero`
* #3296 `kerl/dense-example-data-refresh`
* #3294 `kerl/new-shape-notebook-and-vignette`
* #3308 `kerl/upgrade-experiment-resources`
* #3309 `kerl/fix-notebook-merge`
* #3321 `kerl/more-use-shape`
* #3358 `kerl/revert-3300`
* #3368 `kerl/227a`
* #3369 `kerl/ucd1`
* #3370 `kerl/ucd2`
* #3371 `kerl/ucd3`
* #3372 `kerl/ucd4`
* #3396 `kerl/domain-at-create-docstrings`
* #3302 `kerl/new-shape-vignette`
* https://github.com/single-cell-data/SOMA/pull/250
* #3399 `kerl/new-shape-more-docstrings`
* #3400 `kerl/check-only-r`
* https://github.com/TileDB-Inc/somacore-feedstock/pull/27

</details>

**Closed/abandoned PRs:**

<details>

* #2785 -- This was only dogfooding for the core 2.25 release -- not to be merged
* #2952 `kerl/feature-flag-temp` -- folded into 2962
* #2995 `kerl/polydom`
* #2964 `kerl/tiledbsoma-io-test`
* #3189 `kerl/min-size`
* #3220 `kerl/cpp-ugr-dom-2`
* #3244 `kerl/dense-227-fixes`
* #3276 `kerl/dataframe-shape`

</details>

**Issues which are related but non-blocking:**

* https://github.com/single-cell-data/SOMA/pull/216
* https://github.com/TileDB-Inc/TileDB/pull/5303
* #2966
* #3081
* Note: R append mode does not exist yet -- see #1630 -- so an experiment-level resizer is not a priority in R
* #3271 
* #3272
* #3273

**See also:** [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Problem to be solved

Users want to know the `shape` of an array, in the SciPy sense:

* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`.
  * Some users need the ability to grow their datasets later, using either `tiledbsoma.io`'s append mode, or subsequent writes using the `tiledbsoma` API.
  * Note that the cellxgene census doesn't need this: eact week's published census has fixed shape, and any updates will happen in new storage, on a new week.

Using TileDB-SOMA up until the present:

* The TIleDB `domain` is immutable after array creation
  * This does bounds-checking for reads and writes, which is good
  * To leverage this to function as a `shape`, users would need to set the `domain` at array-creation time. However, users lose the ability to grow their datasets later.
* There is a `non_empty_domain` accessor
  * This only indicates min/max coordinates _at which data exists_. Consider an `X` array for 100 cells and 200 genes. If non-zero expression counts exist only for cell join IDs 2-17, then the `non_empty_domain` will indicate `(2,17)` along `soma_dim_0`.
  * Consider an `obms["X_pca"]` within the same experiment. This may be 100 cells by 50 PCA components: we need a placd to store the number 50.
  * Therefore users cannot leverage this to function as a `shape` accessor.
* We have offered a `used_shape` accessor since TileDB-SOMA 1.5.
  * This functions as a `shape` accessor, in the SciPy sense, but it is not multi-writer safe.

New feature for TileDB-SOMA 1.15:

* Arrays will have a  `shape`
* Reads and writes are bounds-checked against the shape
* This retains its value regardless of which values of a sparse array are or are not actually occupied
* Users can `resize`
* The `used_shape` accessor will be deprecated in TileDB-SOMA 1.13, and slated for removal in TileDB-SOMA 1.14.

Compatiblity:

This will now require users to do an explicit `resize` before appending/growing TileDB-SOMA Experiments. Guidance in the form of example notebooks will be provided.

##  Tracking

See also: [[sc-41074]](https://app.shortcut.com/tiledb-inc/story/41074) and [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

## Scheduling

Support arrives in TileDB Core 2.25. Deprecations for TileDB-SOMA will be released with 1.13. Full support within TileDB-SOMA will be release in 1.14.

## Details

SOMA API mods as we've discussed in a Google doc are as follows.

### `SOMADataFrame`

* `create`: Retain the `domain` argument
  * Issue:
    * Core has a `(lo, hi)` tuple per dim, e.g. `(0,99)` or `(10,19)`
    * SOMA has count per dim, with 0 implicit: e.g. 100 or 20
    * For `SparseNDArray` and `DenseNDArray` core can have `(lo, hi)` and SOMA can have `count`
    * For `DataFrame` there can be multiple dims --- default is a single `soma_joinid`
    * That could be treated either in `(lo, hi)` fashion or `count` fashion
    * However additional dims (e.g. `cell_type`) can be on any type, including strings, floats, etc. where there is no implicit lo=0
    * Therefore we need to keep the current SOMA API wherein `DataFrame` takes a `domain` argument (in `(lo, hi)` fashion) and not a `shape` argument (in `count` fashion)

### `SparseNDArray and DenseNDArray`

* `create`
  * Have an optional shape argument which is of type `Tuple[Int,...]` where each element is the cell count of the corresponding dimension
    * If unsupplied, or if supplied but None in any slot: use the minimum 0 in each slot – nothing larger makes sense since we will not support downsize
  * User guidance should make clear that it will not be possible to create an ‘old’ style array with the ‘new style’ API. (See also the upgrade logic below.)

### All three of `SOMADataFrame`, `SparseNDArray`, `DenseNDArray`

* `write`
  * For new arrays, created with the new shape feature:
    * Core will bounds-check that coordinates provided at `write` time are within the current shape
    * Core will raise `tiledb.cc.TileDBError` to TileDB-SOMA, which will catch and raise `IndexError`, and R-standard behavior on the R side
  * For old arrays created before this feature:
    * Core will not bounds-check that coordinates provided at write time are within the current shape
* Existing `used_shape` accessor
  * TileDB-SOMA will deprecate this over a release cycle.
  * For new arrays: raise `NotImplementedError`
  * For old arrays: return what’s currently returned, with a deprecation warning.
  * Mechanism for determining old vs. new: `array.schema.version` (the core storage version).
* Existing `shape` accessor
  * For new arrays:
    * Have this return the new shape as proposed by core, no longer returning the TileDB domain.
  * For old arrays created before this feature:
    * Return the TileDB domain as now.
* Existing `non_empty_domain` accessor
  * Same behavior for old and new arrays (unaffected by this proposal).
  * Keep this accessor supported, but, with user notes that it’s generally non-useful
  * This should return None (or R equivalent) when there is a schema but no data have been written.
* New `maxshape` accessor
  * Maps the core-level `(lo, hi)` accessor for domain to count-style accessor hi+1. E.g. if the core domain is either `(0,99)` or `(50,99)` then TileDB-SOMA `maxshape` will say 100.
  * Same behavior for old and new arrays.
  * Let users query for what the TileDB domain is, with user notes that it’s the maximum that users can reshape to.
  * Issac suggests: maybe `domain` or `maxshape` (see h5py).
* New `resize` mutator
  * Note: `reshape` means something else in the community (numpy, zarr, h5py), e.g. a 5x20 (total 100 cells) being reinterpreted as 4x25 (still 100 cells). The standard name for changing cell-count is `resize`.
  * For old arrays created before this feature: raise `NotImplementedError`.
  * For new arrays:
    * Will raise `ValueError` if the new shape is smaller on any dim than currently in storage
    * Regardless of whether any data have been written whatsoever
    * Will raise `ValueError` if the new shape exceeds the TileDB domain from create time, which will serve TileDB-SOMA in a role of “max possible shape the user can reshape to”
    * Otherwise, any calls to write from this point will bounds-check writes within this new shape
    * **We don’t expect resize to be multi-writer safe with regard to write ; user notes must be clear on this point**
* New `tiledbsoma_upgrade_shape` method for SparseNDArray and DenseNDArray
  * This will leverage `array.schema.version` to see if an upgrade is needed
  * Leverage core support for storage-version updates
  * This will take a shape argument as in `create`
  * For arrays created with “just-right” size: this will succeed
  * For arrays created with “room-for-growth” / “two billion-ish” size: this will succeed
  * If the user passes a shape which exceeds the current TileDB domain: this will fail
* New `tiledbsoma_upgrade_domain method` for `DataFrame`
  * Same as for `SparseNDArray`/`DenseNDArray` except it will take a domain at the SOMA-API level just as `DataFrame`'s create method

### `tiledbsoma.io`

* The user-facing API has no shape arguments and thus won’t need changing.
* Internally to `tiledbsoma.io`, we’ll still ask the tiledbsoma API for the “big domain” (2 billionish)
* Append mode:
  * Will need a new `resize` method at the `Experiment` level
  * Users will need to:
    * Register as now
    * Call the experiment-level `resize`
      * Could be `exp.resize(...)`, or (better) this could be `tiledbsoma.io.reshape_experiment`
  * In either case: this method will take the new `obs` and `var` counts as inputs:
    * `exp.obs.reshape` to new `obs` count
    * `exp.ms[name].var.reshape` to new `var` count
    * `exp.ms[name].X[name].reshape` to new `obs` count x `var` count
    * `exp.ms[name].obsm[name].reshape` to new `obs` count x same width
    * `exp.ms[name].obsp[name].reshape` to new `obs` count x `obs` count
    * `exp.ms[name].varm[name].reshape` to new `var` count x same width
    * `exp.ms[name].varp[name].reshape` to new `var` count x `var` count
  * Do the individual append-mode writes as now

[c++] Implementation and unit testing for domainish accessors
**Issue and/or context:** As tracked on issue #2407 / [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

This is atop #3011; these will replace #2995.

Note that the intended Python and R API changes are all agreed on and finalized as described in #2407.

**Changes:**

**Notes for Reviewer:**

Python/R PRs will be separate.




Discussion / hints:
#2785 is a quick-and-dirty concept-prover -- its sole function is to flush out any API misunderstandings we might have, in prep for 2.25.0 core release.
#2785 is a quick-and-dirty concept-prover -- its sole function is to flush out any API misunderstandings we might have, in prep for 2.25.0 core release.
## [Codecov](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.79%. Comparing base [(`16fcd68`)](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/commit/16fcd68462b25b6f03478a29b223e7dc9ff45274?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) to head [(`cc0b03f`)](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/commit/cc0b03f33cd4ba6c1bb3a37d485d88b55499b4b3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data).
> Report is 3 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3012      +/-   ##
==========================================
+ Coverage   89.64%   89.79%   +0.14%     
==========================================
  Files          40       40              
  Lines        4096     4096              
==========================================
+ Hits         3672     3678       +6     
+ Misses        424      418       -6     
```

| [Flag](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) | Coverage Δ | |
|---|---|---|
| [python](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) | `89.79% <ø> (+0.14%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Components](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) | Coverage Δ | |
|---|---|---|
| [python_api](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) | `89.79% <ø> (+0.14%)` | :arrow_up: |
| [libtiledbsoma](https://app.codecov.io/gh/single-cell-data/TileDB-SOMA/pull/3012/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=single-cell-data) | `∅ <ø> (∅)` | |

</details>

Fix patch:
diff --git a/apis/python/src/tiledbsoma/_dataframe.py b/apis/python/src/tiledbsoma/_dataframe.py
index 821c33a5c6..b3a1e74ca5 100644
--- a/apis/python/src/tiledbsoma/_dataframe.py
+++ b/apis/python/src/tiledbsoma/_dataframe.py
@@ -354,8 +354,8 @@ def index_column_names(self) -> Tuple[str, ...]:
 
     @property
     def domain(self) -> Tuple[Tuple[Any, Any], ...]:
-        """Returns a tuple of minimum and maximum values, inclusive, storable
-        on each index column of the dataframe.
+        """Returns tuples of minimum and maximum values, one tuple per index column, currently storable
+        on each index column of the dataframe. These can be resized up to ``maxdomain``.
 
         Lifecycle:
             Maturing.
@@ -364,8 +364,8 @@ def domain(self) -> Tuple[Tuple[Any, Any], ...]:
 
     @property
     def maxdomain(self) -> Tuple[Tuple[Any, Any], ...]:
-        """Returns a tuple of minimum and maximum values, inclusive, storable
-        on each index column of the dataframe.
+        """Returns tuples of minimum and maximum values, one tuple per index column, to which the dataframe
+        can have its domain resized.
 
         Lifecycle:
             Maturing.
diff --git a/apis/python/src/tiledbsoma/_tdb_handles.py b/apis/python/src/tiledbsoma/_tdb_handles.py
index 80a059bee8..50298e7d19 100644
--- a/apis/python/src/tiledbsoma/_tdb_handles.py
+++ b/apis/python/src/tiledbsoma/_tdb_handles.py
@@ -12,10 +12,10 @@
 import enum
 from typing import (
     Any,
-    Callable,
     Dict,
     Generic,
     Iterator,
+    List,
     Mapping,
     MutableMapping,
     Optional,
@@ -30,7 +30,6 @@
 import attrs
 import numpy as np
 import pyarrow as pa
-from numpy.typing import DTypeLike
 from somacore import options
 from typing_extensions import Literal, Self
 
@@ -363,41 +362,36 @@ def meta(self) -> "MetadataWrapper":
     def ndim(self) -> int:
         return len(self._handle.dimension_names)
 
-    def _get_and_cast_domain(
-        self, domain_slot_getter: Callable[[str, DTypeLike], Tuple[object, object]]
+    def _cast_domainish(
+        self, domainish: List[Any]
     ) -> Tuple[Tuple[object, object], ...]:
         result = []
-        for name in self._handle.dimension_names:
-            dtype = self._handle.schema.field(name).type
-            if pa.types.is_timestamp(dtype):
-                np_dtype = np.dtype(dtype.to_pandas_dtype())
-                dom = domain_slot_getter(name, np_dtype)
+        for i, slot in enumerate(domainish):
+
+            arrow_type = slot[0].type
+            if pa.types.is_timestamp(arrow_type):
+                pandas_type = np.dtype(arrow_type.to_pandas_dtype())
                 result.append(
-                    (
-                        np_dtype.type(dom[0], dtype.unit),
-                        np_dtype.type(dom[1], dtype.unit),
+                    tuple(
+                        pandas_type.type(e.cast(pa.int64()).as_py(), arrow_type.unit)
+                        for e in slot
                     )
                 )
             else:
-                if pa.types.is_large_string(dtype) or pa.types.is_string(dtype):
-                    dtype = np.dtype("U")
-                elif pa.types.is_large_binary(dtype) or pa.types.is_binary(dtype):
-                    dtype = np.dtype("S")
-                else:
-                    dtype = np.dtype(dtype.to_pandas_dtype())
-                result.append(domain_slot_getter(name, dtype))
+                result.append(tuple(e.as_py() for e in slot))
+
         return tuple(result)
 
     @property
     def domain(self) -> Tuple[Tuple[object, object], ...]:
-        return self._get_and_cast_domain(self._handle.soma_domain_slot)
+        return self._cast_domainish(self._handle.domain())
 
     @property
     def maxdomain(self) -> Tuple[Tuple[object, object], ...]:
-        return self._get_and_cast_domain(self._handle.soma_maxdomain_slot)
+        return self._cast_domainish(self._handle.maxdomain())
 
     def non_empty_domain(self) -> Tuple[Tuple[object, object], ...]:
-        return self._get_and_cast_domain(self._handle.non_empty_domain_slot) or ()
+        return self._cast_domainish(self._handle.non_empty_domain())
 
     @property
     def attr_names(self) -> Tuple[str, ...]:
diff --git a/apis/python/src/tiledbsoma/soma_array.cc b/apis/python/src/tiledbsoma/soma_array.cc
index ddc5d6a6d6..40c53ae34e 100644
--- a/apis/python/src/tiledbsoma/soma_array.cc
+++ b/apis/python/src/tiledbsoma/soma_array.cc
@@ -86,6 +86,34 @@ void write_coords(
     }
 }
 
+// This is shared code for non-empty domain, domain, and maxdomain.  Returns a
+// Python list of Arrow arrays. Python code can convert these further.
+py::list domainish_to_list(ArrowArray* arrow_array, ArrowSchema* arrow_schema) {
+    auto pa = py::module::import("pyarrow");
+    auto pa_array_import = pa.attr("Array").attr("_import_from_c");
+
+    py::list array_list;
+    for (int i = 0; i < arrow_array->n_children; i++) {
+        // Note that this runs the release callbacks within the ArrowArray and
+        // the ArrowSchema, freeing memory.
+        auto array = pa_array_import(
+            py::capsule(arrow_array->children[i]),
+            py::capsule(arrow_schema->children[i]));
+        array_list.append(array);
+
+        // Already released: ensure there is no attempt at second free.
+        arrow_array->children[i] = nullptr;
+        arrow_schema->children[i] = nullptr;
+    }
+    // Already released: ensure there is no attempt at second free.
+    arrow_array->n_children = 0;
+    arrow_array->children = nullptr;
+    arrow_schema->n_children = 0;
+    arrow_schema->children = nullptr;
+
+    return array_list;
+}
+
 void load_soma_array(py::module& m) {
     py::class_<SOMAArray, SOMAObject>(m, "SOMAArray")
         .def(
@@ -668,6 +696,34 @@ void load_soma_array(py::module& m) {
                 return py::cast(array.timestamp()->second);
             })
 
+        .def("domainish_to_list", domainish_to_list)
+
+        .def(
+            "non_empty_domain",
+            [](SOMAArray& array) {
+                ArrowTable arrow_table = array.get_non_empty_domain();
+                return domainish_to_list(
+                    arrow_table.first.get(), arrow_table.second.get());
+            })
+
+        .def(
+            "domain",
+            [](SOMAArray& array) {
+                auto pa = py::module::import("pyarrow");
+                ArrowTable arrow_table = array.get_soma_domain();
+                return domainish_to_list(
+                    arrow_table.first.get(), arrow_table.second.get());
+            })
+
+        .def(
+            "maxdomain",
+            [](SOMAArray& array) {
+                auto pa = py::module::import("pyarrow");
+                ArrowTable arrow_table = array.get_soma_maxdomain();
+                return domainish_to_list(
+                    arrow_table.first.get(), arrow_table.second.get());
+            })
+
         .def(
             "non_empty_domain_slot",
             [](SOMAArray& array, std::string name, py::dtype dtype) {
diff --git a/apis/r/R/RcppExports.R b/apis/r/R/RcppExports.R
index f5906c2c9b..326c898385 100644
--- a/apis/r/R/RcppExports.R
+++ b/apis/r/R/RcppExports.R
@@ -182,6 +182,18 @@ maxshape <- function(uri, ctxxp) {
     .Call(`_tiledbsoma_maxshape`, uri, ctxxp)
 }
 
+non_empty_domain_new <- function(uri, ctxxp) {
+    .Call(`_tiledbsoma_non_empty_domain_new`, uri, ctxxp)
+}
+
+domain <- function(uri, ctxxp) {
+    .Call(`_tiledbsoma_domain`, uri, ctxxp)
+}
+
+maxdomain <- function(uri, ctxxp) {
+    .Call(`_tiledbsoma_maxdomain`, uri, ctxxp)
+}
+
 maybe_soma_joinid_shape <- function(uri, ctxxp) {
     .Call(`_tiledbsoma_maybe_soma_joinid_shape`, uri, ctxxp)
 }
diff --git a/apis/r/R/SOMADataFrame.R b/apis/r/R/SOMADataFrame.R
index 250dbdc856..803468a601 100644
--- a/apis/r/R/SOMADataFrame.R
+++ b/apis/r/R/SOMADataFrame.R
@@ -339,6 +339,30 @@ SOMADataFrame <- R6::R6Class(
       class = 'notYetImplementedError'
     )),
 
+    #' @description Returns a named list of minimum/maximum pairs, one per index
+    #' column, currently storable on each index column of the dataframe. These
+    #' can be resized up to `maxdomain`.
+    #' (lifecycle: maturing)
+    #' @return Named list of minimum/maximum values.
+    domain = function() {
+      as.list(
+        arrow::as_record_batch(
+          arrow::as_arrow_table(
+            domain(self$uri, private$.soma_context))))
+    },
+
+    #' @description Returns a named list of minimum/maximum pairs, one per index
+    #' column, which are the limits up to which the dataframe can have its
+    #' domain resized.
+    #' (lifecycle: maturing)
+    #' @return Named list of minimum/maximum values.
+    maxdomain = function() {
+      as.list(
+        arrow::as_record_batch(
+          arrow::as_arrow_table(
+            maxdomain(self$uri, private$.soma_context))))
+    },
+
     #' @description Returns TRUE if the array has the upgraded resizeable domain
     #' feature from TileDB-SOMA 1.15: the array was created with this support,
     #' or it has had ``upgrade_domain`` applied to it.
diff --git a/apis/r/R/TileDBArray.R b/apis/r/R/TileDBArray.R
index fb7719544e..3f27012bfa 100644
--- a/apis/r/R/TileDBArray.R
+++ b/apis/r/R/TileDBArray.R
@@ -245,6 +245,22 @@ TileDBArray <- R6::R6Class(
       return(ned)
     },
 
+    #' @description Returns a named list of minimum/maximum pairs, one per index
+    #' column, which are the smallest and largest values written on that
+    #' index column.
+    #'
+    #' As tracked on https://github.com/single-cell-data/TileDB-SOMA/issues/2407
+    #' this will replace the existing `non_empty_domain` method.
+    #'
+    #' (lifecycle: maturing)
+    #' @return Named list of minimum/maximum values.
+    non_empty_domain_new = function() {
+      as.list(
+        arrow::as_record_batch(
+          arrow::as_arrow_table(
+            non_empty_domain_new(self$uri, private$.soma_context))))
+    },
+
     #' @description Retrieve number of dimensions (lifecycle: maturing)
     #' @return A scalar with the number of dimensions
     ndim = function() {
diff --git a/apis/r/R/utils-arrow.R b/apis/r/R/utils-arrow.R
index 7ee2ebc2ae..b691579723 100644
--- a/apis/r/R/utils-arrow.R
+++ b/apis/r/R/utils-arrow.R
@@ -370,13 +370,19 @@ get_domain_and_extent_dataframe <- function(tbl_schema, ind_col_names,
             ind_ext <- 64L
         }
 
-        # We need to do this because if we don't:
+        # We need to subtract off extent from the max because if we don't:
         #
         # Error: [TileDB::Dimension] Error: Tile extent check failed; domain max
         # expanded to multiple of tile extent exceeds max value representable by
         # domain type. Reduce domain max by 1 tile extent to allow for
         # expansion.
-        ind_max_dom <- arrow_type_unsigned_range(ind_col_type) - c(0,ind_ext)
+        if (ind_col_name == "soma_joinid") {
+          # Must be non-negative
+          ind_max_dom <- arrow_type_unsigned_range(ind_col_type) - c(0, ind_ext)
+        } else {
+          # Others can be negative
+          ind_max_dom <- arrow_type_range(ind_col_type) - c(0, ind_ext)
+        }
 
         ind_cur_dom <- ind_max_dom
         if (ind_col_type_name %in% c("string", "large_utf8", "utf8")) ind_ext <- NA
diff --git a/apis/r/src/RcppExports.cpp b/apis/r/src/RcppExports.cpp
index 332271939d..ad953fe658 100644
--- a/apis/r/src/RcppExports.cpp
+++ b/apis/r/src/RcppExports.cpp
@@ -380,6 +380,42 @@ BEGIN_RCPP
     return rcpp_result_gen;
 END_RCPP
 }
+// non_empty_domain_new
+SEXP non_empty_domain_new(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp);
+RcppExport SEXP _tiledbsoma_non_empty_domain_new(SEXP uriSEXP, SEXP ctxxpSEXP) {
+BEGIN_RCPP
+    Rcpp::RObject rcpp_result_gen;
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< Rcpp::XPtr<somactx_wrap_t> >::type ctxxp(ctxxpSEXP);
+    rcpp_result_gen = Rcpp::wrap(non_empty_domain_new(uri, ctxxp));
+    return rcpp_result_gen;
+END_RCPP
+}
+// domain
+SEXP domain(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp);
+RcppExport SEXP _tiledbsoma_domain(SEXP uriSEXP, SEXP ctxxpSEXP) {
+BEGIN_RCPP
+    Rcpp::RObject rcpp_result_gen;
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< Rcpp::XPtr<somactx_wrap_t> >::type ctxxp(ctxxpSEXP);
+    rcpp_result_gen = Rcpp::wrap(domain(uri, ctxxp));
+    return rcpp_result_gen;
+END_RCPP
+}
+// maxdomain
+SEXP maxdomain(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp);
+RcppExport SEXP _tiledbsoma_maxdomain(SEXP uriSEXP, SEXP ctxxpSEXP) {
+BEGIN_RCPP
+    Rcpp::RObject rcpp_result_gen;
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< Rcpp::XPtr<somactx_wrap_t> >::type ctxxp(ctxxpSEXP);
+    rcpp_result_gen = Rcpp::wrap(maxdomain(uri, ctxxp));
+    return rcpp_result_gen;
+END_RCPP
+}
 // maybe_soma_joinid_shape
 Rcpp::NumericVector maybe_soma_joinid_shape(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp);
 RcppExport SEXP _tiledbsoma_maybe_soma_joinid_shape(SEXP uriSEXP, SEXP ctxxpSEXP) {
@@ -663,6 +699,9 @@ static const R_CallMethodDef CallEntries[] = {
     {"_tiledbsoma_check_arrow_array_tag", (DL_FUNC) &_tiledbsoma_check_arrow_array_tag, 1},
     {"_tiledbsoma_shape", (DL_FUNC) &_tiledbsoma_shape, 2},
     {"_tiledbsoma_maxshape", (DL_FUNC) &_tiledbsoma_maxshape, 2},
+    {"_tiledbsoma_non_empty_domain_new", (DL_FUNC) &_tiledbsoma_non_empty_domain_new, 2},
+    {"_tiledbsoma_domain", (DL_FUNC) &_tiledbsoma_domain, 2},
+    {"_tiledbsoma_maxdomain", (DL_FUNC) &_tiledbsoma_maxdomain, 2},
     {"_tiledbsoma_maybe_soma_joinid_shape", (DL_FUNC) &_tiledbsoma_maybe_soma_joinid_shape, 2},
     {"_tiledbsoma_maybe_soma_joinid_maxshape", (DL_FUNC) &_tiledbsoma_maybe_soma_joinid_maxshape, 2},
     {"_tiledbsoma_has_current_domain", (DL_FUNC) &_tiledbsoma_has_current_domain, 2},
diff --git a/apis/r/src/rinterface.cpp b/apis/r/src/rinterface.cpp
index 3cb7acf25e..12eb0f3d6f 100644
--- a/apis/r/src/rinterface.cpp
+++ b/apis/r/src/rinterface.cpp
@@ -278,6 +278,34 @@ Rcpp::NumericVector maxshape(
     return retval;
 }
 
+// [[Rcpp::export]]
+SEXP non_empty_domain_new(
+    const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp) {
+    auto sdf = tdbs::SOMADataFrame::open(uri, OpenMode::read, ctxxp->ctxptr);
+    tdbs::ArrowTable arrow_table = sdf->get_non_empty_domain();
+    SEXP retval = convert_domainish(arrow_table);
+    sdf->close();
+    return retval;
+}
+
+// [[Rcpp::export]]
+SEXP domain(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp) {
+    auto sdf = tdbs::SOMADataFrame::open(uri, OpenMode::read, ctxxp->ctxptr);
+    tdbs::ArrowTable arrow_table = sdf->get_soma_domain();
+    SEXP retval = convert_domainish(arrow_table);
+    sdf->close();
+    return retval;
+}
+
+// [[Rcpp::export]]
+SEXP maxdomain(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp) {
+    auto sdf = tdbs::SOMADataFrame::open(uri, OpenMode::read, ctxxp->ctxptr);
+    tdbs::ArrowTable arrow_table = sdf->get_soma_maxdomain();
+    SEXP retval = convert_domainish(arrow_table);
+    sdf->close();
+    return retval;
+}
+
 // [[Rcpp::export]]
 Rcpp::NumericVector maybe_soma_joinid_shape(
     const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp) {
diff --git a/apis/r/src/rutilities.cpp b/apis/r/src/rutilities.cpp
index 4b1743899e..efdc0566ed 100644
--- a/apis/r/src/rutilities.cpp
+++ b/apis/r/src/rutilities.cpp
@@ -4,8 +4,11 @@
 #define TILEDB_NO_API_DEPRECATION_WARNINGS
 #endif
 
-#include <Rcpp.h>                 // for R interface to C++
-#include <nanoarrow/nanoarrow.h>  // for C interface to Arrow
+#include <Rcpp.h>                           // for R interface to C++
+#include <nanoarrow/r.h>                    // for C interface to Arrow (via R package)
+#include <nanoarrow/nanoarrow.h>            // for C interface to Arrow
+#include <RcppInt64>                        // for fromInteger64
+#include <tiledbsoma/tiledbsoma>
 #include <tiledbsoma/reindexer/reindexer.h>
 #include <RcppInt64>  // for fromInteger64
 #include <tiledbsoma/tiledbsoma>
@@ -437,3 +440,47 @@ std::optional<tdbs::TimestampRange> makeTimestampRange(
 
     return tsrng;
 }
+
+SEXP convert_domainish(const tdbs::ArrowTable& arrow_table) {
+    ArrowArray* arrow_array = arrow_table.first.get();
+    ArrowSchema* arrow_schema = arrow_table.second.get();
+
+    auto schemaxp = nanoarrow_schema_owning_xptr();
+    auto sch = nanoarrow_output_schema_from_xptr(schemaxp);
+    exitIfError(
+        ArrowSchemaInitFromType(sch, NANOARROW_TYPE_STRUCT), "Bad schema init");
+    exitIfError(ArrowSchemaSetName(sch, ""), "Bad schema name");
+    exitIfError(
+        ArrowSchemaAllocateChildren(sch, arrow_schema->n_children),
+        "Bad schema children alloc");
+
+    if (arrow_array->n_children != arrow_schema->n_children) {
+        Rcpp::stop(
+            "schema/data column mismatch %d != %d\n",
+            (int)arrow_array->n_children,
+            (int)arrow_schema->n_children);
+    }
+    auto ncol = arrow_schema->n_children;
+
+    auto arrayxp = nanoarrow_array_owning_xptr();
+    auto arr = nanoarrow_output_array_from_xptr(arrayxp);
+    exitIfError(
+        ArrowArrayInitFromType(arr, NANOARROW_TYPE_STRUCT), "Bad array init");
+    exitIfError(
+        ArrowArrayAllocateChildren(arr, arrow_array->n_children),
+        "Bad array children alloc");
+
+    for (size_t i = 0; i < ncol; i++) {
+        spdl::info(
+            "[domainish] name {} length {}",
+            std::string(arrow_schema->children[i]->name),
+            arrow_array->children[i]->length);
+        ArrowArrayMove(arrow_array->children[i], arr->children[i]);
+        ArrowSchemaMove(arrow_schema->children[i], sch->children[i]);
+    }
+
+    // Nanoarrow special: stick schema into xptr tag to return single SEXP
+    array_xptr_set_schema(arrayxp, schemaxp);  // embed schema in array
+
+    return arrayxp;
+}
diff --git a/apis/r/src/rutilities.h b/apis/r/src/rutilities.h
index e451013d58..03990c08ef 100644
--- a/apis/r/src/rutilities.h
+++ b/apis/r/src/rutilities.h
@@ -70,3 +70,9 @@ inline void array_xptr_set_schema(SEXP array_xptr, SEXP schema_xptr) {
 std::optional<tdbs::TimestampRange> makeTimestampRange(Rcpp::Nullable<Rcpp::DatetimeVector> tsvec);
 
 std::vector<int64_t> i64_from_rcpp_numeric(const Rcpp::NumericVector& input);
+
+// Code reuse for non_empty_domain, domain, and maxdomain all of which:
+// * call a libtiledbsoma function
+// * obtain an ArrowTable
+// * need to map that to an R list of lo/hi pairs
+SEXP convert_domainish(const tdbs::ArrowTable& arrow_table);
diff --git a/libtiledbsoma/src/soma/soma_array.cc b/libtiledbsoma/src/soma/soma_array.cc
index fdfdfa9308..d341a30e19 100644
--- a/libtiledbsoma/src/soma/soma_array.cc
+++ b/libtiledbsoma/src/soma/soma_array.cc
@@ -1173,6 +1173,28 @@ ArrowTable SOMAArray::_get_core_domainish(enum Domainish which_kind) {
 
         switch (core_type_code) {
             case TILEDB_INT64:
+            case TILEDB_DATETIME_YEAR:
+            case TILEDB_DATETIME_MONTH:
+            case TILEDB_DATETIME_WEEK:
+            case TILEDB_DATETIME_DAY:
+            case TILEDB_DATETIME_HR:
+            case TILEDB_DATETIME_MIN:
+            case TILEDB_DATETIME_SEC:
+            case TILEDB_DATETIME_MS:
+            case TILEDB_DATETIME_US:
+            case TILEDB_DATETIME_NS:
+            case TILEDB_DATETIME_PS:
+            case TILEDB_DATETIME_FS:
+            case TILEDB_DATETIME_AS:
+            case TILEDB_TIME_HR:
+            case TILEDB_TIME_MIN:
+            case TILEDB_TIME_SEC:
+            case TILEDB_TIME_MS:
+            case TILEDB_TIME_US:
+            case TILEDB_TIME_NS:
+            case TILEDB_TIME_PS:
+            case TILEDB_TIME_FS:
+            case TILEDB_TIME_AS:
                 child = ArrowAdapter::make_arrow_array_child(
                     _core_domainish_slot<int64_t>(core_dim.name(), which_kind));
                 break;
@@ -1422,11 +1444,6 @@ void SOMAArray::_set_current_domain_from_shape(
     // Variant-indexed dataframes must use a separate path
     _check_dims_are_int64();
 
-    if (_get_current_domain().is_empty()) {
-        throw TileDBSOMAError(
-            "[SOMAArray::resize] array must already be sized");
-    }
-
     auto tctx = ctx_->tiledb_ctx();
     ArraySchema schema = arr_->schema();
     Domain domain = schema.domain();
diff --git a/libtiledbsoma/src/utils/arrow_adapter.cc b/libtiledbsoma/src/utils/arrow_adapter.cc
index 6b11a7232c..ac2ea971e1 100644
--- a/libtiledbsoma/src/utils/arrow_adapter.cc
+++ b/libtiledbsoma/src/utils/arrow_adapter.cc
@@ -928,7 +928,40 @@ ArraySchema ArrowAdapter::tiledb_schema_from_arrow_schema(
                     //
                     // Fortunately, these are ASCII dims and we can range
                     // these accordingly.
-                    ndrect.set_range(col_name, "", "\xff");
+
+                    ArrowArray* child_array = index_column_array->children[i];
+                    ArrowSchema* child_schema = index_column_schema
+                                                    ->children[i];
+
+                    std::vector<std::string>
+                        strings = ArrowAdapter::get_array_string_column(
+                            child_array, child_schema);
+                    if (strings.size() != 5) {
+                        throw TileDBSOMAError(fmt::format(
+                            "ArrowAdapter::tiledb_schema_from_arrow_schema: "
+                            "internal error: "
+                            "expected 5 strings, got {}",
+                            strings.size()));
+                    }
+
+                    std::string lo = strings[3];
+                    std::string hi = strings[4];
+                    if (lo == "" && hi == "") {
+                        // These mean "I the caller don't care, you
+                        // libtiledbsoma make it as big as possible"
+                        ndrect.set_range(col_name, "", "\xff");
+                    } else {
+                        ndrect.set_range(col_name, lo, hi);
+                        LOG_DEBUG(fmt::format(
+                            "[ArrowAdapter] index_column_info nbuf {}",
+                            index_column_array->children[i]->n_buffers));
+                    }
+
+                    LOG_DEBUG(fmt::format(
+                        "[ArrowAdapter] current domain {} \"{}\"-\"{}\"",
+                        child_schema->name,
+                        lo,
+                        hi));
                 } else {
                     const void* buff = index_column_array->children[i]
                                            ->buffers[1];
diff --git a/libtiledbsoma/src/utils/arrow_adapter.h b/libtiledbsoma/src/utils/arrow_adapter.h
index 2bebffbbab..6c1258c3bf 100644
--- a/libtiledbsoma/src/utils/arrow_adapter.h
+++ b/libtiledbsoma/src/utils/arrow_adapter.h
@@ -8,6 +8,32 @@
 // https://arrow.apache.org/docs/format/Columnar.html#buffer-listing-for-each-layout
 // https://arrow.apache.org/docs/format/CDataInterface.html#exporting-a-simple-int32-array
 
+// A general developer note: in several places we have
+//
+//     template <typename T>
+//     static sometype foo(T arg) {
+//         if (std::is_same_v<T, std::string>) {
+//             throw std::runtime_error(...);
+//         }
+//         }D...
+//     }
+//
+//     static sometype foo_string(std::string arg) { ... }
+//
+// -- with explicit `_string` suffix -- rather than
+//
+//     template <typename T>
+//     static sometype foo(T arg) ...
+//
+//     template <>
+//     static sometype foo(std::string arg) ...
+//
+// We're aware of the former but we've found it a bit fiddly across systems and
+// compiler versions -- namely, with the latter we find it tricky to always
+// avoid the <typename T> variant being templated with std::string. It's simple,
+// explicit, and robust to go the `_string` suffix route, and it's friendlier to
+// current and future maintainers of this code.
+
 #include "nanoarrow/nanoarrow.hpp"
 #include "nlohmann/json.hpp"
 
@@ -414,10 +440,10 @@ class ArrowAdapter {
     // for keystroke-reduction in unit-test cases.
 
     template <typename T>
-    static std::vector<T> get_table_column_by_name(
+    static std::vector<T> get_table_non_string_column_by_name(
         const ArrowTable& arrow_table, std::string column_name) {
         int64_t index = _get_column_index_from_name(arrow_table, column_name);
-        return get_table_column_by_index<T>(arrow_table, index);
+        return get_table_non_string_column_by_index<T>(arrow_table, index);
     }
 
     static std::vector<std::string> get_table_string_column_by_name(
@@ -426,8 +452,12 @@ class ArrowAdapter {
         return get_table_string_column_by_index(arrow_table, index);
     }
 
+    /**
+     * Returns a copy of the data in a specified non-string column of an
+     * ArrowTable as a standard/non-Arrow C++ object.
+     */
     template <typename T>
-    static std::vector<T> get_table_column_by_index(
+    static std::vector<T> get_table_non_string_column_by_index(
         const ArrowTable& arrow_table, int64_t column_index) {
         ArrowArray* arrow_array = arrow_table.first.get();
         ArrowSchema* arrow_schema = arrow_table.second.get();
@@ -435,75 +465,173 @@ class ArrowAdapter {
 
         if (std::is_same_v<T, std::string>) {
             throw std::runtime_error(
-                "SOMAArray::_core_domain_slot: template-specialization "
+                "SOMAArray::get_table_non_string_column_by_index: "
+                "template-specialization failure.");
+        }
+
+        ArrowArray* child_array = _get_and_check_column(
+            arrow_table, column_index, 2);
+
+        return get_array_non_string_column<T>(child_array);
+    }
+
+    /**
+     * Returns a copy of the data in a specified string column of an
+     * ArrowTable as a standard/non-Arrow C++ object.
+     */
+    static std::vector<std::string> get_table_string_column_by_index(
+        const ArrowTable& arrow_table, int64_t column_index) {
+        ArrowArray* arrow_array = arrow_table.first.get();
+        ArrowSchema* arrow_schema = arrow_table.second.get();
+        _check_shapes(arrow_array, arrow_schema);
+
+        ArrowArray* child_array = _get_and_check_column(
+            arrow_table, column_index, 3);
+
+        const ArrowSchema* child_schema = arrow_schema->children[column_index];
+
+        return get_array_string_column(child_array, child_schema);
+    }
+
+    /**
+     * Returns a copy of the data in a specified non-string column of an
+     * ArrowArray as a standard/non-Arrow C++ object. There is no ArrowSchema*
+     * argument, as the caller must have determined the Arrow type, inferred
+     * a C++ type, and have invoked this method with the appropriate C++ type.
+     *
+     * This is a helper for get_table_non_string_column_by_index; also exposed
+     * for callsites which have access to child objects which are not top-level
+     * ArrowTables.
+     */
+    template <typename T>
+    static std::vector<T> get_array_non_string_column(
+        const ArrowArray* arrow_array) {
+        if (arrow_array->n_children != 0) {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_non_string_column: expected leaf "
+                "node");
+        }
+        if (arrow_array->n_buffers != 2) {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_non_string_column: expected two "
+                "buffers");
+        }
+
+        if (std::is_same_v<T, std::string>) {
+            throw std::runtime_error(
+                "SOMAArray::get_array_non_string_column: "
+                "template-specialization "
                 "failure.");
         }
 
-        ArrowArray* child = _get_and_check_column(arrow_table, column_index, 2);
+        // Two-buffer model for non-string data:
+        // * Slot 0 is the Arrow validity buffer which we leave null
+        // * Slot 1 is data, void* but will be derefrenced as T*
+        // * There is no offset information
 
         // For our purposes -- reporting domains, etc. -- we don't use the Arrow
         // validity buffers. If this class needs to be extended someday to
         // support arrow-nulls, we can work on that.
-        if (child->buffers[0] != nullptr) {
+        if (arrow_array->buffers[0] != nullptr) {
             throw std::runtime_error(
-                "ArrowAdapter::get_table_column_by_index: validity buffer "
+                "ArrowAdapter::get_array_non_string_column: validity buffer "
                 "unsupported here");
         }
+        if (arrow_array->buffers[1] == nullptr) {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_non_string_column: null data buffer");
+        }
 
-        const void* vdata = child->buffers[1];
+        const void* vdata = arrow_array->buffers[1];
         if (vdata == nullptr) {
             throw std::runtime_error(
-                "ArrowAdapter::get_table_column_by_index: null data buffer");
+                "ArrowAdapter::get_array_non_string_column: null data buffer");
         }
 
         const T* data = (T*)vdata;
 
-        std::vector<T> retval(child->length);
-        for (auto i = 0; i < child->length; i++) {
+        std::vector<T> retval(arrow_array->length);
+        for (auto i = 0; i < arrow_array->length; i++) {
             retval[i] = data[i];
         }
         return retval;
     }
 
-    static std::vector<std::string> get_table_string_column_by_index(
-        const ArrowTable& arrow_table, int64_t column_index) {
-        ArrowArray* arrow_array = arrow_table.first.get();
-        ArrowSchema* arrow_schema = arrow_table.second.get();
-        _check_shapes(arrow_array, arrow_schema);
+    /**
+     * Returns a copy of the data in a specified non-string column of an
+     * ArrowArray as a standard/non-Arrow C++ object.
+     * Helper for get_table_string_column_by_index. Also exposed for
+     * callsites which have access to child objects which are not top-level
+     * ArrowTables.
+     */
+    static std::vector<std::string> get_array_string_column(
+        const ArrowArray* arrow_array, const ArrowSchema* arrow_schema) {
+        if (arrow_array->n_children != 0 || arrow_schema->n_children != 0) {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_string_column: expected leaf "
+                "node");
+        }
+        if (arrow_array->n_buffers != 3) {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_string_column: expected three "
+                "buffers");
+        }
 
-        ArrowArray* child = _get_and_check_column(arrow_table, column_index, 3);
+        // Three-buffer model for string data:
+        // * Slot 0 is the Arrow uint8_t* validity buffer
+        // * Slot 1 is the Arrow offsets buffer: uint32_t* for Arrow string
+        //   or uint64_t* for Arrow large_string
+        // * Slot 2 is data, void* but will be derefrenced as T*
 
         // For our purposes -- reporting domains, etc. -- we don't use the Arrow
         // validity buffers. If this class needs to be extended someday to
         // support arrow-nulls, we can work on that.
-        if (child->buffers[0] != nullptr) {
+        if (arrow_array->buffers[0] != nullptr) {
             throw std::runtime_error(
-                "ArrowAdapter::get_table_column_by_index: validity buffer "
+                "ArrowAdapter::get_array_string_column: validity buffer "
                 "unsupported here");
         }
-
-        const char* data = (char*)child->buffers[2];
-
-        if (data == nullptr) {
+        if (arrow_array->buffers[1] == nullptr) {
             throw std::runtime_error(
-                "ArrowAdapter::get_table_column_by_index: null data buffer");
+                "ArrowAdapter::get_array_string_column: null "
+                "offsets buffer");
         }
-
-        if (strcmp(arrow_schema->children[column_index]->format, "U") != 0) {
+        if (arrow_array->buffers[2] == nullptr) {
             throw std::runtime_error(
-                "ArrowAdapter::get_table_column_by_index: expected Arrow "
-                "large_string");
+                "ArrowAdapter::get_array_string_column: null data "
+                "buffer");
         }
-        uint64_t* offsets = (uint64_t*)child->buffers[1];
 
-        int num_cells = (int)child->length;
-        std::vector<std::string> retval(num_cells);
-        for (int j = 0; j < num_cells; j++) {
-            std::string e(&data[offsets[j]], &data[offsets[j + 1]]);
-            retval[j] = e;
+        const char* data = (char*)arrow_array->buffers[2];
+
+        if (strcmp(arrow_schema->format, "u") == 0 ||
+            strcmp(arrow_schema->format, "z") == 0) {
+            uint32_t* offsets = (uint32_t*)arrow_array->buffers[1];
+            int num_cells = (int)arrow_array->length;
+            std::vector<std::string> retval(num_cells);
+            for (int j = 0; j < num_cells; j++) {
+                std::string e(&data[offsets[j]], &data[offsets[j + 1]]);
+                retval[j] = e;
+            }
+            return retval;
+
+        } else if (
+            strcmp(arrow_schema->format, "U") == 0 ||
+            strcmp(arrow_schema->format, "Z") == 0) {
+            uint64_t* offsets = (uint64_t*)arrow_array->buffers[1];
+            int num_cells = (int)arrow_array->length;
+            std::vector<std::string> retval(num_cells);
+            for (int j = 0; j < num_cells; j++) {
+                std::string e(&data[offsets[j]], &data[offsets[j + 1]]);
+                retval[j] = e;
+            }
+            return retval;
+
+        } else {
+            throw std::runtime_error(
+                "ArrowAdapter::get_array_string_column: expected "
+                "Arrow string, large_string, binary, or large_binary");
         }
-
-        return retval;
     }
 
    private:


Test patch:
diff --git a/apis/r/tests/testthat/test-shape.R b/apis/r/tests/testthat/test-shape.R
index 2df8c23e77..7526f4d2af 100644
--- a/apis/r/tests/testthat/test-shape.R
+++ b/apis/r/tests/testthat/test-shape.R
@@ -10,11 +10,14 @@ test_that("SOMADataFrame shape", {
     c("string_column", "int_column")
   )
 
-  for (index_column_names in index_column_name_choices) {
+  for (i in seq_along(index_column_name_choices)) {
+    index_column_names <- index_column_name_choices[[i]]
+
     has_soma_joinid_dim <- "soma_joinid" %in% index_column_names
 
     if (dir.exists(uri)) unlink(uri, recursive=TRUE)
 
+    # TODO: test create with specified domain on PR 3032
     sdf <- SOMADataFrameCreate(uri, asch, index_column_names = index_column_names)
     expect_true(sdf$exists())
     expect_true(dir.exists(uri))
@@ -56,6 +59,44 @@ test_that("SOMADataFrame shape", {
       expect_true(is.na(sjid_maxshape))
     }
 
+    dom <- sdf$domain()
+    mxd <- sdf$maxdomain()
+
+    # First check names
+    expect_equal(names(dom), index_column_names)
+    expect_equal(names(mxd), index_column_names)
+
+    # Then check all slots are pairs
+    for (name in names(dom)) {
+      expect_length(dom[[name]], 2L)
+      expect_length(mxd[[name]], 2L)
+    }
+
+    # Then check contents
+    if ("soma_joinid" %in% index_column_names) {
+      sjid_dom <- dom[["soma_joinid"]]
+      sjid_mxd <- mxd[["soma_joinid"]]
+      expect_equal(sjid_dom[[1]], 0)
+      expect_equal(sjid_mxd[[1]], 0)
+      # Really big number; exact value unimportant
+      # TODO: test create with specified domain on PR 3032
+      # -- then, current and max domain will be different
+      expect_true(sjid_dom[[2]] > bit64::as.integer64(10000000000))
+      expect_true(sjid_mxd[[2]] > bit64::as.integer64(10000000000))
+    }
+
+    if ("int_column" %in% index_column_names) {
+      int_dom <- dom[["int_column"]]
+      int_mxd <- mxd[["int_column"]]
+      expect_true(int_dom[[1]] < -2000000000)
+      expect_true(int_dom[[2]] > 2000000000)
+    }
+
+    if ("string_column" %in% index_column_names) {
+      expect_equal(dom[["string_column"]], c("", ""))
+      expect_equal(mxd[["string_column"]], c("", ""))
+    }
+
     sdf$close()
 
     rm(sdf, tbl0)
diff --git a/libtiledbsoma/test/common.cc b/libtiledbsoma/test/common.cc
index f19d16ea19..5d9e4e9535 100644
--- a/libtiledbsoma/test/common.cc
+++ b/libtiledbsoma/test/common.cc
@@ -197,7 +197,8 @@ static std::unique_ptr<ArrowArray> _create_index_cols_info_array(
             // arrow_adapter for more info. We rely on arrow_adapter to also
             // handle this case.
             if (info.use_current_domain) {
-                std::vector<std::string> dom({"", "", "", "", ""});
+                std::vector<std::string> dom(
+                    {"", "", "", info.string_lo, info.string_hi});
                 dim_array = ArrowAdapter::make_arrow_array_child_string(dom);
             } else {
                 std::vector<std::string> dom({"", "", ""});
diff --git a/libtiledbsoma/test/common.h b/libtiledbsoma/test/common.h
index e4ad5f8476..e8e838724f 100644
--- a/libtiledbsoma/test/common.h
+++ b/libtiledbsoma/test/common.h
@@ -67,6 +67,8 @@ struct DimInfo {
     std::string name;
     tiledb_datatype_t tiledb_datatype;
     int64_t dim_max;
+    std::string string_lo;  // For custom/restricted DataFrame domains
+    std::string string_hi;  // For custom/restricted DataFrame domains
     bool use_current_domain;
 };
 
diff --git a/libtiledbsoma/test/unit_arrow_adapter.cc b/libtiledbsoma/test/unit_arrow_adapter.cc
index 22868dd3a6..573f0abf74 100644
--- a/libtiledbsoma/test/unit_arrow_adapter.cc
+++ b/libtiledbsoma/test/unit_arrow_adapter.cc
@@ -73,20 +73,20 @@ TEST_CASE("name", "[pattern]") {
 
     ArrowTable arrow_table(std::move(arrow_array), std::move(arrow_schema));
 
-    std::vector<int64_t>
-        outputs_int64 = ArrowAdapter::get_table_column_by_name<int64_t>(
+    std::vector<int64_t> outputs_int64 =
+        ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             arrow_table, "int64");
-    std::vector<uint8_t>
-        outputs_uint8 = ArrowAdapter::get_table_column_by_name<uint8_t>(
+    std::vector<uint8_t> outputs_uint8 =
+        ArrowAdapter::get_table_non_string_column_by_name<uint8_t>(
             arrow_table, "uint8");
-    std::vector<double>
-        outputs_float64 = ArrowAdapter::get_table_column_by_name<double>(
+    std::vector<double> outputs_float64 =
+        ArrowAdapter::get_table_non_string_column_by_name<double>(
             arrow_table, "float64");
-    std::vector<float>
-        outputs_float32 = ArrowAdapter::get_table_column_by_name<float>(
+    std::vector<float> outputs_float32 =
+        ArrowAdapter::get_table_non_string_column_by_name<float>(
             arrow_table, "float32");
     std::vector<bool>
-        outputs_bool = ArrowAdapter::get_table_column_by_name<bool>(
+        outputs_bool = ArrowAdapter::get_table_non_string_column_by_name<bool>(
             arrow_table, "bool");
     std::vector<std::string>
         outputs_string = ArrowAdapter::get_table_string_column_by_name(
diff --git a/libtiledbsoma/test/unit_soma_collection.cc b/libtiledbsoma/test/unit_soma_collection.cc
index 48bb782f9a..b6f3e6eff7 100644
--- a/libtiledbsoma/test/unit_soma_collection.cc
+++ b/libtiledbsoma/test/unit_soma_collection.cc
@@ -71,6 +71,8 @@ TEST_CASE("SOMACollection: add SOMASparseNDArray") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
@@ -125,6 +127,8 @@ TEST_CASE("SOMACollection: add SOMADenseNDArray") {
         {{.name = dim_name,
           .tiledb_datatype = tiledb_datatype,
           .dim_max = DIM_MAX,
+          .string_lo = "N/A",
+          .string_hi = "N/A",
           .use_current_domain = false}});
     auto index_columns = helper::create_column_index_info(dim_infos);
 
@@ -179,6 +183,8 @@ TEST_CASE("SOMACollection: add SOMADataFrame") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
         std::vector<helper::AttrInfo> attr_infos(
             {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
@@ -273,6 +279,8 @@ TEST_CASE("SOMACollection: add SOMAExperiment") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
         std::vector<helper::AttrInfo> attr_infos(
             {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
@@ -327,6 +335,8 @@ TEST_CASE("SOMACollection: add SOMAMeasurement") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
         std::vector<helper::AttrInfo> attr_infos(
             {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
@@ -440,6 +450,8 @@ TEST_CASE("SOMAExperiment: metadata") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
         std::vector<helper::AttrInfo> attr_infos(
             {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
@@ -532,6 +544,8 @@ TEST_CASE("SOMAMeasurement: metadata") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = DIM_MAX,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
         std::vector<helper::AttrInfo> attr_infos(
             {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
diff --git a/libtiledbsoma/test/unit_soma_dataframe.cc b/libtiledbsoma/test/unit_soma_dataframe.cc
index 2171bc50e6..33089f6ea9 100644
--- a/libtiledbsoma/test/unit_soma_dataframe.cc
+++ b/libtiledbsoma/test/unit_soma_dataframe.cc
@@ -79,6 +79,8 @@ struct VariouslyIndexedDataFrameFixture {
             {.name = i64_name,
              .tiledb_datatype = i64_datatype,
              .dim_max = i64_dim_max,
+             .string_lo = "N/A",
+             .string_hi = "N/A",
              .use_current_domain = use_current_domain});
     }
     helper::DimInfo u32_dim_info(bool use_current_domain) {
@@ -86,13 +88,18 @@ struct VariouslyIndexedDataFrameFixture {
             {.name = u32_name,
              .tiledb_datatype = u32_datatype,
              .dim_max = u32_dim_max,
+             .string_lo = "N/A",
+             .string_hi = "N/A",
              .use_current_domain = use_current_domain});
     }
-    helper::DimInfo str_dim_info(bool use_current_domain) {
+    helper::DimInfo str_dim_info(
+        bool use_current_domain, std::string string_lo, std::string string_hi) {
         return helper::DimInfo(
             {.name = str_name,
              .tiledb_datatype = str_datatype,
              .dim_max = str_dim_max,
+             .string_lo = string_lo,
+             .string_hi = string_hi,
              .use_current_domain = use_current_domain});
     }
 
@@ -548,18 +555,18 @@ TEST_CASE_METHOD(
 
         // Check domainish accessors before resize
         ArrowTable non_empty_domain = soma_dataframe->get_non_empty_domain();
-        std::vector<int64_t>
-            ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> ned_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 non_empty_domain, "soma_joinid");
 
         ArrowTable soma_domain = soma_dataframe->get_soma_domain();
-        std::vector<int64_t>
-            dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> dom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_domain, "soma_joinid");
 
         ArrowTable soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        std::vector<int64_t>
-            maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> maxdom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_maxdomain, "soma_joinid");
 
         REQUIRE(ned_sjid == std::vector<int64_t>({1, 2}));
@@ -634,16 +641,16 @@ TEST_CASE_METHOD(
         soma_dataframe->open(OpenMode::read);
 
         non_empty_domain = soma_dataframe->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             non_empty_domain, "soma_joinid");
 
         soma_domain = soma_dataframe->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             soma_domain, "soma_joinid");
 
         soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
-            soma_maxdomain, "soma_joinid");
+        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+            int64_t>(soma_maxdomain, "soma_joinid");
 
         if (!use_current_domain) {
             REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
@@ -727,27 +734,27 @@ TEST_CASE_METHOD(
         soma_dataframe->open(OpenMode::read);
 
         ArrowTable non_empty_domain = soma_dataframe->get_non_empty_domain();
-        std::vector<int64_t>
-            ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> ned_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 non_empty_domain, "soma_joinid");
-        std::vector<uint32_t>
-            ned_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
+        std::vector<uint32_t> ned_u32 =
+            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
                 non_empty_domain, "myuint32");
 
         ArrowTable soma_domain = soma_dataframe->get_soma_domain();
-        std::vector<int64_t>
-            dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> dom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_domain, "soma_joinid");
-        std::vector<uint32_t>
-            dom_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
+        std::vector<uint32_t> dom_u32 =
+            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
                 soma_domain, "myuint32");
 
         ArrowTable soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        std::vector<int64_t>
-            maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> maxdom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_maxdomain, "soma_joinid");
-        std::vector<uint32_t>
-            maxdom_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
+        std::vector<uint32_t> maxdom_u32 =
+            ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
                 soma_maxdomain, "myuint32");
 
         REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
@@ -828,22 +835,22 @@ TEST_CASE_METHOD(
         soma_dataframe->open(OpenMode::read);
 
         non_empty_domain = soma_dataframe->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             non_empty_domain, "soma_joinid");
-        ned_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
+        ned_u32 = ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
             non_empty_domain, "myuint32");
 
         soma_domain = soma_dataframe->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             soma_domain, "soma_joinid");
-        dom_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
+        dom_u32 = ArrowAdapter::get_table_non_string_column_by_name<uint32_t>(
             soma_domain, "myuint32");
 
         soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
-            soma_maxdomain, "soma_joinid");
-        maxdom_u32 = ArrowAdapter::get_table_column_by_name<uint32_t>(
-            soma_maxdomain, "myuint32");
+        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+            int64_t>(soma_maxdomain, "soma_joinid");
+        maxdom_u32 = ArrowAdapter::get_table_non_string_column_by_name<
+            uint32_t>(soma_maxdomain, "myuint32");
 
         if (!use_current_domain) {
             REQUIRE(ned_sjid == std::vector<int64_t>({1, 10}));
@@ -879,19 +886,26 @@ TEST_CASE_METHOD(
     VariouslyIndexedDataFrameFixture,
     "SOMADataFrame: variant-indexed dataframe dim-sjid-str attr-u32",
     "[SOMADataFrame]") {
-    // auto use_current_domain = GENERATE(false, true);
-    auto use_current_domain = GENERATE(false);
+    auto use_current_domain = GENERATE(false, true);
     std::ostringstream section;
     section << "- use_current_domain=" << use_current_domain;
     SECTION(section.str()) {
-        std::string suffix = use_current_domain ? "true" : "false";
+        auto specify_domain = GENERATE(false, true);
+        std::ostringstream section2;
+        section2 << "- specify_domain=" << specify_domain;
+
+        std::string suffix1 = use_current_domain ? "true" : "false";
+        std::string suffix2 = specify_domain ? "true" : "false";
         set_up(
             std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-3-" + suffix);
+            "mem://unit-test-variant-indexed-dataframe-3-" + suffix1 + "-" +
+                suffix2);
 
+        std::string string_lo = specify_domain ? "apple" : "";
+        std::string string_hi = specify_domain ? "zebra" : "";
         std::vector<helper::DimInfo> dim_infos(
             {i64_dim_info(use_current_domain),
-             str_dim_info(use_current_domain)});
+             str_dim_info(use_current_domain, string_lo, string_hi)});
         std::vector<helper::AttrInfo> attr_infos({u32_attr_info()});
 
         // Create
@@ -916,12 +930,17 @@ TEST_CASE_METHOD(
 
             std::array<std::string, 2> str_range = ndrect.range<std::string>(
                 dim_infos[1].name);
-            // Can we write empty strings in this range?
-            REQUIRE(str_range[0] <= "");
-            REQUIRE(str_range[1] >= "");
-            // Can we write ASCII values in this range?
-            REQUIRE(str_range[0] < " ");
-            REQUIRE(str_range[1] > "~");
+            if (specify_domain) {
+                REQUIRE(str_range[0] == dim_infos[1].string_lo);
+                REQUIRE(str_range[1] == dim_infos[1].string_hi);
+            } else {
+                // Can we write empty strings in this range?
+                REQUIRE(str_range[0] <= "");
+                REQUIRE(str_range[1] >= "");
+                // Can we write ASCII values in this range?
+                REQUIRE(str_range[0] < " ");
+                REQUIRE(str_range[1] > "~");
+            }
         }
 
         // Check shape before write
@@ -950,24 +969,24 @@ TEST_CASE_METHOD(
 
         // Check domainish accessors before resize
         ArrowTable non_empty_domain = soma_dataframe->get_non_empty_domain();
-        std::vector<int64_t>
-            ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> ned_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 non_empty_domain, "soma_joinid");
         std::vector<std::string>
             ned_str = ArrowAdapter::get_table_string_column_by_name(
                 non_empty_domain, "mystring");
 
         ArrowTable soma_domain = soma_dataframe->get_soma_domain();
-        std::vector<int64_t>
-            dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> dom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_domain, "soma_joinid");
         std::vector<std::string>
             dom_str = ArrowAdapter::get_table_string_column_by_name(
                 soma_domain, "mystring");
 
         ArrowTable soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        std::vector<int64_t>
-            maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        std::vector<int64_t> maxdom_sjid =
+            ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
                 soma_maxdomain, "soma_joinid");
         std::vector<std::string>
             maxdom_str = ArrowAdapter::get_table_string_column_by_name(
@@ -977,9 +996,21 @@ TEST_CASE_METHOD(
         REQUIRE(ned_str == std::vector<std::string>({"apple", "bat"}));
 
         REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
 
-        REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+        if (!use_current_domain) {
+            REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+        } else {
+            if (specify_domain) {
+                REQUIRE(dom_str[0] == dim_infos[1].string_lo);
+                REQUIRE(dom_str[1] == dim_infos[1].string_hi);
+            } else {
+                REQUIRE(dom_str[0] == "");
+                REQUIRE(dom_str[1] == "");
+            }
+
+            REQUIRE(maxdom_sjid[0] == 0);
+            REQUIRE(maxdom_sjid[1] > 2000000000);
+        }
         REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
 
         soma_dataframe->close();
@@ -1036,20 +1067,20 @@ TEST_CASE_METHOD(
         soma_dataframe->open(OpenMode::read, TimestampRange(0, 2));
 
         non_empty_domain = soma_dataframe->get_non_empty_domain();
-        ned_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        ned_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             non_empty_domain, "soma_joinid");
         ned_str = ArrowAdapter::get_table_string_column_by_name(
             non_empty_domain, "mystring");
 
         soma_domain = soma_dataframe->get_soma_domain();
-        dom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
+        dom_sjid = ArrowAdapter::get_table_non_string_column_by_name<int64_t>(
             soma_domain, "soma_joinid");
         dom_str = ArrowAdapter::get_table_string_column_by_name(
             soma_domain, "mystring");
 
         soma_maxdomain = soma_dataframe->get_soma_maxdomain();
-        maxdom_sjid = ArrowAdapter::get_table_column_by_name<int64_t>(
-            soma_maxdomain, "soma_joinid");
+        maxdom_sjid = ArrowAdapter::get_table_non_string_column_by_name<
+            int64_t>(soma_maxdomain, "soma_joinid");
         maxdom_str = ArrowAdapter::get_table_string_column_by_name(
             soma_maxdomain, "mystring");
 
@@ -1057,13 +1088,25 @@ TEST_CASE_METHOD(
         REQUIRE(ned_str == std::vector<std::string>({"", ""}));
 
         REQUIRE(dom_sjid == std::vector<int64_t>({0, 99}));
-        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
 
-        REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+        if (!use_current_domain) {
+            REQUIRE(maxdom_sjid == std::vector<int64_t>({0, 99}));
+            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+        } else {
+            if (specify_domain) {
+                REQUIRE(dom_str[0] == dim_infos[1].string_lo);
+                REQUIRE(dom_str[1] == dim_infos[1].string_hi);
+            } else {
+                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+            }
+
+            REQUIRE(maxdom_sjid[0] == 0);
+            REQUIRE(maxdom_sjid[1] > 2000000000);
+        }
+
         REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
 
         REQUIRE(ned_str == std::vector<std::string>({"", ""}));
-        REQUIRE(dom_str == std::vector<std::string>({"", ""}));
 
         soma_dataframe->close();
     }
@@ -1077,13 +1120,21 @@ TEST_CASE_METHOD(
     std::ostringstream section;
     section << "- use_current_domain=" << use_current_domain;
     SECTION(section.str()) {
-        std::string suffix = use_current_domain ? "true" : "false";
+        auto specify_domain = GENERATE(false, true);
+        std::ostringstream section2;
+        section2 << "- specify_domain=" << specify_domain;
+
+        std::string suffix1 = use_current_domain ? "true" : "false";
+        std::string suffix2 = specify_domain ? "true" : "false";
         set_up(
             std::make_shared<SOMAContext>(),
-            "mem://unit-test-variant-indexed-dataframe-4-" + suffix);
+            "mem://unit-test-variant-indexed-dataframe-4-" + suffix1 + "-" +
+                suffix2);
 
+        std::string string_lo = specify_domain ? "apple" : "";
+        std::string string_hi = specify_domain ? "zebra" : "";
         std::vector<helper::DimInfo> dim_infos(
-            {str_dim_info(use_current_domain),
+            {str_dim_info(use_current_domain, string_lo, string_hi),
              u32_dim_info(use_current_domain)});
         std::vector<helper::AttrInfo> attr_infos({i64_attr_info()});
 
@@ -1104,12 +1155,17 @@ TEST_CASE_METHOD(
 
             std::array<std::string, 2> str_range = ndrect.range<std::string>(
                 dim_infos[0].name);
-            // Can we write empty strings in this range?
-            REQUIRE(str_range[0] <= "");
-            REQUIRE(str_range[1] >= "");
-            // Can we write ASCII values in this range?
-            REQUIRE(str_range[0] < " ");
-            REQUIRE(str_range[1] > "~");
+            if (specify_domain) {
+                REQUIRE(str_range[0] == dim_infos[0].string_lo);
+                REQUIRE(str_range[1] == dim_infos[0].string_hi);
+            } else {
+                // Can we write empty strings in this range?
+                REQUIRE(str_range[0] <= "");
+                REQUIRE(str_range[1] >= "");
+                // Can we write ASCII values in this range?
+                REQUIRE(str_range[0] < " ");
+                REQUIRE(str_range[1] > "~");
+            }
 
             std::array<uint32_t, 2> u32_range = ndrect.range<uint32_t>(
                 dim_infos[1].name);
@@ -1144,7 +1200,12 @@ TEST_CASE_METHOD(
             REQUIRE(dom_str == std::vector<std::string>({"", ""}));
             REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
         } else {
-            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+            if (specify_domain) {
+                REQUIRE(dom_str[0] == dim_infos[0].string_lo);
+                REQUIRE(dom_str[1] == dim_infos[0].string_hi);
+            } else {
+                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+            }
             REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
         }
 
@@ -1229,7 +1290,12 @@ TEST_CASE_METHOD(
             REQUIRE(dom_str == std::vector<std::string>({"", ""}));
             REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
         } else {
-            REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+            if (specify_domain) {
+                REQUIRE(dom_str[0] == dim_infos[0].string_lo);
+                REQUIRE(dom_str[1] == dim_infos[0].string_hi);
+            } else {
+                REQUIRE(dom_str == std::vector<std::string>({"", ""}));
+            }
             REQUIRE(maxdom_str == std::vector<std::string>({"", ""}));
         }
 
diff --git a/libtiledbsoma/test/unit_soma_dense_ndarray.cc b/libtiledbsoma/test/unit_soma_dense_ndarray.cc
index 3f22ea7727..d9aaca6790 100644
--- a/libtiledbsoma/test/unit_soma_dense_ndarray.cc
+++ b/libtiledbsoma/test/unit_soma_dense_ndarray.cc
@@ -62,6 +62,8 @@ TEST_CASE("SOMADenseNDArray: basic", "[SOMADenseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = dim_tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
@@ -174,6 +176,8 @@ TEST_CASE("SOMADenseNDArray: platform_config", "[SOMADenseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
@@ -236,6 +240,8 @@ TEST_CASE("SOMADenseNDArray: metadata", "[SOMADenseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
diff --git a/libtiledbsoma/test/unit_soma_sparse_ndarray.cc b/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
index c73ddef119..bb6b2decc4 100644
--- a/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
+++ b/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
@@ -39,8 +39,7 @@ TEST_CASE("SOMASparseNDArray: basic", "[SOMASparseNDArray]") {
     int64_t dim_max = 999;
     int64_t shape = 1000;
 
-    // auto use_current_domain = GENERATE(false, true);
-    auto use_current_domain = GENERATE(true);
+    auto use_current_domain = GENERATE(false, true);
     // TODO this could be formatted with fmt::format which is part of internal
     // header spd/log/fmt/fmt.h and should not be used. In C++20, this can be
     // replaced with std::format.
@@ -64,6 +63,8 @@ TEST_CASE("SOMASparseNDArray: basic", "[SOMASparseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = dim_tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
@@ -148,6 +149,13 @@ TEST_CASE("SOMASparseNDArray: basic", "[SOMASparseNDArray]") {
             REQUIRE_THROWS(soma_sparse->resize(new_shape));
             // Now set the shape
             soma_sparse->upgrade_shape(new_shape);
+            soma_sparse->close();
+
+            soma_sparse->open(OpenMode::read);
+            REQUIRE(soma_sparse->has_current_domain());
+            soma_sparse->close();
+
+            soma_sparse->open(OpenMode::write);
             REQUIRE(soma_sparse->has_current_domain());
             // Should not fail since we're setting it to what it already is.
             soma_sparse->resize(new_shape);
@@ -208,6 +216,8 @@ TEST_CASE("SOMASparseNDArray: platform_config", "[SOMASparseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = dim_tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);
@@ -258,6 +268,8 @@ TEST_CASE("SOMASparseNDArray: metadata", "[SOMASparseNDArray]") {
             {{.name = dim_name,
               .tiledb_datatype = dim_tiledb_datatype,
               .dim_max = dim_max,
+              .string_lo = "N/A",
+              .string_hi = "N/A",
               .use_current_domain = use_current_domain}});
 
         auto index_columns = helper::create_column_index_info(dim_infos);