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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [3.4.1] - 2026-08-27

### Fixed
- Reaction quick-search no longer queries Solr 9 nested stoichiometry child paths as parent fields, and reaction details now return normalized stoichiometry participants.
- Solr 9 compound batch and reverse reaction lookups now restrict results to parent documents.

---

## [3.4.0] - 2026-08-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.4.0
3.4.1
140 changes: 123 additions & 17 deletions lib/api/biochem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface Reaction {
deltagerr: number;
reversibility: string;
stoichiometry: string;
participants?: StoichiometryParticipant[];
status: string;
aliases: string[];
ec_numbers: string[];
Expand Down Expand Up @@ -821,6 +822,17 @@ function sortDocs<T>(
});
}

export interface StoichiometryParticipant {
compound: string;
coefficient: number;
compartment: number;
name: string;
/** true when the participant is consumed (coefficient < 0). */
is_reactant: boolean;
charge?: number;
formula?: string;
}

/** Coerces a Solr thermodynamics child's `energy`/`error` value to a finite number or null. */
function coerceThermodynamicsNumber(value: unknown): number | null {
const raw = Array.isArray(value) ? value[0] : value;
Expand Down Expand Up @@ -867,6 +879,83 @@ export function normalizeThermodynamics(doc: unknown): ThermodynamicsRecord[] {
return results;
}

/**
* Normalizes raw Solr nested or legacy reaction stoichiometry into typed
* participants. Pure and never throws: malformed or missing input yields `[]`.
*/
export function normalizeStoichiometry(doc: unknown): StoichiometryParticipant[] {
if (!doc || typeof doc !== 'object') return [];
const record = doc as Record<string, unknown>;

if (Array.isArray(record.stoichiometry) || Array.isArray(record._childDocuments_)) {
const children = Array.isArray(record.stoichiometry)
? record.stoichiometry
: record._childDocuments_ as unknown[];
const results: Array<StoichiometryParticipant & { nestPath?: number }> = [];
let canSortByNestPath = true;
for (const child of children) {
if (!child || typeof child !== 'object') continue;
const c = child as Record<string, unknown>;
if (typeof c.doc_type === 'string' && c.doc_type !== 'stoichiometry') continue;
if (typeof c.compound !== 'string' || c.compound.length === 0) continue;
const coefficient = coerceThermodynamicsNumber(c.coefficient);
if (coefficient === null) continue;
const nestMatch = typeof c._nest_path_ === 'string'
? /\/stoichiometry#(\d+)$/.exec(c._nest_path_)
: null;
if (!nestMatch) canSortByNestPath = false;
const entry: StoichiometryParticipant & { nestPath?: number } = {
compound: c.compound,
coefficient,
compartment: coerceThermodynamicsNumber(c.compartment) ?? 0,
name: typeof c.participant_name === 'string' && c.participant_name.length > 0
? c.participant_name : c.compound,
is_reactant: typeof c.is_reactant === 'boolean' ? c.is_reactant : coefficient < 0,
nestPath: nestMatch ? Number(nestMatch[1]) : undefined,
};
const charge = coerceThermodynamicsNumber(c.participant_charge);
if (charge !== null) entry.charge = charge;
if (typeof c.participant_formula === 'string' && c.participant_formula.length > 0) entry.formula = c.participant_formula;
results.push(entry);
}
if (canSortByNestPath) results.sort((a, b) => a.nestPath! - b.nestPath!);
return results.map(({ compound, coefficient, compartment, name, is_reactant, charge, formula }) => ({
compound,
coefficient,
compartment,
name,
is_reactant,
...(charge === undefined ? {} : { charge }),
...(formula === undefined ? {} : { formula }),
}));
}

if (typeof record.stoichiometry !== 'string') return [];
const results: StoichiometryParticipant[] = [];
for (const segment of record.stoichiometry.split(';')) {
if (!segment) continue;
const [coefficientRaw, compound = '', compartmentRaw, , nameRaw = ''] = segment.split(':', 5);
const coefficient = coerceThermodynamicsNumber(coefficientRaw);
if (coefficient === null || compound.length === 0) continue;
const name = nameRaw.length >= 2 && nameRaw.startsWith('"') && nameRaw.endsWith('"')
? nameRaw.slice(1, -1) : nameRaw;
results.push({
compound,
coefficient,
compartment: coerceThermodynamicsNumber(compartmentRaw) ?? 0,
name,
is_reactant: coefficient < 0,
});
}
return results;
}

export function serializeStoichiometry(participants: StoichiometryParticipant[]): string {
return participants.map(({ coefficient, compound, compartment, name }) =>
`${coefficient}:${compound}:${compartment}:0:"${name}"`,
).join(';');
}

/**
* Apply MUI column filter items to row objects locally (for APIs that cannot express filters server-side).
* Uses the same operator semantics as Solr-backed biochem when used with `get*FromModelseedApi`.
Expand Down Expand Up @@ -901,6 +990,9 @@ const MIN_WILDCARD_QUERY_LENGTH = 3;
/** Reaction search fields matching legacy `rxn_sFields`. */
const RXN_SEARCH_FIELDS = ['id', 'name', 'status', 'ec_numbers', 'aliases', 'pathways', 'stoichiometry', 'notes'];

/** Solr 9 nested stoichiometry is a child path, not a queryable parent field; querying it yields HTTP 400 "undefined field stoichiometry". */
const RXN_SEARCH_FIELDS_NESTED = RXN_SEARCH_FIELDS.filter((field) => field !== 'stoichiometry');

/** Reaction visible fields matching legacy `rxnOpts.visible`. */
const RXN_VISIBLE = [
'name', 'id', 'definition', 'deltag', 'deltagerr', 'reversibility',
Expand Down Expand Up @@ -948,17 +1040,16 @@ const CPD_VISIBLE = [
* ```
*/
export async function getReactions(opts: SolrQueryOpts = {}): Promise<SolrResponse<Reaction>> {
const nested = await hasNestedSchema('reactions');
const mergedOpts: SolrQueryOpts = {
limit: 25,
offset: 0,
sort: { field: 'id' },
searchFields: RXN_SEARCH_FIELDS,
searchFields: nested ? RXN_SEARCH_FIELDS_NESTED : RXN_SEARCH_FIELDS,
visible: RXN_VISIBLE,
...opts,
};

// Reactions page is intentionally pinned to legacy Solr.
const nested = await hasNestedSchema('reactions');
const queryOpts = nested
? {
...mergedOpts,
Expand Down Expand Up @@ -1073,11 +1164,20 @@ export async function getReactionById(id: string): Promise<Reaction> {
let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=id:${id}`;
const nested = await hasNestedSchema('reactions');
if (nested) {
url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`;
url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}&fl=${encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]')}`;
}
const res = await fetchSolr<Reaction>(url);
const raw = res.docs[0];
return raw ? { ...raw, thermodynamics: normalizeThermodynamics(raw) } : raw;
if (!raw) return raw;
const participants = normalizeStoichiometry(raw);
return {
...raw,
thermodynamics: normalizeThermodynamics(raw),
participants,
stoichiometry: typeof raw.stoichiometry === 'string'
? raw.stoichiometry
: participants.length > 0 ? serializeStoichiometry(participants) : '',
};
}

/**
Expand Down Expand Up @@ -1137,22 +1237,23 @@ export async function getCompoundsForReaction(ids: string[]): Promise<Map<string
return getCompoundsByIdsWithFields(ids, ['id', 'name', 'formula', 'charge', 'smiles', 'inchikey', 'aliases']);
}

function getCompoundsByIdsWithFields(ids: string[], fields: string[]): Promise<Map<string, Compound>> {
async function getCompoundsByIdsWithFields(ids: string[], fields: string[]): Promise<Map<string, Compound>> {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
if (uniqueIds.length === 0) return Promise.resolve(new Map());
if (uniqueIds.length === 0) return new Map();

const idQuery = uniqueIds.map((id) => `id:${id}`).join(' OR ');
const fl = fields.join(',');
// Batch ID fetch is currently Solr-backed for both modes.
const url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`;

return fetchSolr<Compound>(url).then((res) => {
const map = new Map<string, Compound>();
for (const doc of res.docs) {
map.set(doc.id, doc);
}
return map;
});
let url = `${solrCorpusEndpoint('compounds')}/select?wt=json&q=(${idQuery})&rows=${uniqueIds.length}&fl=${fl}`;
if (await hasNestedSchema('compounds')) {
url += `&fq=${encodeURIComponent(parentDocTypeFilter('compounds'))}`;
}
const res = await fetchSolr<Compound>(url);
const map = new Map<string, Compound>();
for (const doc of res.docs) {
map.set(doc.id, doc);
}
return map;
}

/**
Expand Down Expand Up @@ -1180,7 +1281,12 @@ export async function findReactionsForCompound(
const sort = opts.sort;

// Reverse compound lookup remains Solr-backed for now.
let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=equation:*${cpdId}*&fl=*`;
const nested = await hasNestedSchema('reactions');
const query = nested && /^[A-Za-z0-9_]+$/.test(cpdId)
? `{!parent which="doc_type:reaction"}doc_type:stoichiometry AND compound:${cpdId}`
: `equation:*${cpdId}*`;
let url = `${solrCorpusEndpoint('reactions')}/select?wt=json&q=${query}&fl=*`;
if (nested) url += `&fq=${encodeURIComponent(parentDocTypeFilter('reactions'))}`;
if (limit) url += `&rows=${limit}`;
if (offset) url += `&start=${offset}`;
if (sort) {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modelseed-ui",
"version": "3.4.0",
"version": "3.4.1",
"private": true,
"scripts": {
"predev": "node scripts/sync-version-from-env.mjs",
Expand Down
125 changes: 125 additions & 0 deletions tests/unit/api/biochemStoichiometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetSolrSchemaCache } from '@/lib/api/solrSchema';

async function loadBiochemApi() {
vi.resetModules();
vi.stubEnv('NEXT_PUBLIC_DEPLOYMENT_MODE', 'staging');
return import('@/lib/api/biochem');
}

function mockFetch(nested: { reactions?: boolean; compounds?: boolean }, doc?: Record<string, unknown>) {
return vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = String(input);
const isProbe = url.includes('rows=0');
const isCompound = url.includes('/compounds_staging/');
const found = isCompound ? nested.compounds : nested.reactions;
const body = isProbe
? { response: { numFound: found ? 1 : 0, start: 0, docs: [] } }
: { response: { numFound: doc ? 1 : 0, start: 0, docs: doc ? [doc] : [] } };
return Promise.resolve(new Response(JSON.stringify(body), { status: 200 }));
});
}

function dataUrl(mock: ReturnType<typeof mockFetch>, index = -1): string {
return String(mock.mock.calls.filter(([input]) => !String(input).includes('rows=0')).at(index)?.[0]);
}

describe('Solr stoichiometry support', () => {
beforeEach(() => resetSolrSchemaCache());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
resetSolrSchemaCache();
});

it('normalizes nested children, optional values, and nest-path order', async () => {
const api = await loadBiochemApi();
const doc = { stoichiometry: [
{ doc_type: 'stoichiometry', compound: 'cpd3', coefficient: '1', compartment: '2', participant_name: 'Three', _nest_path_: '/stoichiometry#2' },
{ doc_type: 'stoichiometry', compound: 'cpd1', coefficient: ['-1.5'], compartment: 0, is_reactant: true, participant_charge: ['-2'], participant_formula: 'H2O', _nest_path_: '/stoichiometry#0' },
{ compound: 'cpd2', coefficient: 1, _nest_path_: '/stoichiometry#1' },
] };
expect(api.normalizeStoichiometry(doc)).toEqual([
{ compound: 'cpd1', coefficient: -1.5, compartment: 0, name: 'cpd1', is_reactant: true, charge: -2, formula: 'H2O' },
{ compound: 'cpd2', coefficient: 1, compartment: 0, name: 'cpd2', is_reactant: false },
{ compound: 'cpd3', coefficient: 1, compartment: 2, name: 'Three', is_reactant: false },
]);
expect(api.normalizeStoichiometry({ stoichiometry: [
{ compound: 'first', coefficient: 1 }, { compound: 'second', coefficient: -1 },
] }).map((p) => p.compound)).toEqual(['first', 'second']);
});

it('parses legacy strings and round-trips them', async () => {
const api = await loadBiochemApi();
const source = '-1.5:cpd00001:0:0:"Water, liquid";2:cpd00002:1:0:"ATP";bad:cpd:0:0:"bad"';
const participants = api.normalizeStoichiometry({ stoichiometry: source });
expect(participants).toEqual([
{ compound: 'cpd00001', coefficient: -1.5, compartment: 0, name: 'Water, liquid', is_reactant: true },
{ compound: 'cpd00002', coefficient: 2, compartment: 1, name: 'ATP', is_reactant: false },
]);
expect(api.serializeStoichiometry(participants)).toBe('-1.5:cpd00001:0:0:"Water, liquid";2:cpd00002:1:0:"ATP"');
});

it('returns [] without throwing for absent, malformed, and garbage children', async () => {
const api = await loadBiochemApi();
for (const doc of [null, undefined, {}, 42, { stoichiometry: [] }, { stoichiometry: [null, 'bad', {}, { compound: '', coefficient: 1 }, { compound: 'ok', coefficient: 'abc' }] }]) {
expect(() => api.normalizeStoichiometry(doc)).not.toThrow();
expect(api.normalizeStoichiometry(doc)).toEqual([]);
}
});

it('fetches nested reaction children and preserves thermodynamics and mapping fields', async () => {
const api = await loadBiochemApi();
const fetchMock = mockFetch({ reactions: true }, {
id: 'rxn00001', has_atom_mapping: true, atom_mapping_data: ['map'],
thermodynamics: [{ doc_type: 'thermodynamics', source_name: 'GC', energy: 4.18, error: 2.24 }],
stoichiometry: [{ doc_type: 'stoichiometry', compound: 'cpd00001', coefficient: -1, compartment: 0, participant_name: 'H2O', _nest_path_: '/stoichiometry#0' }],
});
const result = await api.getReactionById('rxn00001');
const url = dataUrl(fetchMock);
expect(url).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`);
expect(url).toContain(encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]'));
expect(result.participants).toEqual([{ compound: 'cpd00001', coefficient: -1, compartment: 0, name: 'H2O', is_reactant: true }]);
expect(result.stoichiometry).toBe('-1:cpd00001:0:0:"H2O"');
expect(result.thermodynamics).toEqual([{ source_name: 'GC', energy: 4.18, error: 2.24 }]);
expect(result.atom_mapping_data).toEqual(['map']);
expect(result.has_atom_mapping).toBe(true);
});

it('keeps legacy URLs byte-identical and parses legacy participants', async () => {
const api = await loadBiochemApi();
const fetchMock = mockFetch({ reactions: false }, { id: 'rxn00001', stoichiometry: '-1:cpd00001:0:0:"H2O"' });
const reaction = await api.getReactionById('rxn00001');
expect(dataUrl(fetchMock)).toBe('https://staging.modelseed.org/solr/reactions_staging/select?wt=json&q=id:rxn00001');
expect(reaction.stoichiometry).toBe('-1:cpd00001:0:0:"H2O"');
expect(reaction.participants).toHaveLength(1);
await api.getReactions({ filterModel: { items: [], quickFilterValues: ['cpd00001'] } });
expect(dataUrl(fetchMock)).toBe(
`https://staging.modelseed.org/solr/reactions_staging/select?wt=json&fl=name,id,definition,deltag,deltagerr,reversibility,stoichiometry,status,aliases,ec_numbers,is_obsolete,is_transport,ontology,pathways,notes&q=${encodeURIComponent('(id:*cpd00001* OR name:*cpd00001* OR status:*cpd00001* OR ec_numbers:*cpd00001* OR aliases:*cpd00001* OR pathways:*cpd00001* OR stoichiometry:*cpd00001* OR notes:*cpd00001*)')}&rows=25&sort=id asc`,
);
await api.findReactionsForCompound('cpd00002');
expect(dataUrl(fetchMock)).toBe('https://staging.modelseed.org/solr/reactions_staging/select?wt=json&q=equation:*cpd00002*&fl=*&rows=25');
});

it('uses parent-only nested quick search, reaction joins, and compound batches', async () => {
const api = await loadBiochemApi();
const fetchMock = mockFetch({ reactions: true, compounds: true });
await api.getReactions({ filterModel: { items: [], quickFilterValues: ['cpd00001'] } });
expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`);
expect(decodeURIComponent(dataUrl(fetchMock))).not.toContain('stoichiometry:');
await api.findReactionsForCompound('cpd00002');
expect(decodeURIComponent(dataUrl(fetchMock))).toContain('{!parent which="doc_type:reaction"}doc_type:stoichiometry AND compound:cpd00002');
expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`);
await api.findReactionsForCompound('cpd*');
expect(decodeURIComponent(dataUrl(fetchMock))).toContain('q=equation:*cpd**');
await api.getCompoundsByIds(['cpd00001']);
expect(dataUrl(fetchMock)).toContain(`fq=${encodeURIComponent('doc_type:compound')}`);
});

it('leaves legacy compound batches unfiltered', async () => {
const api = await loadBiochemApi();
const fetchMock = mockFetch({ compounds: false });
await api.getCompoundsByIds(['cpd00001']);
expect(dataUrl(fetchMock)).not.toContain('fq=');
});
});
4 changes: 3 additions & 1 deletion tests/unit/api/biochemThermo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ describe('getReactionById / getCompoundById thermodynamics', () => {
const dataCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('rows=0'));
const dataUrl = String(dataCall?.[0]);
expect(dataUrl).toContain(`fq=${encodeURIComponent('doc_type:reaction')}`);
expect(dataUrl).toContain(`fl=${encodeURIComponent('*,[child childFilter=doc_type:thermodynamics]')}`);
expect(dataUrl).toContain(`fl=${encodeURIComponent('*,[child childFilter="doc_type:thermodynamics OR doc_type:stoichiometry" limit=200]')}`);
expect(dataUrl).toContain('doc_type%3Astoichiometry');
expect(dataUrl).toContain('limit%3D200');
});

it('drops malformed children and coerces array-wrapped/absent numeric values', async () => {
Expand Down
Loading