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

Issue:
[c++] Performant `DataFrame.shape`
**Issue and/or context:** This follows on #2944 for issue #2407 / [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048). Here we implement `shape` for SOMA `DataFrame`, including its variant-indexed flavors.

**Changes:**

While `SOMASparseNDArray` and `SOMADenseNDArray` must always and only have `int64` dims within the [SOMA data model](https://github.com/single-cell-data/SOMA/blob/main/abstract_specification.md), `SOMADataFrame` is different. The default behavior -- which almost everyone uses -- has a single `soma_joinid` dim which is indeed of type `int64`. (Also note that `exp.obs.shape` _does_ exist within TileDB-SOMA-Py, and people _do_ call it.) However, the spec only requires that `soma_joinid` exist as a dim or an attr: it can be a dim along with others, or it can not be a dim at all. Here we do the right thing, without and with current-domain support, to produce the user-expected `exp.obs.shape`.

**Notes for Reviewer:**
[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++] Performant `DataFrame.shape`
**Issue and/or context:** This follows on #2944 for issue #2407 / [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048). Here we implement `shape` for SOMA `DataFrame`, including its variant-indexed flavors.

**Changes:**

While `SOMASparseNDArray` and `SOMADenseNDArray` must always and only have `int64` dims within the [SOMA data model](https://github.com/single-cell-data/SOMA/blob/main/abstract_specification.md), `SOMADataFrame` is different. The default behavior -- which almost everyone uses -- has a single `soma_joinid` dim which is indeed of type `int64`. (Also note that `exp.obs.shape` _does_ exist within TileDB-SOMA-Py, and people _do_ call it.) However, the spec only requires that `soma_joinid` exist as a dim or an attr: it can be a dim along with others, or it can not be a dim at all. Here we do the right thing, without and with current-domain support, to produce the user-expected `exp.obs.shape`.

**Notes for Reviewer:**
[c++] Resize for variant-indexed `DataFrame`
**Issue and/or context:** This follows on #2917 for issue #2407 / [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048). Here we implement `resize`.

Note that the intended Python and R API changes are all agreed on and finalized as described in #2407.

**Changes:**

While `SOMASparseNDArray` and `SOMADenseNDArray` must always and only have `int64` dims within the [SOMA data model](https://github.com/single-cell-data/SOMA/blob/main/abstract_specification.md), `SOMADataFrame` is different. The default behavior -- which almost everyone uses -- has a single `soma_joinid` dim which is indeed of type `int64`. (Also note that `exp.obs.shape` _does_ exist within TileDB-SOMA-Py, and people _do_ call it.) However, the spec only requires that `soma_joinid` exist as a dim or an attr: it can be a dim along with others, or it can not be a dim at all. Here we do the right thing, without and with current-domain support, to allow people to resize the `soma_joinid` dim, and only that, whether it's the sole dimension, a dimension but not the sole one, or not a dim at all.

**Notes for Reviewer:**



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.



Fix patch:
diff --git a/libtiledbsoma/src/utils/arrow_adapter.cc b/libtiledbsoma/src/utils/arrow_adapter.cc
index 6a03aa5b2f..5a450e3e26 100644
--- a/libtiledbsoma/src/utils/arrow_adapter.cc
+++ b/libtiledbsoma/src/utils/arrow_adapter.cc
@@ -32,7 +32,7 @@
 
 #include "arrow_adapter.h"
 #include "../soma/column_buffer.h"
-#include "../utils/logger.h"
+#include "logger.h"
 
 namespace tiledbsoma {
 
@@ -417,29 +417,122 @@ Dimension ArrowAdapter::_create_dim(
         case TILEDB_DATETIME_SEC:
         case TILEDB_DATETIME_MS:
         case TILEDB_DATETIME_US:
-        case TILEDB_DATETIME_NS:
-            return Dimension::create(
-                *ctx, name, type, (uint64_t*)buff, (uint64_t*)buff + 2);
-        case TILEDB_INT8:
+        case TILEDB_DATETIME_NS: {
+            // Sadly we cannot put this in the centralized _create_dim_aux
+            // in the header file. That's because we need utils/logger.h
+            // -- which is a _fixed_ relative path from _this_ .cc file
+            // but a _varying_ relative path from all the places that
+            // #include arrow_adapter.h. Hence the code duplication in
+            // logging statements. :(
+            uint64_t* b = (uint64_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
+            return Dimension::create(*ctx, name, type, b, b + 2);
+        }
+        case TILEDB_INT8: {
+            int8_t* b = (int8_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (int8_t*)buff);
-        case TILEDB_UINT8:
+        }
+        case TILEDB_UINT8: {
+            uint8_t* b = (uint8_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (uint8_t*)buff);
-        case TILEDB_INT16:
+        }
+        case TILEDB_INT16: {
+            int16_t* b = (int16_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (int16_t*)buff);
-        case TILEDB_UINT16:
+        }
+        case TILEDB_UINT16: {
+            uint16_t* b = (uint16_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (uint16_t*)buff);
-        case TILEDB_INT32:
+        }
+        case TILEDB_INT32: {
+            int32_t* b = (int32_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (int32_t*)buff);
-        case TILEDB_UINT32:
+        }
+        case TILEDB_UINT32: {
+            uint32_t* b = (uint32_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (uint32_t*)buff);
-        case TILEDB_INT64:
+        }
+        case TILEDB_INT64: {
+            int64_t* b = (int64_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (int64_t*)buff);
-        case TILEDB_UINT64:
+        }
+        case TILEDB_UINT64: {
+            uint64_t* b = (uint64_t*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (uint64_t*)buff);
-        case TILEDB_FLOAT32:
+        }
+        case TILEDB_FLOAT32: {
+            float* b = (float*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (float*)buff);
-        case TILEDB_FLOAT64:
+        }
+        case TILEDB_FLOAT64: {
+            double* b = (double*)buff;
+            LOG_DEBUG(fmt::format(
+                "_create_dim name={} b={} b1={} b2={}",
+                name,
+                b[0],
+                b[1],
+                b[2]));
             return ArrowAdapter::_create_dim_aux(ctx, name, (double*)buff);
+        }
         default:
             throw TileDBSOMAError(fmt::format(
                 "ArrowAdapter: Unsupported TileDB dimension: {} ",


Test patch:
diff --git a/libtiledbsoma/test/common.cc b/libtiledbsoma/test/common.cc
index d82d880c4a..f8a3b5cdaa 100644
--- a/libtiledbsoma/test/common.cc
+++ b/libtiledbsoma/test/common.cc
@@ -31,14 +31,10 @@
  */
 
 #include "common.h"
+#include "utils/logger.h"
 
 namespace helper {
 
-static std::unique_ptr<ArrowArray> _create_index_cols_info_array(
-    int64_t dim_max, bool use_current_domain);
-static std::unique_ptr<ArrowSchema> _create_index_cols_info_schema(
-    std::string dim_name);
-
 // This non-obvious number is:
 // * Something that fits into signed 32-bit integer for R-friendliness;
 // * Is a comfortable tile-extent distance away from 2^31-1 for default
@@ -46,6 +42,13 @@ static std::unique_ptr<ArrowSchema> _create_index_cols_info_schema(
 //   array-creation error.)
 const int CORE_DOMAIN_MAX = 2147483646;
 
+static std::unique_ptr<ArrowSchema> _create_index_cols_info_schema(
+    const std::vector<DimInfo>& dim_infos);
+static std::unique_ptr<ArrowArray> _create_index_cols_info_array(
+    const std::vector<DimInfo>& dim_infos);
+
+static std::string tdb_to_arrow_type(tiledb_datatype_t tiledb_dtype);
+
 // Notes:
 //
 // * This is multi-purpose code used for generic SOMASparseNDArray,
@@ -80,35 +83,47 @@ const int CORE_DOMAIN_MAX = 2147483646;
 // * The Python and R bindings prepare similar Arrow information when
 //   passing a create-array request to libtiledbsoma.
 
+// Create ArrowSchema for the entire SOMAArray -- dims and attrs both -- as well
+// as index-column info
 std::pair<std::unique_ptr<ArrowSchema>, ArrowTable>
 create_arrow_schema_and_index_columns(
-    int64_t dim_max, bool use_current_domain) {
-    // Create ArrowSchema for the entire SOMAArray: dims and attrs both
+    const std::vector<DimInfo>& dim_infos,
+    const std::vector<AttrInfo>& attr_infos) {
+    int ndim = dim_infos.size();
+    int nattr = attr_infos.size();
+
     auto arrow_schema = std::make_unique<ArrowSchema>();
     arrow_schema->format = "+s";
-    arrow_schema->n_children = 2;  // non-leaf node
+    arrow_schema->n_children = ndim + nattr;  // non-leaf node
     arrow_schema->dictionary = nullptr;
     arrow_schema->release = &ArrowAdapter::release_schema;
     arrow_schema->children = new ArrowSchema*[arrow_schema->n_children];
 
-    ArrowSchema* dim = arrow_schema->children[0] = new ArrowSchema;
-    dim->format = "l";
-    dim->name = "d0";
-    dim->n_children = 0;  // leaf node
-    dim->dictionary = nullptr;
-    dim->release = &ArrowAdapter::release_schema;
-
-    ArrowSchema* attr = arrow_schema->children[1] = new ArrowSchema;
-    attr->format = "l";
-    attr->name = "a0";
-    attr->n_children = 0;  // leaf node
-    attr->flags = 0;
-    attr->dictionary = nullptr;
-    attr->release = &ArrowAdapter::release_schema;
-
-    auto index_cols_info_schema = _create_index_cols_info_schema("d0");
-    auto index_cols_info_array = _create_index_cols_info_array(
-        dim_max, use_current_domain);
+    for (int i = 0; i < ndim; i++) {
+        const DimInfo& info = dim_infos[i];
+        ArrowSchema* dim = new ArrowSchema;
+        dim->name = strdup(info.name.c_str());
+        dim->format = strdup(tdb_to_arrow_type(info.tiledb_datatype).c_str());
+        dim->n_children = 0;  // leaf node
+        dim->dictionary = nullptr;
+        dim->release = &ArrowAdapter::release_schema;
+        arrow_schema->children[i] = dim;
+    }
+
+    for (int i = 0; i < nattr; i++) {
+        const AttrInfo& info = attr_infos[i];
+        ArrowSchema* attr = new ArrowSchema;
+        attr->name = strdup(info.name.c_str());
+        attr->format = strdup(tdb_to_arrow_type(info.tiledb_datatype).c_str());
+        attr->n_children = 0;  // leaf node
+        attr->flags = 0;
+        attr->dictionary = nullptr;
+        attr->release = &ArrowAdapter::release_schema;
+        arrow_schema->children[ndim + i] = attr;
+    }
+
+    auto index_cols_info_schema = _create_index_cols_info_schema(dim_infos);
+    auto index_cols_info_array = _create_index_cols_info_array(dim_infos);
 
     return std::pair(
         std::move(arrow_schema),
@@ -117,71 +132,139 @@ create_arrow_schema_and_index_columns(
             std::move(index_cols_info_schema)));
 }
 
-ArrowTable create_column_index_info(int64_t dim_max, bool use_current_domain) {
-    auto index_cols_info_schema = _create_index_cols_info_schema("soma_dim_0");
-    auto index_cols_info_array = _create_index_cols_info_array(
-        dim_max, use_current_domain);
+// Create index-column info only, no schema involving the attrs
+ArrowTable create_column_index_info(const std::vector<DimInfo>& dim_infos) {
+    for (auto info : dim_infos) {
+        LOG_DEBUG(fmt::format(
+            "create_column_index_info name={} type={} dim_max={} ucd={}",
+            info.name,
+            info.tiledb_datatype,
+            info.dim_max,
+            info.use_current_domain));
+    }
+
+    auto index_cols_info_schema = _create_index_cols_info_schema(dim_infos);
+    auto index_cols_info_array = _create_index_cols_info_array(dim_infos);
 
     return ArrowTable(
         std::move(index_cols_info_array), std::move(index_cols_info_schema));
 }
 
 static std::unique_ptr<ArrowSchema> _create_index_cols_info_schema(
-    std::string dim_name) {
+    const std::vector<DimInfo>& dim_infos) {
+    int ndim = dim_infos.size();
+
     auto index_cols_info_schema = std::make_unique<ArrowSchema>();
     index_cols_info_schema->format = "+s";
-    index_cols_info_schema->n_children = 1;  // non-leaf node
+    index_cols_info_schema->n_children = ndim;  // non-leaf node
     index_cols_info_schema->dictionary = nullptr;
     index_cols_info_schema->release = &ArrowAdapter::release_schema;
     index_cols_info_schema
         ->children = new ArrowSchema*[index_cols_info_schema->n_children];
 
-    ArrowSchema* dim = index_cols_info_schema->children[0] = new ArrowSchema;
-    dim->format = "l";
-    dim->name = strdup(dim_name.c_str());
-    dim->n_children = 0;  // leaf node
-    dim->dictionary = nullptr;
-    dim->release = &ArrowAdapter::release_schema;
+    for (int i = 0; i < ndim; i++) {
+        const DimInfo& info = dim_infos[i];
+        ArrowSchema* dim_schema = new ArrowSchema;
+        dim_schema->format = strdup(
+            tdb_to_arrow_type(info.tiledb_datatype).c_str());
+        dim_schema->name = strdup(info.name.c_str());
+        dim_schema->n_children = 0;  // leaf node
+        dim_schema->dictionary = nullptr;
+        dim_schema->release = &ArrowAdapter::release_schema;
+        index_cols_info_schema->children[i] = dim_schema;
+    }
 
     return index_cols_info_schema;
 }
 
 static std::unique_ptr<ArrowArray> _create_index_cols_info_array(
-    int64_t dim_max, bool use_current_domain) {
+    const std::vector<DimInfo>& dim_infos) {
+    int ndim = dim_infos.size();
+
     auto index_cols_info_array = std::make_unique<ArrowArray>();
     index_cols_info_array->length = 0;
     index_cols_info_array->null_count = 0;
     index_cols_info_array->offset = 0;
     index_cols_info_array->n_buffers = 0;
     index_cols_info_array->buffers = nullptr;
-    index_cols_info_array->n_children = 1;
+    index_cols_info_array->n_children = ndim;
     index_cols_info_array->release = &ArrowAdapter::release_array;
-    index_cols_info_array->children = new ArrowArray*[1];
-
-    int n = use_current_domain ? 5 : 3;
-
-    auto d0_info = index_cols_info_array->children[0] = new ArrowArray;
-    d0_info->length = n;
-    d0_info->null_count = 0;
-    d0_info->offset = 0;
-    d0_info->n_buffers = 2;
-    d0_info->release = &ArrowAdapter::release_array;
-    d0_info->buffers = new const void*[2];
-    d0_info->buffers[0] = nullptr;
-    d0_info->buffers[1] = malloc(sizeof(int64_t) * n);
-    d0_info->n_children = 0;
-
-    if (use_current_domain) {
-        // domain big; current_domain small
-        int64_t dom[] = {0, CORE_DOMAIN_MAX, 1, 0, dim_max};
-        std::memcpy((void*)d0_info->buffers[1], &dom, sizeof(int64_t) * n);
-    } else {
-        // domain small; current_domain feature not being used
-        int64_t dom[] = {0, dim_max, 1};
-        std::memcpy((void*)d0_info->buffers[1], &dom, sizeof(int64_t) * n);
+    index_cols_info_array->children = new ArrowArray*[ndim];
+
+    for (int i = 0; i < ndim; i++) {
+        const DimInfo& info = dim_infos[i];
+
+        int n = info.use_current_domain ? 5 : 3;
+
+        auto dim_array = new ArrowArray;
+        dim_array->length = n;
+        dim_array->null_count = 0;
+        dim_array->offset = 0;
+        dim_array->n_buffers = 2;
+        dim_array->release = &ArrowAdapter::release_array;
+        dim_array->buffers = new const void*[2];
+        dim_array->buffers[0] = nullptr;
+        dim_array->n_children = 0;  // leaf node
+        index_cols_info_array->children[i] = dim_array;
+
+        // The full user-level SOMA API supports many more index types.
+        // Here we support enough types to verify we've got variant-indexed
+        // SOMADataFrame objects baseline-tested in C++, then defer exhaustive
+        // loop-over-all-datatypes handling to Python and R.
+        if (info.tiledb_datatype == TILEDB_INT64) {
+            size_t nbytes = n * sizeof(int64_t);
+            dim_array->buffers[1] = malloc(nbytes);
+            if (info.use_current_domain) {
+                // domain big; current_domain small
+                int64_t dom[] = {0, CORE_DOMAIN_MAX, 1, 0, info.dim_max};
+                void* vsrc = (void*)&dom[0];
+                std::memcpy((void*)dim_array->buffers[1], vsrc, nbytes);
+            } else {
+                // domain small; current_domain feature not being used
+                int64_t dom[] = {0, info.dim_max, 1};
+                void* vsrc = (void*)&dom[0];
+                std::memcpy((void*)dim_array->buffers[1], vsrc, nbytes);
+            }
+
+        } else if (info.tiledb_datatype == TILEDB_UINT32) {
+            size_t nbytes = n * sizeof(uint32_t);
+            dim_array->buffers[1] = malloc(nbytes);
+            if (info.use_current_domain) {
+                // domain big; current_domain small
+                uint32_t dom[] = {
+                    0, (uint32_t)CORE_DOMAIN_MAX, 1, 0, (uint32_t)info.dim_max};
+                void* vsrc = (void*)&dom[0];
+                std::memcpy((void*)dim_array->buffers[1], vsrc, nbytes);
+            } else {
+                // domain small; current_domain feature not being used
+                int64_t dom[] = {0, info.dim_max, 1};
+                void* vsrc = (void*)&dom[0];
+                std::memcpy((void*)dim_array->buffers[1], vsrc, nbytes);
+            }
+
+        } else if (info.tiledb_datatype == TILEDB_STRING_ASCII) {
+            // Domain specification for strings is not supported in core. See
+            // arrow_adapter for more info. We rely on arrow_adapter to also
+            // handle this case.
+            dim_array->buffers[1] = nullptr;
+
+        } else {
+            throw TileDBSOMAError(
+                "Unsupported datatype encountered in unit test. You can add a "
+                "new type if you like!");
+        }
     }
 
     return index_cols_info_array;
 }
 
+// Just a keystroke-saver
+static std::string tdb_to_arrow_type(tiledb_datatype_t tiledb_datatype) {
+    return std::string(ArrowAdapter::to_arrow_format(tiledb_datatype));
+}
+
+std::string to_arrow_format(tiledb_datatype_t tiledb_datatype) {
+    return std::string(ArrowAdapter::to_arrow_format(tiledb_datatype));
+}
+
 }  // namespace helper
diff --git a/libtiledbsoma/test/common.h b/libtiledbsoma/test/common.h
index da238e6ad6..e4ad5f8476 100644
--- a/libtiledbsoma/test/common.h
+++ b/libtiledbsoma/test/common.h
@@ -60,8 +60,30 @@ using namespace Catch::Matchers;
 static const std::string src_path = TILEDBSOMA_SOURCE_ROOT;
 
 namespace helper {
+
+// E.g. "d0" is of type TILEDB_INT64 with dim_max 1000 and current-domain
+// feature enabled
+struct DimInfo {
+    std::string name;
+    tiledb_datatype_t tiledb_datatype;
+    int64_t dim_max;
+    bool use_current_domain;
+};
+
+// E.g. "a0" is of type TILEDB_FLOAT64
+struct AttrInfo {
+    std::string name;
+    tiledb_datatype_t tiledb_datatype;
+};
+
 std::pair<std::unique_ptr<ArrowSchema>, ArrowTable>
-create_arrow_schema_and_index_columns(int64_t dim_max, bool use_current_domain);
-ArrowTable create_column_index_info(int64_t dim_max, bool use_current_domain);
+create_arrow_schema_and_index_columns(
+    const std::vector<DimInfo>& dim_infos,
+    const std::vector<AttrInfo>& attr_infos);
+
+ArrowTable create_column_index_info(const std::vector<DimInfo>& dim_infos);
+
+std::string to_arrow_format(tiledb_datatype_t tiledb_datatype);
+
 }  // namespace helper
 #endif
diff --git a/libtiledbsoma/test/unit_column_buffer.cc b/libtiledbsoma/test/unit_column_buffer.cc
index 79b04308dc..1ff087d542 100644
--- a/libtiledbsoma/test/unit_column_buffer.cc
+++ b/libtiledbsoma/test/unit_column_buffer.cc
@@ -98,4 +98,4 @@ TEST_CASE("ColumnBuffer: Create from array") {
         REQUIRE(buffers->is_var() == true);
         REQUIRE(buffers->is_nullable() == true);
     }
-}
\ No newline at end of file
+}
diff --git a/libtiledbsoma/test/unit_managed_query.cc b/libtiledbsoma/test/unit_managed_query.cc
index ee708533c5..1413450245 100644
--- a/libtiledbsoma/test/unit_managed_query.cc
+++ b/libtiledbsoma/test/unit_managed_query.cc
@@ -65,17 +65,20 @@ auto create_array(const std::string& uri, Context& ctx) {
         vfs.remove_dir(uri);
     }
 
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+
     // Create schema
     ArraySchema schema(ctx, TILEDB_SPARSE);
     auto dim = Dimension::create(
-        ctx, "d0", TILEDB_STRING_ASCII, nullptr, nullptr);
+        ctx, dim_name, TILEDB_STRING_ASCII, nullptr, nullptr);
     dim.set_cell_val_num(TILEDB_VAR_NUM);
 
     Domain domain(ctx);
     domain.add_dimension(dim);
     schema.set_domain(domain);
 
-    auto attr = Attribute::create<std::string>(ctx, "a0");
+    auto attr = Attribute::create<std::string>(ctx, attr_name);
     attr.set_nullable(true);
     schema.add_attribute(attr);
     schema.check();
@@ -97,11 +100,11 @@ auto create_array(const std::string& uri, Context& ctx) {
     // Write data to array and close the array
     Query query(ctx, array);
     query.set_layout(TILEDB_UNORDERED)
-        .set_data_buffer("d0", d0_data)
-        .set_offsets_buffer("d0", d0_offsets)
-        .set_data_buffer("a0", a0_data)
-        .set_offsets_buffer("a0", a0_offsets)
-        .set_validity_buffer("a0", a0_valids);
+        .set_data_buffer(dim_name, d0_data)
+        .set_offsets_buffer(dim_name, d0_offsets)
+        .set_data_buffer(attr_name, a0_data)
+        .set_offsets_buffer(attr_name, a0_offsets)
+        .set_validity_buffer(attr_name, a0_valids);
     query.submit();
     array.close();
 
@@ -118,6 +121,9 @@ TEST_CASE("ManagedQuery: Basic execution test") {
     }
 
     std::string uri = "mem://unit-test-array";
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+
     auto ctx = std::make_shared<Context>();
     auto [array, d0, a0, _] = create_array(uri, *ctx);
 
@@ -131,18 +137,21 @@ TEST_CASE("ManagedQuery: Basic execution test") {
     auto num_cells = mq.total_num_cells();
     REQUIRE(num_cells == d0.size());
 
-    REQUIRE_THAT(d0, Equals(mq.strings("d0")));
-    REQUIRE_THAT(a0, Equals(mq.strings("a0")));
+    REQUIRE_THAT(d0, Equals(mq.strings(dim_name)));
+    REQUIRE_THAT(a0, Equals(mq.strings(attr_name)));
 }
 
 TEST_CASE("ManagedQuery: Select test") {
     std::string uri = "mem://unit-test-array";
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+
     auto ctx = std::make_shared<Context>();
     auto [array, d0, a0, _] = create_array(uri, *ctx);
 
     auto mq = ManagedQuery(array, ctx);
-    mq.select_columns({"a0"});
-    mq.select_points<std::string>("d0", {"a"});
+    mq.select_columns({attr_name});
+    mq.select_points<std::string>(dim_name, {"a"});
     mq.setup_read();
 
     mq.submit_read();
@@ -153,15 +162,18 @@ TEST_CASE("ManagedQuery: Select test") {
     REQUIRE(num_cells == 1);
 
     REQUIRE_THROWS(mq.data<int>("a1"));
-    REQUIRE_THROWS(mq.strings("d0"));
+    REQUIRE_THROWS(mq.strings(dim_name));
     REQUIRE_THROWS(mq.string_view("d1", 0));
 
     REQUIRE_THAT(
-        std::string(a0[0]), Equals(std::string(mq.string_view("a0", 0))));
+        std::string(a0[0]), Equals(std::string(mq.string_view(attr_name, 0))));
 }
 
 TEST_CASE("ManagedQuery: Validity test") {
     std::string uri = "mem://unit-test-array";
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+
     auto ctx = std::make_shared<Context>();
     auto [array, d0, a0, a0_valids] = create_array(uri, *ctx);
 
@@ -176,11 +188,11 @@ TEST_CASE("ManagedQuery: Validity test") {
     REQUIRE(num_cells == d0.size());
 
     // Convert span to vector
-    auto valids = mq.validity("a0");
+    auto valids = mq.validity(attr_name);
     std::vector<uint8_t> a0_valids_actual;
     a0_valids_actual.assign(valids.begin(), valids.end());
 
-    REQUIRE_THAT(d0, Equals(mq.strings("d0")));
-    REQUIRE_THAT(a0, Equals(mq.strings("a0")));
+    REQUIRE_THAT(d0, Equals(mq.strings(dim_name)));
+    REQUIRE_THAT(a0, Equals(mq.strings(attr_name)));
     REQUIRE_THAT(a0_valids, Equals(a0_valids_actual));
 }
diff --git a/libtiledbsoma/test/unit_soma_array.cc b/libtiledbsoma/test/unit_soma_array.cc
index a1c000c415..29ee295596 100644
--- a/libtiledbsoma/test/unit_soma_array.cc
+++ b/libtiledbsoma/test/unit_soma_array.cc
@@ -44,6 +44,7 @@
 
 #include <tiledb/tiledb>
 #include <tiledbsoma/tiledbsoma>
+#include "common.h"
 #include "utils/util.h"
 
 using namespace tiledb;
@@ -54,8 +55,6 @@ using namespace Catch::Matchers;
 #define TILEDBSOMA_SOURCE_ROOT "not_defined"
 #endif
 
-const std::string src_path = TILEDBSOMA_SOURCE_ROOT;
-
 namespace {
 
 std::tuple<std::string, uint64_t> create_array(
@@ -70,17 +69,22 @@ std::tuple<std::string, uint64_t> create_array(
         vfs.remove_dir(uri);
     }
 
+    const char* dim_name = "d0";
+    const char* attr_name = "a0";
+
     // Create schema
     ArraySchema schema(*ctx->tiledb_ctx(), TILEDB_SPARSE);
 
     auto dim = Dimension::create<int64_t>(
-        *ctx->tiledb_ctx(), "d0", {0, std::numeric_limits<int64_t>::max() - 1});
+        *ctx->tiledb_ctx(),
+        dim_name,
+        {0, std::numeric_limits<int64_t>::max() - 1});
 
     Domain domain(*ctx->tiledb_ctx());
     domain.add_dimension(dim);
     schema.set_domain(domain);
 
-    auto attr = Attribute::create<int>(*ctx->tiledb_ctx(), "a0");
+    auto attr = Attribute::create<int>(*ctx->tiledb_ctx(), attr_name);
     schema.add_attribute(attr);
     schema.set_allows_dups(allow_duplicates);
     schema.check();
@@ -115,6 +119,9 @@ std::tuple<std::vector<int64_t>, std::vector<int>> write_array(
     std::iota(frags.begin(), frags.end(), 0);
     std::shuffle(frags.begin(), frags.end(), std::random_device{});
 
+    const char* dim_name = "d0";
+    const char* attr_name = "a0";
+
     // Write to SOMAArray
     for (auto i = 0; i < num_fragments; ++i) {
         auto frag_num = frags[i];
@@ -140,8 +147,8 @@ std::tuple<std::vector<int64_t>, std::vector<int>> write_array(
         std::vector<int> a0(num_cells_per_fragment, frag_num);
 
         // Write data to array
-        soma_array->set_column_data("a0", a0.size(), a0.data());
-        soma_array->set_column_data("d0", d0.size(), d0.data());
+        soma_array->set_column_data(attr_name, a0.size(), a0.data());
+        soma_array->set_column_data(dim_name, d0.size(), d0.data());
         soma_array->write();
         soma_array->close();
     }
@@ -159,14 +166,14 @@ std::tuple<std::vector<int64_t>, std::vector<int>> write_array(
 
     Query query(*ctx->tiledb_ctx(), tiledb_array);
     query.set_layout(TILEDB_UNORDERED)
-        .set_data_buffer("d0", expected_d0)
-        .set_data_buffer("a0", expected_a0);
+        .set_data_buffer(dim_name, expected_d0)
+        .set_data_buffer(attr_name, expected_a0);
     query.submit();
 
     tiledb_array.close();
 
-    expected_d0.resize(query.result_buffer_elements()["d0"].second);
-    expected_a0.resize(query.result_buffer_elements()["a0"].second);
+    expected_d0.resize(query.result_buffer_elements()[dim_name].second);
+    expected_a0.resize(query.result_buffer_elements()[attr_name].second);
 
     return {expected_d0, expected_a0};
 }
@@ -180,6 +187,9 @@ TEST_CASE("SOMAArray: nnz") {
     int num_cells_per_fragment = 128;
     auto timestamp = 10;
 
+    const char* dim_name = "d0";
+    const char* attr_name = "a0";
+
     // TODO this use to 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.
@@ -230,11 +240,13 @@ TEST_CASE("SOMAArray: nnz") {
         // Check that data from SOMAArray::read_next matches expected data
         while (auto batch = soma_array->read_next()) {
             auto arrbuf = batch.value();
-            REQUIRE(arrbuf->names() == std::vector<std::string>({"d0", "a0"}));
+            REQUIRE(
+                arrbuf->names() ==
+                std::vector<std::string>({dim_name, attr_name}));
             REQUIRE(arrbuf->num_rows() == nnz);
 
-            auto d0span = arrbuf->at("d0")->data<int64_t>();
-            auto a0span = arrbuf->at("a0")->data<int>();
+            auto d0span = arrbuf->at(dim_name)->data<int64_t>();
+            auto a0span = arrbuf->at(attr_name)->data<int>();
 
             std::vector<int64_t> d0col(d0span.begin(), d0span.end());
             std::vector<int> a0col(a0span.begin(), a0span.end());
@@ -495,12 +507,15 @@ TEST_CASE("SOMAArray: Write and read back Boolean") {
 
     auto ctx = std::make_shared<SOMAContext>();
 
+    const char* dim_name = "d0";
+    const char* attr_name = "a0";
+
     ArraySchema schema(*ctx->tiledb_ctx(), TILEDB_SPARSE);
-    auto dim = Dimension::create<int64_t>(*ctx->tiledb_ctx(), "d0", {0, 7});
+    auto dim = Dimension::create<int64_t>(*ctx->tiledb_ctx(), dim_name, {0, 7});
     Domain domain(*ctx->tiledb_ctx());
     domain.add_dimension(dim);
     schema.set_domain(domain);
-    auto attr = Attribute::create<bool>(*ctx->tiledb_ctx(), "a0");
+    auto attr = Attribute::create<bool>(*ctx->tiledb_ctx(), attr_name);
     schema.add_attribute(attr);
     schema.set_allows_dups(true);
 
@@ -514,14 +529,14 @@ TEST_CASE("SOMAArray: Write and read back Boolean") {
     arrow_schema->release = &ArrowAdapter::release_schema;
     arrow_schema->children = new ArrowSchema*[arrow_schema->n_children];
     ArrowSchema* arrow_dim = arrow_schema->children[0] = new ArrowSchema;
-    arrow_dim->format = "l";
-    arrow_dim->name = "d0";
+    arrow_dim->format = strdup(helper::to_arrow_format(TILEDB_INT64).c_str());
+    arrow_dim->name = dim_name;
     arrow_dim->n_children = 0;
     arrow_dim->dictionary = nullptr;
     arrow_dim->release = &ArrowAdapter::release_schema;
     ArrowSchema* arrow_att = arrow_schema->children[1] = new ArrowSchema;
     arrow_att->format = "b";
-    arrow_att->name = "a0";
+    arrow_att->name = attr_name;
     arrow_att->n_children = 0;
     arrow_att->dictionary = nullptr;
     arrow_att->release = &ArrowAdapter::release_schema;
@@ -569,12 +584,12 @@ TEST_CASE("SOMAArray: Write and read back Boolean") {
     soma_array = SOMAArray::open(OpenMode::read, uri, ctx);
     auto arrbuf = soma_array->read_next().value();
 
-    auto d0_span = arrbuf->at("d0")->data<int64_t>();
+    auto d0_span = arrbuf->at(dim_name)->data<int64_t>();
     REQUIRE(
         std::vector<int64_t>(d0_span.begin(), d0_span.end()) ==
         std::vector<int64_t>(d0_data, d0_data + 8));
 
-    auto a0_span = arrbuf->at("a0")->data<bool>();
+    auto a0_span = arrbuf->at(attr_name)->data<bool>();
     REQUIRE(
         std::vector<bool>(a0_span.begin(), a0_span.end()) ==
         std::vector<bool>(
diff --git a/libtiledbsoma/test/unit_soma_collection.cc b/libtiledbsoma/test/unit_soma_collection.cc
index 4ffdb8ff26..903c9710f3 100644
--- a/libtiledbsoma/test/unit_soma_collection.cc
+++ b/libtiledbsoma/test/unit_soma_collection.cc
@@ -60,11 +60,19 @@ TEST_CASE("SOMACollection: add SOMASparseNDArray") {
         auto ctx = std::make_shared<SOMAContext>();
         std::string base_uri = "mem://unit-test-add-sparse-ndarray";
         std::string sub_uri = "mem://unit-test-add-sparse-ndarray/sub";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         SOMACollection::create(base_uri, ctx, ts);
 
-        auto index_columns = helper::create_column_index_info(
-            DIM_MAX, use_current_domain);
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
 
         std::map<std::string, SOMAGroupEntry> expected_map{
             {"sparse_ndarray", SOMAGroupEntry(sub_uri, "SOMAArray")}};
@@ -78,7 +86,7 @@ TEST_CASE("SOMACollection: add SOMASparseNDArray") {
             sub_uri,
             URIType::absolute,
             ctx,
-            "l",
+            arrow_format,
             ArrowTable(
                 std::move(index_columns.first),
                 std::move(index_columns.second)));
@@ -104,11 +112,19 @@ TEST_CASE("SOMACollection: add SOMADenseNDArray") {
     auto ctx = std::make_shared<SOMAContext>();
     std::string base_uri = "mem://unit-test-add-dense-ndarray";
     std::string sub_uri = "mem://unit-test-add-dense-ndarray/sub";
+    std::string dim_name = "soma_dim_0";
+    tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+    std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
     SOMACollection::create(base_uri, ctx, ts);
     // TODO: add support for current domain in dense arrays once we have that
     // support from core
-    auto index_columns = helper::create_column_index_info(DIM_MAX, false);
+    std::vector<helper::DimInfo> dim_infos(
+        {{.name = dim_name,
+          .tiledb_datatype = tiledb_datatype,
+          .dim_max = DIM_MAX,
+          .use_current_domain = false}});
+    auto index_columns = helper::create_column_index_info(dim_infos);
 
     std::map<std::string, SOMAGroupEntry> expected_map{
         {"dense_ndarray", SOMAGroupEntry(sub_uri, "SOMAArray")}};
@@ -122,7 +138,7 @@ TEST_CASE("SOMACollection: add SOMADenseNDArray") {
         sub_uri,
         URIType::absolute,
         ctx,
-        "l",
+        arrow_format,
         ArrowTable(
             std::move(index_columns.first), std::move(index_columns.second)));
     REQUIRE(soma_collection->members_map() == expected_map);
@@ -149,11 +165,23 @@ TEST_CASE("SOMACollection: add SOMADataFrame") {
         auto ctx = std::make_shared<SOMAContext>();
         std::string base_uri = "mem://unit-test-add-dataframe";
         std::string sub_uri = "mem://unit-test-add-dataframe/sub";
+        std::string dim_name = "d0";
+        std::string attr_name = "a0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         SOMACollection::create(base_uri, ctx, ts);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                DIM_MAX, use_current_domain);
+                dim_infos, attr_infos);
 
         std::map<std::string, SOMAGroupEntry> expected_map{
             {"dataframe", SOMAGroupEntry(sub_uri, "SOMAArray")}};
@@ -175,7 +203,7 @@ TEST_CASE("SOMACollection: add SOMADataFrame") {
         REQUIRE(soma_dataframe->uri() == sub_uri);
         REQUIRE(soma_dataframe->ctx() == ctx);
         REQUIRE(soma_dataframe->type() == "SOMADataFrame");
-        std::vector<std::string> expected_index_column_names = {"d0"};
+        std::vector<std::string> expected_index_column_names = {dim_name};
         REQUIRE(
             soma_dataframe->index_column_names() ==
             expected_index_column_names);
@@ -190,27 +218,35 @@ TEST_CASE("SOMACollection: add SOMADataFrame") {
 }
 
 TEST_CASE("SOMACollection: add SOMACollection") {
-    auto ctx = std::make_shared<SOMAContext>();
-    std::string base_uri = "mem://unit-test-add-collection";
-    std::string sub_uri = "mem://unit-test-add-collection/sub";
+    auto use_current_domain = GENERATE(false, true);
+    std::ostringstream section;
+    section << "- use_current_domain=" << use_current_domain;
+    SECTION(section.str()) {
+        auto ctx = std::make_shared<SOMAContext>();
+        std::string base_uri = "mem://unit-test-add-collection";
+        std::string sub_uri = "mem://unit-test-add-collection/sub";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
-    SOMACollection::create(base_uri, ctx);
+        SOMACollection::create(base_uri, ctx);
 
-    std::map<std::string, SOMAGroupEntry> expected_map{
-        {"subcollection", SOMAGroupEntry(sub_uri, "SOMAGroup")}};
+        std::map<std::string, SOMAGroupEntry> expected_map{
+            {"subcollection", SOMAGroupEntry(sub_uri, "SOMAGroup")}};
 
-    auto soma_collection = SOMACollection::open(base_uri, OpenMode::write, ctx);
-    auto soma_subcollection = soma_collection->add_new_collection(
-        "subcollection", sub_uri, URIType::absolute, ctx);
-    REQUIRE(soma_collection->members_map() == expected_map);
-    REQUIRE(soma_subcollection->uri() == sub_uri);
-    REQUIRE(soma_subcollection->ctx() == ctx);
-    REQUIRE(soma_subcollection->type() == "SOMACollection");
-    soma_collection->close();
+        auto soma_collection = SOMACollection::open(
+            base_uri, OpenMode::write, ctx);
+        auto soma_subcollection = soma_collection->add_new_collection(
+            "subcollection", sub_uri, URIType::absolute, ctx);
+        REQUIRE(soma_collection->members_map() == expected_map);
+        REQUIRE(soma_subcollection->uri() == sub_uri);
+        REQUIRE(soma_subcollection->ctx() == ctx);
+        REQUIRE(soma_subcollection->type() == "SOMACollection");
+        soma_collection->close();
 
-    soma_collection = SOMACollection::open(base_uri, OpenMode::read, ctx);
-    REQUIRE(soma_collection->members_map() == expected_map);
-    soma_collection->close();
+        soma_collection = SOMACollection::open(base_uri, OpenMode::read, ctx);
+        REQUIRE(soma_collection->members_map() == expected_map);
+        soma_collection->close();
+    }
 }
 
 TEST_CASE("SOMACollection: add SOMAExperiment") {
@@ -221,11 +257,23 @@ TEST_CASE("SOMACollection: add SOMAExperiment") {
         auto ctx = std::make_shared<SOMAContext>();
         std::string base_uri = "mem://unit-test-add-experiment";
         std::string sub_uri = "mem://unit-test-add-experiment/sub";
+        std::string dim_name = "d0";
+        std::string attr_name = "a0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         SOMACollection::create(base_uri, ctx);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                DIM_MAX, use_current_domain);
+                dim_infos, attr_infos);
 
         std::map<std::string, SOMAGroupEntry> expected_map{
             {"experiment", SOMAGroupEntry(sub_uri, "SOMAGroup")}};
@@ -262,11 +310,23 @@ TEST_CASE("SOMACollection: add SOMAMeasurement") {
         auto ctx = std::make_shared<SOMAContext>();
         std::string base_uri = "mem://unit-test-add-measurement";
         std::string sub_uri = "mem://unit-test-add-measurement/sub";
+        std::string dim_name = "d0";
+        std::string attr_name = "a0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         SOMACollection::create(base_uri, ctx);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                DIM_MAX, use_current_domain);
+                dim_infos, attr_infos);
 
         std::map<std::string, SOMAGroupEntry> expected_map{
             {"measurement", SOMAGroupEntry(sub_uri, "SOMAGroup")}};
@@ -296,56 +356,64 @@ TEST_CASE("SOMACollection: add SOMAMeasurement") {
 }
 
 TEST_CASE("SOMACollection: metadata") {
-    auto ctx = std::make_shared<SOMAContext>();
+    auto use_current_domain = GENERATE(false, true);
+    std::ostringstream section;
+    section << "- use_current_domain=" << use_current_domain;
+    SECTION(section.str()) {
+        auto ctx = std::make_shared<SOMAContext>();
 
-    std::string uri = "mem://unit-test-collection";
-    SOMACollection::create(uri, ctx, TimestampRange(0, 2));
-    auto soma_collection = SOMACollection::open(
-        uri, OpenMode::write, ctx, std::pair<uint64_t, uint64_t>(1, 1));
+        std::string uri = "mem://unit-test-collection";
+        SOMACollection::create(uri, ctx, TimestampRange(0, 2));
+        auto soma_collection = SOMACollection::open(
+            uri, OpenMode::write, ctx, std::pair<uint64_t, uint64_t>(1, 1));
 
-    int32_t val = 100;
-    soma_collection->set_metadata("md", TILEDB_INT32, 1, &val);
-    soma_collection->close();
+        int32_t val = 100;
+        soma_collection->set_metadata("md", TILEDB_INT32, 1, &val);
+        soma_collection->close();
 
-    // Read metadata
-    soma_collection->open(OpenMode::read, TimestampRange(0, 2));
-    REQUIRE(soma_collection->metadata_num() == 3);
-    REQUIRE(soma_collection->has_metadata("soma_object_type"));
-    REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
-    REQUIRE(soma_collection->has_metadata("md"));
-    auto mdval = soma_collection->get_metadata("md");
-    REQUIRE(std::get<MetadataInfo::dtype>(*mdval) == TILEDB_INT32);
-    REQUIRE(std::get<MetadataInfo::num>(*mdval) == 1);
-    REQUIRE(*((const int32_t*)std::get<MetadataInfo::value>(*mdval)) == 100);
-    soma_collection->close();
+        // Read metadata
+        soma_collection->open(OpenMode::read, TimestampRange(0, 2));
+        REQUIRE(soma_collection->metadata_num() == 3);
+        REQUIRE(soma_collection->has_metadata("soma_object_type"));
+        REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
+        REQUIRE(soma_collection->has_metadata("md"));
+        auto mdval = soma_collection->get_metadata("md");
+        REQUIRE(std::get<MetadataInfo::dtype>(*mdval) == TILEDB_INT32);
+        REQUIRE(std::get<MetadataInfo::num>(*mdval) == 1);
+        REQUIRE(
+            *((const int32_t*)std::get<MetadataInfo::value>(*mdval)) == 100);
+        soma_collection->close();
 
-    // md should not be available at (2, 2)
-    soma_collection->open(OpenMode::read, TimestampRange(2, 2));
-    REQUIRE(soma_collection->metadata_num() == 2);
-    REQUIRE(soma_collection->has_metadata("soma_object_type"));
-    REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
-    REQUIRE(!soma_collection->has_metadata("md"));
-    soma_collection->close();
+        // md should not be available at (2, 2)
+        soma_collection->open(OpenMode::read, TimestampRange(2, 2));
+        REQUIRE(soma_collection->metadata_num() == 2);
+        REQUIRE(soma_collection->has_metadata("soma_object_type"));
+        REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
+        REQUIRE(!soma_collection->has_metadata("md"));
+        soma_collection->close();
 
-    // Metadata should also be retrievable in write mode
-    soma_collection->open(OpenMode::write, TimestampRange(0, 2));
-    REQUIRE(soma_collection->metadata_num() == 3);
-    REQUIRE(soma_collection->has_metadata("soma_object_type"));
-    REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
-    REQUIRE(soma_collection->has_metadata("md"));
-    mdval = soma_collection->get_metadata("md");
-    REQUIRE(*((const int32_t*)std::get<MetadataInfo::value>(*mdval)) == 100);
-
-    // Delete and have it reflected when reading metadata while in write mode
-    soma_collection->delete_metadata("md");
-    mdval = soma_collection->get_metadata("md");
-    REQUIRE(!mdval.has_value());
-    soma_collection->close();
+        // Metadata should also be retrievable in write mode
+        soma_collection->open(OpenMode::write, TimestampRange(0, 2));
+        REQUIRE(soma_collection->metadata_num() == 3);
+        REQUIRE(soma_collection->has_metadata("soma_object_type"));
+        REQUIRE(soma_collection->has_metadata("soma_encoding_version"));
+        REQUIRE(soma_collection->has_metadata("md"));
+        mdval = soma_collection->get_metadata("md");
+        REQUIRE(
+            *((const int32_t*)std::get<MetadataInfo::value>(*mdval)) == 100);
 
-    // Confirm delete in read mode
-    soma_collection->open(OpenMode::read, TimestampRange(0, 2));
-    REQUIRE(!soma_collection->has_metadata("md"));
-    REQUIRE(soma_collection->metadata_num() == 2);
+        // Delete and have it reflected when reading metadata while in write
+        // mode
+        soma_collection->delete_metadata("md");
+        mdval = soma_collection->get_metadata("md");
+        REQUIRE(!mdval.has_value());
+        soma_collection->close();
+
+        // Confirm delete in read mode
+        soma_collection->open(OpenMode::read, TimestampRange(0, 2));
+        REQUIRE(!soma_collection->has_metadata("md"));
+        REQUIRE(soma_collection->metadata_num() == 2);
+    }
 }
 
 TEST_CASE("SOMAExperiment: metadata") {
@@ -356,9 +424,22 @@ TEST_CASE("SOMAExperiment: metadata") {
         auto ctx = std::make_shared<SOMAContext>();
 
         std::string uri = "mem://unit-test-experiment";
+        std::string dim_name = "soma_dim_0";
+        std::string attr_name = "soma_data";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                DIM_MAX, use_current_domain);
+                dim_infos, attr_infos);
+
         SOMAExperiment::create(
             uri,
             std::move(schema),
@@ -434,9 +515,22 @@ TEST_CASE("SOMAMeasurement: metadata") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-measurement";
+        std::string dim_name = "soma_dim_0";
+        std::string attr_name = "soma_data";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = DIM_MAX,
+              .use_current_domain = use_current_domain}});
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                DIM_MAX, use_current_domain);
+                dim_infos, attr_infos);
+
         SOMAMeasurement::create(
             uri,
             std::move(schema),
diff --git a/libtiledbsoma/test/unit_soma_dataframe.cc b/libtiledbsoma/test/unit_soma_dataframe.cc
index 49974f0290..bd75350caa 100644
--- a/libtiledbsoma/test/unit_soma_dataframe.cc
+++ b/libtiledbsoma/test/unit_soma_dataframe.cc
@@ -45,12 +45,26 @@ TEST_CASE("SOMADataFrame: basic") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-dataframe-basic";
+        std::string dim_name = "d0";
+        std::string attr_name = "a0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         REQUIRE(!SOMADataFrame::exists(uri, ctx));
 
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
+
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                dim_max, use_current_domain);
+                dim_infos, attr_infos);
+
         SOMADataFrame::create(
             uri,
             std::move(schema),
@@ -67,7 +81,7 @@ TEST_CASE("SOMADataFrame: basic") {
         REQUIRE(soma_dataframe->uri() == uri);
         REQUIRE(soma_dataframe->ctx() == ctx);
         REQUIRE(soma_dataframe->type() == "SOMADataFrame");
-        std::vector<std::string> expected_index_column_names = {"d0"};
+        std::vector<std::string> expected_index_column_names = {dim_name};
         REQUIRE(
             soma_dataframe->index_column_names() ==
             expected_index_column_names);
@@ -80,16 +94,16 @@ TEST_CASE("SOMADataFrame: basic") {
         std::vector<int> a0(10, 1);
 
         soma_dataframe = SOMADataFrame::open(uri, OpenMode::write, ctx);
-        soma_dataframe->set_column_data("a0", a0.size(), a0.data());
-        soma_dataframe->set_column_data("d0", d0.size(), d0.data());
+        soma_dataframe->set_column_data(attr_name, a0.size(), a0.data());
+        soma_dataframe->set_column_data(dim_name, d0.size(), d0.data());
         soma_dataframe->write();
         soma_dataframe->close();
 
         soma_dataframe = SOMADataFrame::open(uri, OpenMode::read, ctx);
         while (auto batch = soma_dataframe->read_next()) {
             auto arrbuf = batch.value();
-            auto d0span = arrbuf->at("d0")->data<int64_t>();
-            auto a0span = arrbuf->at("a0")->data<int>();
+            auto d0span = arrbuf->at(dim_name)->data<int64_t>();
+            auto a0span = arrbuf->at(attr_name)->data<int>();
             REQUIRE(d0 == std::vector<int64_t>(d0span.begin(), d0span.end()));
             REQUIRE(a0 == std::vector<int>(a0span.begin(), a0span.end()));
         }
@@ -161,6 +175,9 @@ TEST_CASE("SOMADataFrame: platform_config") {
         SECTION(section2.str()) {
             auto ctx = std::make_shared<SOMAContext>();
             std::string uri = "mem://unit-test-dataframe-platform-config";
+            std::string dim_name = "d0";
+            std::string attr_name = "a0";
+            tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
 
             PlatformConfig platform_config;
             platform_config.dataframe_dim_zstd_level = 6;
@@ -171,9 +188,19 @@ TEST_CASE("SOMADataFrame: platform_config") {
                                         filter.first + R"(]}})";
             }
 
+            std::vector<helper::DimInfo> dim_infos(
+                {{.name = dim_name,
+                  .tiledb_datatype = tiledb_datatype,
+                  .dim_max = dim_max,
+                  .use_current_domain = use_current_domain}});
+
+            std::vector<helper::AttrInfo> attr_infos(
+                {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
+
             auto [schema, index_columns] =
                 helper::create_arrow_schema_and_index_columns(
-                    dim_max, use_current_domain);
+                    dim_infos, attr_infos);
+
             SOMADataFrame::create(
                 uri,
                 std::move(schema),
@@ -194,7 +221,7 @@ TEST_CASE("SOMADataFrame: platform_config") {
                 filter.second);
 
             auto dim_filter = sch->domain()
-                                  .dimension("d0")
+                                  .dimension(dim_name)
                                   .filter_list()
                                   .filter(0);
             REQUIRE(dim_filter.filter_type() == TILEDB_FILTER_ZSTD);
@@ -203,7 +230,7 @@ TEST_CASE("SOMADataFrame: platform_config") {
 
             if (filter.second != TILEDB_FILTER_WEBP) {
                 REQUIRE(
-                    sch->attribute("a0")
+                    sch->attribute(attr_name)
                         .filter_list()
                         .filter(0)
                         .filter_type() == filter.second);
@@ -224,10 +251,24 @@ TEST_CASE("SOMADataFrame: metadata") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-collection";
+        std::string dim_name = "d0";
+        std::string attr_name = "a0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        std::vector<helper::AttrInfo> attr_infos(
+            {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
 
         auto [schema, index_columns] =
             helper::create_arrow_schema_and_index_columns(
-                dim_max, use_current_domain);
+                dim_infos, attr_infos);
+
         SOMADataFrame::create(
             uri,
             std::move(schema),
@@ -302,9 +343,22 @@ TEST_CASE("SOMADataFrame: bounds-checking") {
 
     auto ctx = std::make_shared<SOMAContext>();
     std::string uri = "mem://unit-test-bounds-checking";
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+    tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+    std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+    std::vector<helper::DimInfo> dim_infos(
+        {{.name = dim_name,
+          .tiledb_datatype = tiledb_datatype,
+          .dim_max = old_max,
+          .use_current_domain = use_current_domain}});
+
+    std::vector<helper::AttrInfo> attr_infos(
+        {{.name = attr_name, .tiledb_datatype = tiledb_datatype}});
 
     auto [schema, index_columns] =
-        helper::create_arrow_schema_and_index_columns(100, use_current_domain);
+        helper::create_arrow_schema_and_index_columns(dim_infos, attr_infos);
 
     SOMADataFrame::create(
         uri,
@@ -317,8 +371,8 @@ TEST_CASE("SOMADataFrame: bounds-checking") {
 
     std::vector<int64_t> d0({old_max + 1, old_max + 2});
     std::vector<double> a0({1.5, 2.5});
-    soma_dataframe->set_column_data("d0", d0.size(), d0.data());
-    soma_dataframe->set_column_data("a0", a0.size(), a0.data());
+    soma_dataframe->set_column_data(dim_name, d0.size(), d0.data());
+    soma_dataframe->set_column_data(attr_name, a0.size(), a0.data());
     // Writing outside the current domain should fail
     REQUIRE_THROWS(soma_dataframe->write());
     soma_dataframe->close();
@@ -328,8 +382,8 @@ TEST_CASE("SOMADataFrame: bounds-checking") {
     soma_dataframe->close();
 
     soma_dataframe = SOMADataFrame::open(uri, OpenMode::write, ctx);
-    soma_dataframe->set_column_data("d0", d0.size(), d0.data());
-    soma_dataframe->set_column_data("a0", a0.size(), a0.data());
+    soma_dataframe->set_column_data(dim_name, d0.size(), d0.data());
+    soma_dataframe->set_column_data(attr_name, a0.size(), a0.data());
     // Writing after resize should succeed
     soma_dataframe->write();
 
diff --git a/libtiledbsoma/test/unit_soma_dense_ndarray.cc b/libtiledbsoma/test/unit_soma_dense_ndarray.cc
index 5422975a9d..cbfd4783f4 100644
--- a/libtiledbsoma/test/unit_soma_dense_ndarray.cc
+++ b/libtiledbsoma/test/unit_soma_dense_ndarray.cc
@@ -45,17 +45,26 @@ TEST_CASE("SOMADenseNDArray: basic") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-dense-ndarray-basic";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         REQUIRE(!SOMADenseNDArray::exists(uri, ctx));
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
+
         if (use_current_domain) {
             // Setting a current domain on a TileDB dense array is not (yet)
             // supported
             REQUIRE_THROWS(SOMADenseNDArray::create(
                 uri,
-                "l",
+                arrow_format,
                 ArrowTable(
                     std::move(index_columns.first),
                     std::move(index_columns.second)),
@@ -65,7 +74,7 @@ TEST_CASE("SOMADenseNDArray: basic") {
         } else {
             SOMADenseNDArray::create(
                 uri,
-                "l",
+                arrow_format,
                 ArrowTable(
                     std::move(index_columns.first),
                     std::move(index_columns.second)),
@@ -82,11 +91,11 @@ TEST_CASE("SOMADenseNDArray: basic") {
             REQUIRE(soma_dense->ctx() == ctx);
             REQUIRE(soma_dense->type() == "SOMADenseNDArray");
             REQUIRE(soma_dense->is_sparse() == false);
-            REQUIRE(soma_dense->soma_data_type() == "l");
+            REQUIRE(soma_dense->soma_data_type() == arrow_format);
             auto schema = soma_dense->tiledb_schema();
             REQUIRE(schema->has_attribute("soma_data"));
             REQUIRE(schema->array_type() == TILEDB_DENSE);
-            REQUIRE(schema->domain().has_dimension("soma_dim_0"));
+            REQUIRE(schema->domain().has_dimension(dim_name));
             REQUIRE(soma_dense->ndim() == 1);
 
             // Once we have support for current domain in dense arrays
@@ -109,7 +118,7 @@ TEST_CASE("SOMADenseNDArray: basic") {
 
             soma_dense->open(OpenMode::write);
             soma_dense->set_column_data("soma_data", a0.size(), a0.data());
-            soma_dense->set_column_data("soma_dim_0", d0.size(), d0.data());
+            soma_dense->set_column_data(dim_name, d0.size(), d0.data());
             soma_dense->write();
             soma_dense->close();
 
@@ -135,18 +144,27 @@ TEST_CASE("SOMADenseNDArray: platform_config") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-dense-ndarray-platform-config";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         PlatformConfig platform_config;
         platform_config.dense_nd_array_dim_zstd_level = 6;
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
+
         if (use_current_domain) {
             // Setting a current domain on a TileDB dense array is not (yet)
             // supported
             REQUIRE_THROWS(SOMADenseNDArray::create(
                 uri,
-                "l",
+                arrow_format,
                 ArrowTable(
                     std::move(index_columns.first),
                     std::move(index_columns.second)),
@@ -156,7 +174,7 @@ TEST_CASE("SOMADenseNDArray: platform_config") {
         } else {
             SOMADenseNDArray::create(
                 uri,
-                "l",
+                arrow_format,
                 ArrowTable(
                     std::move(index_columns.first),
                     std::move(index_columns.second)),
@@ -166,7 +184,7 @@ TEST_CASE("SOMADenseNDArray: platform_config") {
             auto soma_dense = SOMADenseNDArray::open(uri, OpenMode::read, ctx);
             auto dim_filter = soma_dense->tiledb_schema()
                                   ->domain()
-                                  .dimension("soma_dim_0")
+                                  .dimension(dim_name)
                                   .filter_list()
                                   .filter(0);
             REQUIRE(dim_filter.filter_type() == TILEDB_FILTER_ZSTD);
@@ -188,14 +206,22 @@ TEST_CASE("SOMADenseNDArray: metadata") {
     section << "- use_current_domain=" << use_current_domain;
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
-
         std::string uri = "mem://unit-test-dense-ndarray";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
         SOMASparseNDArray::create(
             uri,
-            "l",
+            arrow_format,
             ArrowTable(
                 std::move(index_columns.first),
                 std::move(index_columns.second)),
diff --git a/libtiledbsoma/test/unit_soma_group.cc b/libtiledbsoma/test/unit_soma_group.cc
index 018d13bfaf..feaf5b3988 100644
--- a/libtiledbsoma/test/unit_soma_group.cc
+++ b/libtiledbsoma/test/unit_soma_group.cc
@@ -68,6 +68,9 @@ std::tuple<std::string, uint64_t> create_array(
     bool allow_duplicates = false,
     uint64_t timestamp = 1,
     bool reuse_existing = false) {
+    std::string dim_name = "d0";
+    std::string attr_name = "a0";
+
     // Create array, if not reusing the existing array
     if (!reuse_existing) {
         auto vfs = VFS(ctx);
@@ -79,13 +82,13 @@ std::tuple<std::string, uint64_t> create_array(
         ArraySchema schema(ctx, TILEDB_SPARSE);
 
         auto dim = Dimension::create<int64_t>(
-            ctx, "d0", {0, std::numeric_limits<int64_t>::max() - 1});
+            ctx, dim_name, {0, std::numeric_limits<int64_t>::max() - 1});
 
         Domain domain(ctx);
         domain.add_dimension(dim);
         schema.set_domain(domain);
 
-        auto attr = Attribute::create<int>(ctx, "a0");
+        auto attr = Attribute::create<int>(ctx, attr_name);
         schema.add_attribute(attr);
         schema.set_allows_dups(allow_duplicates);
         schema.check();
@@ -120,8 +123,8 @@ std::tuple<std::string, uint64_t> create_array(
         // Write data to array
         Query query(ctx, array);
         query.set_layout(TILEDB_UNORDERED)
-            .set_data_buffer("d0", d0)
-            .set_data_buffer("a0", a0);
+            .set_data_buffer(dim_name, d0)
+            .set_data_buffer(attr_name, a0);
         query.submit();
     }
 
diff --git a/libtiledbsoma/test/unit_soma_sparse_ndarray.cc b/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
index f664a8e563..added6b5b4 100644
--- a/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
+++ b/libtiledbsoma/test/unit_soma_sparse_ndarray.cc
@@ -44,14 +44,23 @@ TEST_CASE("SOMASparseNDArray: basic") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-sparse-ndarray-basic";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         REQUIRE(!SOMASparseNDArray::exists(uri, ctx));
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
+
         SOMASparseNDArray::create(
             uri,
-            "l",
+            arrow_format,
             ArrowTable(
                 std::move(index_columns.first),
                 std::move(index_columns.second)),
@@ -68,11 +77,11 @@ TEST_CASE("SOMASparseNDArray: basic") {
         REQUIRE(soma_sparse->ctx() == ctx);
         REQUIRE(soma_sparse->type() == "SOMASparseNDArray");
         REQUIRE(soma_sparse->is_sparse() == true);
-        REQUIRE(soma_sparse->soma_data_type() == "l");
+        REQUIRE(soma_sparse->soma_data_type() == arrow_format);
         auto schema = soma_sparse->tiledb_schema();
         REQUIRE(schema->has_attribute("soma_data"));
         REQUIRE(schema->array_type() == TILEDB_SPARSE);
-        REQUIRE(schema->domain().has_dimension("soma_dim_0"));
+        REQUIRE(schema->domain().has_dimension(dim_name));
         REQUIRE(soma_sparse->ndim() == 1);
         REQUIRE(soma_sparse->nnz() == 0);
 
@@ -92,14 +101,14 @@ TEST_CASE("SOMASparseNDArray: basic") {
 
         soma_sparse->open(OpenMode::write);
         soma_sparse->set_column_data("soma_data", a0.size(), a0.data());
-        soma_sparse->set_column_data("soma_dim_0", d0.size(), d0.data());
+        soma_sparse->set_column_data(dim_name, d0.size(), d0.data());
         soma_sparse->write();
         soma_sparse->close();
 
         soma_sparse->open(OpenMode::read);
         while (auto batch = soma_sparse->read_next()) {
             auto arrbuf = batch.value();
-            auto d0span = arrbuf->at("soma_dim_0")->data<int64_t>();
+            auto d0span = arrbuf->at(dim_name)->data<int64_t>();
             auto a0span = arrbuf->at("soma_data")->data<int>();
             REQUIRE(d0 == std::vector<int64_t>(d0span.begin(), d0span.end()));
             REQUIRE(a0 == std::vector<int>(a0span.begin(), a0span.end()));
@@ -125,15 +134,24 @@ TEST_CASE("SOMASparseNDArray: platform_config") {
     SECTION(section.str()) {
         auto ctx = std::make_shared<SOMAContext>();
         std::string uri = "mem://unit-test-dataframe-platform-config";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
 
         PlatformConfig platform_config;
         platform_config.sparse_nd_array_dim_zstd_level = 6;
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
+
         SOMASparseNDArray::create(
             uri,
-            "l",
+            arrow_format,
             ArrowTable(
                 std::move(index_columns.first),
                 std::move(index_columns.second)),
@@ -143,7 +161,7 @@ TEST_CASE("SOMASparseNDArray: platform_config") {
         auto soma_dataframe = SOMASparseNDArray::open(uri, OpenMode::read, ctx);
         auto dim_filter = soma_dataframe->tiledb_schema()
                               ->domain()
-                              .dimension("soma_dim_0")
+                              .dimension(dim_name)
                               .filter_list()
                               .filter(0);
         REQUIRE(dim_filter.filter_type() == TILEDB_FILTER_ZSTD);
@@ -165,12 +183,21 @@ TEST_CASE("SOMASparseNDArray: metadata") {
         auto ctx = std::make_shared<SOMAContext>();
 
         std::string uri = "mem://unit-test-sparse-ndarray";
+        std::string dim_name = "soma_dim_0";
+        tiledb_datatype_t tiledb_datatype = TILEDB_INT64;
+        std::string arrow_format = helper::to_arrow_format(tiledb_datatype);
+
+        std::vector<helper::DimInfo> dim_infos(
+            {{.name = dim_name,
+              .tiledb_datatype = tiledb_datatype,
+              .dim_max = dim_max,
+              .use_current_domain = use_current_domain}});
+
+        auto index_columns = helper::create_column_index_info(dim_infos);
 
-        auto index_columns = helper::create_column_index_info(
-            dim_max, use_current_domain);
         SOMASparseNDArray::create(
             uri,
-            "l",
+            arrow_format,
             ArrowTable(
                 std::move(index_columns.first),
                 std::move(index_columns.second)),