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

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

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

[python] Bindings for `upgrade_domain`
**Issue and/or context:** As tracked on issue #2407 / [[sc-51048]](https://app.shortcut.com/tiledb-inc/story/51048).

Note that the intended Python and R API changes are all agreed on and finalized as described in #2407.

**Changes:**

Connects #3220 to the Python user-facing layers.

**Notes for Reviewer:**

I was going to do Python+R on a single PR but the line count is ~400 which is enough (IMO) so I'll make a separate for R -- cc @mojaveazure 


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/apis/python/src/tiledbsoma/_dataframe.py b/apis/python/src/tiledbsoma/_dataframe.py
index a9fbeedccb..c6b5f9dbf1 100644
--- a/apis/python/src/tiledbsoma/_dataframe.py
+++ b/apis/python/src/tiledbsoma/_dataframe.py
@@ -547,8 +547,9 @@ def tiledbsoma_upgrade_domain(
         ``DataFrame`` does not have a domain set yet.  The argument must be a
         tuple of pairs of low/high values for the desired domain, one pair per
         index column. For string index columns, you must offer the low/high pair
-        as `("", "")`.  If ``check_only`` is ``True``, returns whether the
-        operation would succeed if attempted, and a reason why it would not.
+        as `("", "")`, or as `None`.  If ``check_only`` is ``True``, returns
+        whether the operation would succeed if attempted, and a reason why it
+        would not.
         """
         pyarrow_domain_table = self._upgrade_or_change_domain_helper(
             newdomain, "tiledbsoma_upgrade_domain"
@@ -574,8 +575,8 @@ def change_domain(
         the ``DataFrame`` already has a domain.  The argument must be a tuple of
         pairs of low/high values for the desired domain, one pair per index
         column. For string index columns, you must offer the low/high pair as
-        `("", "")`.  If ``check_only`` is ``True``, returns whether the
-        operation would succeed if attempted, and a reason why it would not.
+        `("", "")`, or as `None`.  If ``check_only`` is ``True``, returns whether
+        the operation would succeed if attempted, and a reason why it would not.
         """
         pyarrow_domain_table = self._upgrade_or_change_domain_helper(
             newdomain, "change_domain"
diff --git a/apis/r/DESCRIPTION b/apis/r/DESCRIPTION
index b2f12104b8..9e50f7824c 100644
--- a/apis/r/DESCRIPTION
+++ b/apis/r/DESCRIPTION
@@ -6,7 +6,7 @@ Description: Interface for working with 'TileDB'-based Stack of Matrices,
     like those commonly used for single cell data analysis. It is documented at
     <https://github.com/single-cell-data>; a formal specification available is at
     <https://github.com/single-cell-data/SOMA/blob/main/abstract_specification.md>.
-Version: 1.15.99.10
+Version: 1.15.99.11
 Authors@R: c(
     person(given = "Aaron", family = "Wolen",
            role = c("cre", "aut"), email = "aaron@tiledb.com",
diff --git a/apis/r/NEWS.md b/apis/r/NEWS.md
index 4eac934637..4ae7c106c6 100644
--- a/apis/r/NEWS.md
+++ b/apis/r/NEWS.md
@@ -18,6 +18,32 @@
 * Expose block/random writer for sparse arrays [#3204](https://github.com/single-cell-data/TileDB-SOMA/pull/3204)
 * Min-sizing for dataframes/arrays with new shape feature [#3208](https://github.com/single-cell-data/TileDB-SOMA/pull/3208)
 * Proper prefixing for shape-related methods [#3237](https://github.com/single-cell-data/TileDB-SOMA/pull/3237)
+* Bindings for `upgrade_domain` [#3238](https://github.com/single-cell-data/TileDB-SOMA/pull/3238)
+
+# tiledbsoma 1.14.5
+
+## Changes
+
+* Fixes a Python-only bug [#3225](https://github.com/single-cell-data/TileDB-SOMA/pull/3225)
+
+# tiledbsoma 1.14.4
+
+## Changes
+
+* Add new Arrow-to-R type mapper [#3161](https://github.com/single-cell-data/TileDB-SOMA/pull/3161)
+* Expose block/random writer for sparse arrays [#3204](https://github.com/single-cell-data/TileDB-SOMA/pull/3204)
+
+# tiledbsoma 1.14.3
+
+## Changes
+
+* Handle `numeric` coords properly when reading arrays [3145](https://github.com/single-cell-data/TileDB-SOMA/pull/3145)
+
+# tiledbsoma 1.14.2
+
+## Changes
+
+* Fixes a Python-only bug [#3074](https://github.com/single-cell-data/TileDB-SOMA/pull/3074)
 
 # tiledbsoma 1.14.1
 
diff --git a/apis/r/R/RcppExports.R b/apis/r/R/RcppExports.R
index 4ae5d15dd7..e49188b65e 100644
--- a/apis/r/R/RcppExports.R
+++ b/apis/r/R/RcppExports.R
@@ -250,6 +250,10 @@ tiledbsoma_upgrade_shape <- function(uri, new_shape, function_name_for_messages,
     invisible(.Call(`_tiledbsoma_tiledbsoma_upgrade_shape`, uri, new_shape, function_name_for_messages, ctxxp))
 }
 
+upgrade_or_change_domain <- function(uri, is_change_domain, nadimap, nadimsp, function_name_for_messages, ctxxp) {
+    invisible(.Call(`_tiledbsoma_upgrade_or_change_domain`, uri, is_change_domain, nadimap, nadimsp, function_name_for_messages, ctxxp))
+}
+
 c_update_dataframe_schema <- function(uri, ctxxp, column_names_to_drop, add_cols_types, add_cols_enum_value_types, add_cols_enum_ordered) {
     invisible(.Call(`_tiledbsoma_c_update_dataframe_schema`, uri, ctxxp, column_names_to_drop, add_cols_types, add_cols_enum_value_types, add_cols_enum_ordered))
 }
diff --git a/apis/r/R/SOMADataFrame.R b/apis/r/R/SOMADataFrame.R
index 41f17e0ea9..0f3d2854fe 100644
--- a/apis/r/R/SOMADataFrame.R
+++ b/apis/r/R/SOMADataFrame.R
@@ -305,11 +305,11 @@ SOMADataFrame <- R6::R6Class(
       )
 
       drop_cols_for_clib <- drop_cols
-      add_cols_types_for_clib <- 
-        add_cols_enum_value_types_for_clib <- 
+      add_cols_types_for_clib <-
+        add_cols_enum_value_types_for_clib <-
         add_cols_enum_ordered_for_clib <- vector("list", length = length(add_cols))
-      names(add_cols_types_for_clib) <- 
-        names(add_cols_enum_value_types_for_clib) <- 
+      names(add_cols_types_for_clib) <-
+        names(add_cols_enum_value_types_for_clib) <-
         names(add_cols_enum_ordered_for_clib) <- add_cols
 
       # Add columns
@@ -423,6 +423,73 @@ SOMADataFrame <- R6::R6Class(
       invisible(
         resize_soma_joinid_shape(
           self$uri, new_shape, .name_of_function(), private$.soma_context))
+    },
+
+    #' @description Allows you to set the domain of a `SOMADataFrame`, when the
+    #' `SOMADataFrame` does not have a domain set yet.  The argument must be a
+    #' tuple of pairs of low/high values for the desired domain, one pair per
+    #' index column. For string index columns, you must offer the low/high pair
+    #' as `("", "")`, or as `NULL`.  If ``check_only`` is ``True``, returns
+    #' whether the operation would succeed if attempted, and a reason why it
+    #' would not. The domain being requested must be contained within what
+    #' `maxdomain` returns.
+    #' @param new_domain A named list, keyed by index-column name, with values
+    #' being two-element vectors containing the desired lower and upper bounds
+    #' for the domain.
+    #' @return No return value
+    tiledbsoma_upgrade_domain = function(new_domain) {
+      # stopifnot("'new_domain' must be CODE ME UP PLZ" = ...
+      # Checking slotwise new shape >= old shape, and <= max_shape, is already
+      # done in libtiledbsoma
+
+      pyarrow_domain_table <- private$upgrade_or_change_domain_helper(
+        new_domain, "tiledbsoma_upgrade_domain"
+      )
+
+      invisible(
+        upgrade_or_change_domain(
+          self$uri,
+          FALSE,
+          pyarrow_domain_table$array,
+          pyarrow_domain_table$schema,
+          .name_of_function(),
+          private$.soma_context
+        )
+      )
+    },
+
+    #' @description Allows you to set the domain of a `SOMADataFrame`, when the
+    #' `SOMADataFrame` already has a domain set yet.  The argument must be a
+    #' tuple of pairs of low/high values for the desired domain, one pair per
+    #' index column. For string index columns, you must offer the low/high pair
+    #' as `("", "")`, or as `NULL`.  If ``check_only`` is ``True``, returns
+    #' whether the operation would succeed if attempted, and a reason why it
+    #' would not. The return value from `domain` must be contained within
+    #' the requested `new_domain`, and the requested `new_domain` must be
+    #' contained within the return value from `maxdomain`.
+    #' @param new_domain A named list, keyed by index-column name, with values
+    #' being two-element vectors containing the desired lower and upper bounds
+    #' for the domain.
+    #' @return No return value
+    change_domain = function(new_domain) {
+      # stopifnot("'new_domain' must be CODE ME UP PLZ" = ...
+      # Checking slotwise new shape >= old shape, and <= max_shape, is already
+      # done in libtiledbsoma
+
+      pyarrow_domain_table <- private$upgrade_or_change_domain_helper(
+        new_domain, tiledbsoma_upgrade_domain
+      )
+
+      invisible(
+        upgrade_or_change_domain(
+          self$uri,
+          TRUE,
+          pyarrow_domain_table$array,
+          pyarrow_domain_table$schema,
+          .name_of_function(),
+          private$.soma_context
+        )
+      )
     }
 
   ),
@@ -460,6 +527,55 @@ SOMADataFrame <- R6::R6Class(
       }
 
       schema
+    },
+
+    # Converts the user-level tuple of low/high pairs into a pyarrow table
+    # suitable for calling libtiledbsoma.
+    upgrade_or_change_domain_helper = function(new_domain, function_name_for_messages) {
+      dimnames <- self$dimnames()
+
+      # Check user-provided domain against dataframe domain.
+      stopifnot(
+        "new_domain must be a named list, with values being 2-element vectors or NULL, with names the same as the dataframe's index-column names" =
+        rlang::is_list(new_domain, n = length(dimnames)) &&
+          identical(sort(names(new_domain)), sort(dimnames)) &&
+          all(vapply_lgl(
+            new_domain,
+            function(x) is.null(x) || (is.atomic(x) && !is.factor(x) && length(x) == 2L)
+          ))
+      )
+
+      # From the dataframe's schema, extract the subschema for only index
+      # columns (TileDB dimensions).
+      full_schema <- self$schema()
+      dim_schema_list <- list()
+      for (dimname in dimnames) {
+        dim_schema_list[[dimname]] <- full_schema[[dimname]]
+      }
+      dim_schema <- arrow::schema(dim_schema_list)
+
+      # Get the user's new_domain list with keys in the same order as dim_names.
+      ordered_new_domain = list()
+      for (dimname in dimnames) {
+        # * Domain cannot be specified for string-type index columns.
+        # * So we let them say `NULL` rather than `c("", "")`.
+        # * But R list semantics are `mylist[[key]] <- NULL` results in nothing
+        #   being set at that key.
+        if (is.null(new_domain[[dimname]]) && full_schema[[dimname]]$type$ToString() %in% c("string", "large_string", "utf8", "large_utf8")) {
+          ordered_new_domain[[dimname]] <- c("", "")
+        } else {
+          ordered_new_domain[[dimname]] <- new_domain[[dimname]]
+        }
+      }
+
+      pyarrow_table <- arrow::arrow_table(as.data.frame(ordered_new_domain), schema=dim_schema)
+
+      # We transfer to the arrow table via a pair of array and schema pointers
+      dnaap <- nanoarrow::nanoarrow_allocate_array()
+      dnasp <- nanoarrow::nanoarrow_allocate_schema()
+      arrow::as_record_batch(pyarrow_table)$export_to_c(dnaap, dnasp)
+
+      return(list(array=dnaap, schema=dnasp))
     }
   )
 )
diff --git a/apis/r/src/RcppExports.cpp b/apis/r/src/RcppExports.cpp
index 30aab500f0..7c1121cafc 100644
--- a/apis/r/src/RcppExports.cpp
+++ b/apis/r/src/RcppExports.cpp
@@ -591,6 +591,21 @@ BEGIN_RCPP
     return R_NilValue;
 END_RCPP
 }
+// upgrade_or_change_domain
+void upgrade_or_change_domain(const std::string& uri, bool is_change_domain, naxpArray nadimap, naxpSchema nadimsp, std::string function_name_for_messages, Rcpp::XPtr<somactx_wrap_t> ctxxp);
+RcppExport SEXP _tiledbsoma_upgrade_or_change_domain(SEXP uriSEXP, SEXP is_change_domainSEXP, SEXP nadimapSEXP, SEXP nadimspSEXP, SEXP function_name_for_messagesSEXP, SEXP ctxxpSEXP) {
+BEGIN_RCPP
+    Rcpp::RNGScope rcpp_rngScope_gen;
+    Rcpp::traits::input_parameter< const std::string& >::type uri(uriSEXP);
+    Rcpp::traits::input_parameter< bool >::type is_change_domain(is_change_domainSEXP);
+    Rcpp::traits::input_parameter< naxpArray >::type nadimap(nadimapSEXP);
+    Rcpp::traits::input_parameter< naxpSchema >::type nadimsp(nadimspSEXP);
+    Rcpp::traits::input_parameter< std::string >::type function_name_for_messages(function_name_for_messagesSEXP);
+    Rcpp::traits::input_parameter< Rcpp::XPtr<somactx_wrap_t> >::type ctxxp(ctxxpSEXP);
+    upgrade_or_change_domain(uri, is_change_domain, nadimap, nadimsp, function_name_for_messages, ctxxp);
+    return R_NilValue;
+END_RCPP
+}
 // c_update_dataframe_schema
 void c_update_dataframe_schema(const std::string& uri, Rcpp::XPtr<somactx_wrap_t> ctxxp, Rcpp::CharacterVector column_names_to_drop, Rcpp::List add_cols_types, Rcpp::List add_cols_enum_value_types, Rcpp::List add_cols_enum_ordered);
 RcppExport SEXP _tiledbsoma_c_update_dataframe_schema(SEXP uriSEXP, SEXP ctxxpSEXP, SEXP column_names_to_dropSEXP, SEXP add_cols_typesSEXP, SEXP add_cols_enum_value_typesSEXP, SEXP add_cols_enum_orderedSEXP) {
@@ -822,6 +837,7 @@ static const R_CallMethodDef CallEntries[] = {
     {"_tiledbsoma_resize", (DL_FUNC) &_tiledbsoma_resize, 4},
     {"_tiledbsoma_resize_soma_joinid_shape", (DL_FUNC) &_tiledbsoma_resize_soma_joinid_shape, 4},
     {"_tiledbsoma_tiledbsoma_upgrade_shape", (DL_FUNC) &_tiledbsoma_tiledbsoma_upgrade_shape, 4},
+    {"_tiledbsoma_upgrade_or_change_domain", (DL_FUNC) &_tiledbsoma_upgrade_or_change_domain, 6},
     {"_tiledbsoma_c_update_dataframe_schema", (DL_FUNC) &_tiledbsoma_c_update_dataframe_schema, 6},
     {"_tiledbsoma_sr_setup", (DL_FUNC) &_tiledbsoma_sr_setup, 10},
     {"_tiledbsoma_sr_complete", (DL_FUNC) &_tiledbsoma_sr_complete, 1},
diff --git a/apis/r/src/rinterface.cpp b/apis/r/src/rinterface.cpp
index 6682a225fc..5b1050eddd 100644
--- a/apis/r/src/rinterface.cpp
+++ b/apis/r/src/rinterface.cpp
@@ -425,8 +425,7 @@ void resize(
     Rcpp::XPtr<somactx_wrap_t> ctxxp) {
     // This function is solely for SparseNDArray and DenseNDArray for which the
     // dims are required by the SOMA spec to be of type int64. Domain-resize for
-    // variant-indexed dataframes will be separate work as tracked on
-    // https://github.com/single-cell-data/TileDB-SOMA/issues/2407.
+    // variant-indexed dataframes is via upgrade_domain and change_domain.
     auto sr = tdbs::SOMAArray::open(OpenMode::write, uri, ctxxp->ctxptr);
     std::vector<int64_t> new_shape_i64 = i64_from_rcpp_numeric(new_shape);
     sr->resize(new_shape_i64, function_name_for_messages);
@@ -454,14 +453,44 @@ void tiledbsoma_upgrade_shape(
     Rcpp::XPtr<somactx_wrap_t> ctxxp) {
     // This function is solely for SparseNDArray and DenseNDArray for which the
     // dims are required by the SOMA spec to be of type int64. Domain-resize for
-    // variant-indexed dataframes will be separate work as tracked on
-    // https://github.com/single-cell-data/TileDB-SOMA/issues/2407.
+    // variant-indexed dataframes is via upgrade_domain and change_domain.
     auto sr = tdbs::SOMAArray::open(OpenMode::write, uri, ctxxp->ctxptr);
     std::vector<int64_t> new_shape_i64 = i64_from_rcpp_numeric(new_shape);
     sr->upgrade_shape(new_shape_i64, function_name_for_messages);
     sr->close();
 }
 
+// [[Rcpp::export]]
+void upgrade_or_change_domain(
+    const std::string& uri,
+    bool is_change_domain,
+    naxpArray nadimap,
+    naxpSchema nadimsp,
+    std::string function_name_for_messages,
+    Rcpp::XPtr<somactx_wrap_t> ctxxp) {
+
+    // This is pointer manipulation from R -> Rcpp SEXP -> libtiledbsoma:
+    nanoarrow::UniqueArray apdim{nanoarrow_array_from_xptr(nadimap)};
+    nanoarrow::UniqueSchema spdim{nanoarrow_schema_from_xptr(nadimsp)};
+
+    auto dimarr = std::make_unique<ArrowArray>();
+    auto dimsch = std::make_unique<ArrowSchema>();
+
+    apdim.move(dimarr.get());
+    spdim.move(dimsch.get());
+
+    tdbs::ArrowTable arrow_table(std::move(dimarr), std::move(dimsch));
+
+    // Now call libtiledbsoma
+    auto sr = tdbs::SOMADataFrame::open(uri, OpenMode::write, ctxxp->ctxptr);
+    if (is_change_domain) {
+      sr->change_domain(arrow_table, function_name_for_messages);
+    } else {
+      sr->upgrade_domain(arrow_table, function_name_for_messages);
+    }
+    sr->close();
+}
+
 // [[Rcpp::export]]
 void c_update_dataframe_schema(
     const std::string& uri,


Test patch:
diff --git a/apis/r/tests/testthat/test-shape.R b/apis/r/tests/testthat/test-shape.R
index 589f17454b..f3c61c8d54 100644
--- a/apis/r/tests/testthat/test-shape.R
+++ b/apis/r/tests/testthat/test-shape.R
@@ -338,6 +338,110 @@ test_that("SOMADataFrame shape", {
   ))
 })
 
+test_that("SOMADataFrame domain mods", {
+  skip_if(!.new_shape_feature_flag_is_enabled())
+
+  uri <- withr::local_tempdir("soma-dataframe-domain-mods")
+
+   schema = arrow::schema(
+     arrow::field("soma_joinid", arrow::int64()),
+     arrow::field("mystring", arrow::string()),
+     arrow::field("myint", arrow::int16()),
+     arrow::field("myfloat", arrow::float32()),
+     arrow::field("mybool", arrow::bool()) # not supported as an index type
+   )
+
+   index_column_names <- c("soma_joinid", "mystring", "myint", "myfloat")
+
+    domain_for_create <- list(
+        soma_joinid = c(1, 4),
+        mystring = NULL,
+        myint = c(20, 50),
+        myfloat = c(0.0, 6.0)
+    )
+
+    table <- arrow::arrow_table(
+      soma_joinid = 1L:4L,
+      mystring = c("a", "b", "a", "b"),
+      myint = c(20, 30, 40, 50),
+      myfloat = c(1.0, 2.5, 4.0, 5.5),
+      mybool = c(TRUE, FALSE, TRUE, TRUE),
+      schema = schema
+    )
+
+    sdf <- SOMADataFrameCreate(
+      uri,
+      schema=schema,
+      index_column_names=index_column_names,
+      domain=domain_for_create
+    )
+    sdf$write(table)
+    sdf$close()
+
+    sdf <- SOMADataFrameOpen(uri, "WRITE")
+
+    # Check "expand" to same
+    new_domain <- list(
+        soma_joinid = c(1, 4),
+        mystring = NULL,
+        myint = c(20, 50),
+        myfloat = c(0.0, 6.0)
+    )
+    expect_no_condition(sdf$change_domain(new_domain))
+
+    # Shrink
+    new_domain <- list(
+        soma_joinid = c(1, 3),
+        mystring = NULL,
+        myint = c(20, 50),
+        myfloat = c(0.0, 6.0)
+    )
+    expect_error(sdf$change_domain(new_domain))
+
+    new_domain <- list(
+        soma_joinid = c(1, 4),
+        mystring = NULL,
+        myint = c(20, 40),
+        myfloat = c(0.0, 6.0)
+    )
+    expect_error(sdf$change_domain(new_domain))
+
+    new_domain <- list(
+        soma_joinid = c(1, 4),
+        mystring = NULL,
+        myint = c(20, 50),
+        myfloat = c(2.0, 6.0)
+    )
+    expect_error(sdf$change_domain(new_domain))
+
+    # String domain cannot be specified
+    new_domain <- list(
+        soma_joinid = c(1, 4),
+        mystring = c("a", "z"),
+        myint = c(20, 50),
+        myfloat = c(0.0, 6.0)
+    )
+    expect_error(sdf$change_domain(new_domain))
+
+    # All clear
+
+    # String domain cannot be specified
+    new_domain <- list(
+        soma_joinid = c(1, 9),
+        mystring = c("", ""),
+        myint = c(20, 50),
+        myfloat = c(0.0, 10.0)
+    )
+    expect_no_condition(sdf$change_domain(new_domain))
+    sdf$close()
+
+    # Check for success
+    sdf <- SOMADataFrameOpen(uri, "WRITE")
+    dom <- sdf$domain()
+    expect_equal(sdf$domain(), new_domain)
+    sdf$close()
+})
+
 test_that("SOMASparseNDArray shape", {
   uri <- withr::local_tempdir("soma-sparse-ndarray-shape")
   asch <- create_arrow_schema()