Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-candies-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@spatialdata/core": minor
---

Fix schema to allow for tables without association to spatial elements.
27 changes: 23 additions & 4 deletions docs/docs/core/elements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,26 @@ const adata = await table.getAnnDataJS();
const rowIds = await table.loadObsIndex();
const regionColumns = await table.loadObsColumns(['region']);

// Access region annotation metadata
// Normalized association metadata (Python `get_table_keys()` equivalent)
const { region, regionKey, instanceKey } = table.getTableKeys();

// Find tables that annotate a spatial element
const associated = sdata.getAssociatedTable('shapes', 'cell_boundaries');
const [tableName, associatedTable] = associated ?? [];

// Raw attrs are also available; association keys may be absent on some tables
table.attrs.instance_key; // Column name for feature IDs / instance IDs
table.attrs.region; // Element(s) this table annotates
table.attrs.region_key; // Column name linking to region
```

`getTableKeys()` always returns normalized keys: `region` is a string array,
and when association metadata is missing `region` is `[]` while
`regionKey` / `instanceKey` are empty strings. Prefer this method over
reading `table.attrs` directly when matching tables to spatial elements.
`SpatialData.getAssociatedTables()` / `getAssociatedTable()` use the same
normalization and ignore tables without association metadata.

### Experimental extension idea: equivalent region encodings

The SpatialData table contract currently maps each row to a single target
Expand Down Expand Up @@ -271,13 +285,18 @@ boundary rather than in the query layer.

```ts
type TableAttrs = {
instance_key: string;
region: string | string[];
region_key: string;
instance_key?: string | null;
region?: string | string[] | null;
region_key?: string | null;
'spatialdata-encoding-type': 'ngff:regions_table';
};
```

Association metadata is optional at the schema level: some tables only carry
the `ngff:regions_table` encoding marker, and real stores may persist missing
keys as JSON `null`. Use `getTableKeys()` to read the normalized association
contract rather than assuming all three keys are present strings.

## Working with Coordinate Systems

All spatial elements share a common interface for coordinate transformations:
Expand Down
8 changes: 5 additions & 3 deletions docs/docs/core/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ The main entry points for application code are:
| Element classes | `ImageElement`, `ShapesElement`, `LabelsElement`, `PointsElement`, `TableElement` |
| `Result` utilities | `Ok`, `Err`, `isOk`, `isErr`, `unwrap`, `unwrapOr` |
| `getTransformMatrix()` | Convenience function for getting Matrix4 transforms |
| Table association helpers | `loadAssociatedTableFeatureRows`, `loadFeatureRowIndexByFeatureIndex`, `createFeatureTableAlignment` |
| Table association helpers | `TableElement.getTableKeys()`, `SpatialData.getAssociatedTable(s)`, `loadAssociatedTableFeatureRows`, `loadFeatureRowIndexByFeatureIndex`, `createFeatureTableAlignment` |

The table association helpers follow Python `spatialdata` semantics: regions
are matched through `region`, `region_key`, and `instance_key`, with feature ids
coming from SpatialElement instances such as `GeoDataFrame.index` for shapes.
are matched through `region`, `region_key`, and `instance_key` (when present),
with feature ids coming from SpatialElement instances such as
`GeoDataFrame.index` for shapes. `getTableKeys()` normalizes the attrs contract
and treats missing association metadata as "no region links".
Visual encoders in `@spatialdata/layers` consume the row alignment produced
here rather than reimplementing association rules.

Expand Down
42 changes: 19 additions & 23 deletions packages/core/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,27 +236,6 @@ export type TableKeys = {
instanceKey: string;
};

type TableKeysInput = TableAttrs | { attrs: TableAttrs };

function getTableAttrs(input: TableKeysInput): TableAttrs {
const attrs = (input as { attrs?: TableAttrs }).attrs;
return attrs ?? (input as TableAttrs);
}

/**
* Equivalent of SpatialData's Python-side `get_table_keys()`.
* Returns normalized table association metadata, always exposing `region`
* as an array for easier downstream matching.
*/
export function getTableKeys(input: TableKeysInput): TableKeys {
const attrs = getTableAttrs(input);
return {
region: Array.isArray(attrs.region) ? attrs.region : [attrs.region],
regionKey: attrs.region_key,
instanceKey: attrs.instance_key,
};
}

// ============================================
// Table Element (non-spatial)
// ============================================
Expand Down Expand Up @@ -297,10 +276,27 @@ export class TableElement extends AbstractElement<'tables'> {
}

/**
* Return the normalized association keys for this table.
* Equivalent of SpatialData's Python-side `get_table_keys()`.
* Returns normalized table association metadata, always exposing `region`
* as an array for easier downstream matching.
*
* When the table has no region association metadata, `region` is an empty
* array and `regionKey` / `instanceKey` are empty strings (subject to revision).
*/
getTableKeys(): TableKeys {
return getTableKeys(this);
const { region, region_key, instance_key } = this.attrs;
if (!region || !region_key || !instance_key) {
return {
region: [],
regionKey: '',
instanceKey: '',
};
}
return {
region: Array.isArray(region) ? region : [region],
regionKey: region_key,
instanceKey: instance_key,
};
}

/**
Expand Down
41 changes: 17 additions & 24 deletions packages/core/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,9 @@ export type NgffImage = z.infer<typeof imageSchema>;
* - For shapes/points: `version` is the spatialdata format version (e.g., '0.1', '0.2') and IS used for format detection.
*/
export const spatialDataAttrsSchema = z
.object({
.looseObject({
version: z.string(),
})
.passthrough(); // allow extra fields we don't validate yet
}); // allow extra fields we don't validate yet

export type SpatialDataAttrs = z.infer<typeof spatialDataAttrsSchema>;

Expand All @@ -302,7 +301,7 @@ export type SpatialDataAttrs = z.infer<typeof spatialDataAttrsSchema>;
* Uses OME-NGFF 0.4 format with multiscales at the top level
*/
const rasterAttrs_OME_04_Schema = z
.object({
.looseObject({
multiscales: z
.array(
z.object({
Expand All @@ -322,17 +321,16 @@ const rasterAttrs_OME_04_Schema = z
.min(1),
omero: omeroSchema.optional(),
spatialdata_attrs: spatialDataAttrsSchema.optional(),
})
.passthrough();
});

/**
* Schema for raster element attrs in spatialdata 0.6.1+ format
* Uses OME-NGFF 0.5 format with multiscales nested under 'ome' key
*/
const rasterAttrs_OME_05_Schema = z
.object({
.looseObject({
ome: z
.object({
.looseObject({
multiscales: z
.array(
z.object({
Expand All @@ -351,11 +349,9 @@ const rasterAttrs_OME_05_Schema = z
)
.min(1),
omero: omeroSchema.optional(),
})
.passthrough(),
}),
spatialdata_attrs: spatialDataAttrsSchema.optional(),
})
.passthrough();
});

/**
* Schema for raster element attrs (images & labels)
Expand Down Expand Up @@ -417,13 +413,12 @@ export type RasterAttrs = {
* Transformations are at the top level with input/output coordinate system references.
*/
export const shapesAttrsSchema = z
.object({
.looseObject({
'encoding-type': z.string().optional(), // e.g., 'ngff:shapes'
axes: z.array(z.string()).optional(), // e.g., ['x', 'y']
coordinateTransformations: coordinateTransformationSchema.optional(),
spatialdata_attrs: spatialDataAttrsSchema.optional(),
})
.passthrough();
});

export type ShapesAttrs = z.infer<typeof shapesAttrsSchema>;

Expand All @@ -432,27 +427,25 @@ export type ShapesAttrs = z.infer<typeof shapesAttrsSchema>;
* Transformations are at the top level with input/output coordinate system references.
*/
export const pointsAttrsSchema = z
.object({
.looseObject({
'encoding-type': z.string().optional(), // e.g., 'ngff:points'
axes: z.array(z.string()).optional(), // e.g., ['x', 'y']
coordinateTransformations: coordinateTransformationSchema.optional(),
spatialdata_attrs: spatialDataAttrsSchema.optional(),
})
.passthrough();
});

export type PointsAttrs = z.infer<typeof pointsAttrsSchema>;

/**
* Schema for anndata table metadata
*/
export const tableAttrsSchema = z
.object({
instance_key: z.string(),
region: z.union([z.string(), z.array(z.string())]),
region_key: z.string(),
.looseObject({
instance_key: z.string().optional().nullable(),
region: z.union([z.string(), z.array(z.string())]).optional().nullable(),
region_key: z.string().optional().nullable(),
'spatialdata-encoding-type': z.literal('ngff:regions_table'),
})
.passthrough();
});

export type TableAttrs = z.infer<typeof tableAttrsSchema>;

Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
serializeZarrTree,
} from 'zarrextra';
import {
getTableKeys,
loadElements,
type ElementInstanceMap,
type SpatialElement,
Expand Down Expand Up @@ -167,7 +166,7 @@ export class SpatialData {
}
const candidates = elementPathCandidates(kind, key);
return Object.entries(this.tables).filter(([, table]) => {
const { region } = getTableKeys(table);
const { region } = table.getTableKeys();
return region.some((regionName) => candidates.has(regionName));
});
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/tests/schemas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,17 @@ describe('Schema Transformations', () => {

expect(() => tableAttrsSchema.parse(attrs)).not.toThrow();
});

it('should accept null association keys from real stores', () => {
const attrs = {
instance_key: null,
region: null,
region_key: null,
'spatialdata-encoding-type': 'ngff:regions_table',
};

expect(() => tableAttrsSchema.parse(attrs)).not.toThrow();
});
});

describe('spatialDataSchema', () => {
Expand Down
61 changes: 43 additions & 18 deletions packages/core/tests/tableAssociations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { ATTRS_KEY } from 'zarrextra';
import type { ConsolidatedStore } from 'zarrextra';
import { describe, expect, it } from 'vitest';
import { getTableKeys } from '../src/models/index.js';
import { SpatialData } from '../src/store/index.js';
import {
createFeatureTableAlignment,
Expand Down Expand Up @@ -61,6 +60,19 @@ function createMockSpatialData() {
'spatialdata-encoding-type': 'ngff:regions_table',
},
},
orphan_table: {
[ATTRS_KEY]: {
'spatialdata-encoding-type': 'ngff:regions_table',
},
},
null_keys_table: {
[ATTRS_KEY]: {
instance_key: null,
region: null,
region_key: null,
'spatialdata-encoding-type': 'ngff:regions_table',
},
},
},
},
zarritaStore: {},
Expand All @@ -72,36 +84,42 @@ function createMockSpatialData() {
]);
}

describe('getTableKeys', () => {
describe('TableElement.getTableKeys', () => {
it('normalizes a single region to an array', () => {
expect(
getTableKeys({
instance_key: 'cell_id',
region: 'cells',
region_key: 'region',
'spatialdata-encoding-type': 'ngff:regions_table',
})
).toEqual({
const sdata = createMockSpatialData();
expect(sdata.tables!.cells_table.getTableKeys()).toEqual({
instanceKey: 'cell_id',
region: ['cells'],
regionKey: 'region',
});
});

it('preserves multiple regions', () => {
expect(
getTableKeys({
instance_key: 'cell_id',
region: ['cells', 'nuclei'],
region_key: 'region',
'spatialdata-encoding-type': 'ngff:regions_table',
})
).toEqual({
const sdata = createMockSpatialData();
expect(sdata.tables!.multi_region_table.getTableKeys()).toEqual({
instanceKey: 'cell_id',
region: ['cells', 'nuclei'],
regionKey: 'region',
});
});

it('returns empty keys when association metadata is absent', () => {
const sdata = createMockSpatialData();
expect(sdata.tables!.orphan_table.getTableKeys()).toEqual({
instanceKey: '',
region: [],
regionKey: '',
});
});

it('returns empty keys when association metadata is null', () => {
const sdata = createMockSpatialData();
expect(sdata.tables!.null_keys_table.getTableKeys()).toEqual({
instanceKey: '',
region: [],
regionKey: '',
});
});
});

describe('SpatialData table associations', () => {
Expand Down Expand Up @@ -133,6 +151,13 @@ describe('SpatialData table associations', () => {
const sdata = createMockSpatialData();
expect(sdata.getAssociatedTables('shapes', 'missing')).toEqual([]);
});

it('ignores tables without region association metadata', () => {
const sdata = createMockSpatialData();
expect(sdata.getAssociatedTables('shapes', 'cells').map(([name]) => name)).not.toContain(
'orphan_table'
);
});
});

describe('loadAssociatedTableFeatureRows', () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/layers/src/spatialLayerProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,12 @@ export type SpatialLayerProps = z.infer<typeof spatialLayerPropsSchema>;

/** Version 0: pre-schema ad-hoc objects (empty or partial). */
const spatialLayerPropsV0Schema = z
.object({
.looseObject({
schemaVersion: z.never().optional(),
sublayers: z.array(z.unknown()).optional(),
viewMode: z.enum(['2d', '3d']).optional(),
globalTimeIndex: z.number().optional(),
})
.passthrough();
});

function migrateV0ToV1(raw: z.infer<typeof spatialLayerPropsV0Schema>): SpatialLayerProps {
const sublayersIn = raw.sublayers ?? [];
Expand Down
Loading