diff --git a/CHANGELOG.md b/CHANGELOG.md index 9618456d..0065c8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION.md b/VERSION.md index 18091983..47b322c9 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.4.0 +3.4.1 diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 5b745e88..1026236d 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -34,6 +34,7 @@ export interface Reaction { deltagerr: number; reversibility: string; stoichiometry: string; + participants?: StoichiometryParticipant[]; status: string; aliases: string[]; ec_numbers: string[]; @@ -821,6 +822,17 @@ function sortDocs( }); } +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; @@ -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; + + if (Array.isArray(record.stoichiometry) || Array.isArray(record._childDocuments_)) { + const children = Array.isArray(record.stoichiometry) + ? record.stoichiometry + : record._childDocuments_ as unknown[]; + const results: Array = []; + let canSortByNestPath = true; + for (const child of children) { + if (!child || typeof child !== 'object') continue; + const c = child as Record; + 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`. @@ -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', @@ -948,17 +1040,16 @@ const CPD_VISIBLE = [ * ``` */ export async function getReactions(opts: SolrQueryOpts = {}): Promise> { + 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, @@ -1073,11 +1164,20 @@ export async function getReactionById(id: string): Promise { 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(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) : '', + }; } /** @@ -1137,22 +1237,23 @@ export async function getCompoundsForReaction(ids: string[]): Promise> { +async function getCompoundsByIdsWithFields(ids: string[], fields: string[]): Promise> { 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(url).then((res) => { - const map = new Map(); - 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(url); + const map = new Map(); + for (const doc of res.docs) { + map.set(doc.id, doc); + } + return map; } /** @@ -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) { diff --git a/package-lock.json b/package-lock.json index 1ccb42a4..0ce8826c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelseed-ui", - "version": "3.4.0", + "version": "3.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelseed-ui", - "version": "3.4.0", + "version": "3.4.1", "dependencies": { "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 5acee4e5..c6c38e26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/unit/api/biochemStoichiometry.test.ts b/tests/unit/api/biochemStoichiometry.test.ts new file mode 100644 index 00000000..b1ef376e --- /dev/null +++ b/tests/unit/api/biochemStoichiometry.test.ts @@ -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) { + 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, 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='); + }); +}); diff --git a/tests/unit/api/biochemThermo.test.ts b/tests/unit/api/biochemThermo.test.ts index 9ecda569..3fcbc9ef 100644 --- a/tests/unit/api/biochemThermo.test.ts +++ b/tests/unit/api/biochemThermo.test.ts @@ -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 () => {