From 9a656da4e737767ab42efc5b0ec117b2785cd641 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Thu, 11 Jun 2026 16:02:27 +0100 Subject: [PATCH 1/5] replace deprecated zod passthrough with looseObject --- packages/core/src/schemas/index.ts | 35 ++++++++++-------------- packages/layers/src/spatialLayerProps.ts | 5 ++-- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 5f16a137..770764b8 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -290,10 +290,9 @@ export type NgffImage = z.infer; * - 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; @@ -302,7 +301,7 @@ export type SpatialDataAttrs = z.infer; * 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({ @@ -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({ @@ -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) @@ -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; @@ -432,13 +427,12 @@ export type ShapesAttrs = z.infer; * 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; @@ -446,13 +440,12 @@ export type PointsAttrs = z.infer; * Schema for anndata table metadata */ export const tableAttrsSchema = z - .object({ + .looseObject({ instance_key: z.string(), region: z.union([z.string(), z.array(z.string())]), region_key: z.string(), 'spatialdata-encoding-type': z.literal('ngff:regions_table'), - }) - .passthrough(); + }); export type TableAttrs = z.infer; diff --git a/packages/layers/src/spatialLayerProps.ts b/packages/layers/src/spatialLayerProps.ts index 0aa7949b..d7a64ea9 100644 --- a/packages/layers/src/spatialLayerProps.ts +++ b/packages/layers/src/spatialLayerProps.ts @@ -89,13 +89,12 @@ export type SpatialLayerProps = z.infer; /** 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): SpatialLayerProps { const sublayersIn = raw.sublayers ?? []; From 7fa22ee557d349cc729b2ca8aecd0ddd977cb4b1 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Thu, 11 Jun 2026 17:07:48 +0100 Subject: [PATCH 2/5] getTableKeys is now only a method on table, and schema entries are optional --- packages/core/src/models/index.ts | 42 ++++++++++++++---------------- packages/core/src/schemas/index.ts | 6 ++--- packages/core/src/store/index.ts | 3 +-- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index b2202be0..99b5149c 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -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) // ============================================ @@ -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, + }; } /** diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 770764b8..2ef35408 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -441,9 +441,9 @@ export type PointsAttrs = z.infer; */ export const tableAttrsSchema = z .looseObject({ - instance_key: z.string(), - region: z.union([z.string(), z.array(z.string())]), - region_key: z.string(), + instance_key: z.string().optional(), + region: z.union([z.string(), z.array(z.string())]).optional(), + region_key: z.string().optional(), 'spatialdata-encoding-type': z.literal('ngff:regions_table'), }); diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index b1c277d1..1fd26c32 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -10,7 +10,6 @@ import { serializeZarrTree, } from 'zarrextra'; import { - getTableKeys, loadElements, type ElementInstanceMap, type SpatialElement, @@ -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)); }); } From f063fb5bebe3fc3f85ab8502ee170c7f706ef917 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Thu, 11 Jun 2026 17:11:42 +0100 Subject: [PATCH 3/5] table attrs are nullable (not just optional) --- packages/core/src/schemas/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 2ef35408..f391eb5a 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -441,9 +441,9 @@ export type PointsAttrs = z.infer; */ export const tableAttrsSchema = z .looseObject({ - instance_key: z.string().optional(), - region: z.union([z.string(), z.array(z.string())]).optional(), - region_key: z.string().optional(), + 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'), }); From 59798cc6eced5836a6f1a5aa016f206ee61ab01a Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Thu, 11 Jun 2026 17:13:04 +0100 Subject: [PATCH 4/5] update docs and tests --- docs/docs/core/elements.mdx | 27 ++++++-- docs/docs/core/overview.mdx | 8 ++- packages/core/tests/schemas.spec.ts | 11 ++++ packages/core/tests/tableAssociations.spec.ts | 61 +++++++++++++------ 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/docs/docs/core/elements.mdx b/docs/docs/core/elements.mdx index cb074fd3..dd1ee204 100644 --- a/docs/docs/core/elements.mdx +++ b/docs/docs/core/elements.mdx @@ -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 @@ -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: diff --git a/docs/docs/core/overview.mdx b/docs/docs/core/overview.mdx index 35c8b1de..5d79c858 100644 --- a/docs/docs/core/overview.mdx +++ b/docs/docs/core/overview.mdx @@ -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. diff --git a/packages/core/tests/schemas.spec.ts b/packages/core/tests/schemas.spec.ts index b65dfece..6ab2268d 100644 --- a/packages/core/tests/schemas.spec.ts +++ b/packages/core/tests/schemas.spec.ts @@ -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', () => { diff --git a/packages/core/tests/tableAssociations.spec.ts b/packages/core/tests/tableAssociations.spec.ts index ac1ede00..1f593e8e 100644 --- a/packages/core/tests/tableAssociations.spec.ts +++ b/packages/core/tests/tableAssociations.spec.ts @@ -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, @@ -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: {}, @@ -72,16 +84,10 @@ 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', @@ -89,19 +95,31 @@ describe('getTableKeys', () => { }); 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', () => { @@ -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', () => { From 43815024a34e394b41b6aa845ca7f55daaf9cca1 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Thu, 11 Jun 2026 17:17:12 +0100 Subject: [PATCH 5/5] add changeset --- .changeset/proud-candies-smell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/proud-candies-smell.md diff --git a/.changeset/proud-candies-smell.md b/.changeset/proud-candies-smell.md new file mode 100644 index 00000000..6e47cb35 --- /dev/null +++ b/.changeset/proud-candies-smell.md @@ -0,0 +1,5 @@ +--- +"@spatialdata/core": minor +--- + +Fix schema to allow for tables without association to spatial elements.