diff --git a/app/(reference-data)/biochem/compounds/[id]/page.tsx b/app/(reference-data)/biochem/compounds/[id]/page.tsx index 8d2a1b94..f09aebf6 100644 --- a/app/(reference-data)/biochem/compounds/[id]/page.tsx +++ b/app/(reference-data)/biochem/compounds/[id]/page.tsx @@ -467,9 +467,6 @@ export default function CompoundDetailPage() { - {cpd.ontology && cpd.ontology !== 'class:null|context:null' && ( - {cpd.ontology} - )} {cpd.source ?? 'N/A'} diff --git a/app/(reference-data)/biochem/reactions/[id]/page.tsx b/app/(reference-data)/biochem/reactions/[id]/page.tsx index ff9be792..3012e99a 100644 --- a/app/(reference-data)/biochem/reactions/[id]/page.tsx +++ b/app/(reference-data)/biochem/reactions/[id]/page.tsx @@ -426,13 +426,6 @@ export default function ReactionDetailPage() { - {rxn.ontology && rxn.ontology !== 'class:null|context:null|step:null' && ( - - - {rxn.ontology} - - - )} diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 60a887dc..5e8aa274 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -12,6 +12,7 @@ import { CPD_IMG_BASE, MODELSEED_API_URL, + SOLR_COMPOUNDS_COLLECTION, solrCorpusEndpoint, } from './config'; import { hasNestedSchema, parentDocTypeFilter } from './solrSchema'; @@ -114,7 +115,6 @@ export interface Reaction { ec_numbers: string[]; is_obsolete: string; is_transport: boolean; - ontology: string; pathways: string[]; notes: string[]; abbreviation?: string; @@ -146,7 +146,6 @@ export interface Compound { deltagerr: number; abbreviation: string; aliases: string[]; - ontology: string; inchikey?: string; smiles?: string; is_cofactor?: boolean; @@ -461,7 +460,13 @@ function buildQuickSearchClause( if (nestedStoichiometryQuickSearch) { const wildcard = usePrefixOnly ? `${token}*` : `*${token}*`; fieldClauses.push( - `({!parent which="${parentDocTypeFilter('reactions')}" v="doc_type:stoichiometry AND (compound:${wildcard} OR participant_name:${wildcard} OR participant_aliases:${wildcard} OR aliases:${wildcard})"})`, + // Participant aliases are NOT denormalised onto the stoichiometry children. + // They are read from the compounds core at query time with a cross-core + // join, so editing a compound's aliases takes effect without reposting + // every reaction that uses it. fromIndex must name the compounds core for + // THIS environment -- compounds_staging from reactions_staging -- or the + // clause silently resolves against the wrong dataset instead of erroring. + `({!parent which="${parentDocTypeFilter('reactions')}" v="doc_type:stoichiometry AND (compound:${wildcard} OR participant_name:${wildcard} OR {!join from=id to=compound fromIndex=${SOLR_COMPOUNDS_COLLECTION}}aliases:${wildcard})"})`, ); } if (nestedThermoEvidenceQuickSearch) { @@ -519,11 +524,7 @@ function buildSolrUrl(collection: BiochemCollection, opts: SolrQueryOpts = {}): url += `&fq=${encodeURIComponent(fq)}`; } - // Filter out ontology field for compounds (Solr compounds_staging has no ontology field) - const filterItems = (filterModel?.items ?? []).filter(item => { - const field = toSolrField(String(item.field ?? '')); - return !(collection === 'compounds' && field === 'ontology'); - }); + const filterItems = filterModel?.items ?? []; const filterClauses = filterItems .map((item) => buildFilterClause(item)) @@ -799,8 +800,6 @@ function matchesFilterItem( const field = toSolrField(String(item.field ?? '')); if (!field || !operator) return true; - /* Solr compounds_staging has no ontology query field — REST payloads typically omit it too. */ - if (endpoint === 'compounds' && field === 'ontology') return true; const rawField = doc[field]; const fieldValue = normalizeFieldValue(rawField); @@ -1157,7 +1156,7 @@ const RXN_SEARCH_FIELDS_NESTED = RXN_SEARCH_FIELDS.filter((field) => field !== ' const RXN_VISIBLE = [ 'name', 'id', 'definition', 'reversibility', 'thermo_evidence', 'stoichiometry', 'status', 'aliases', 'ec_numbers', 'is_obsolete', - 'is_transport', 'ontology', 'pathways', 'notes', + 'is_transport', 'pathways', 'notes', ]; // Solr child transformers only return fields also present in the parent `fl` list. // Include the stored stoichiometry fields needed by Equation highlighting before @@ -1165,15 +1164,11 @@ const RXN_VISIBLE = [ const RXN_VISIBLE_NESTED = [ ...RXN_VISIBLE, 'compound', 'coefficient', 'compartment', 'is_reactant', 'participant_name', - 'participant_aliases', 'aliases', 'grade', 'doc_type', '_nest_path_', + 'grade', 'doc_type', '_nest_path_', '[child childFilter="doc_type:stoichiometry OR doc_type:thermo_evidence OR doc_type:thermo-evidence" limit=200]', ]; -/** - * Compound quick-search fields — must exist on Solr `compounds_staging`. - * Note: Solr does not expose an `ontology` field on compounds; querying it yields 400 - * ("undefined field ontology") and breaks the whole quick-search clause. - */ +/** Compound quick-search fields — every entry must exist on the Solr core. */ const CPD_SEARCH_FIELDS = ['id', 'name', 'formula', 'synonyms', 'aliases']; /** Compound visible fields matching Solr `compounds_staging` stored fields (see fl=). */ diff --git a/tests/unit/api/biochem-rest-filtering.test.ts b/tests/unit/api/biochem-rest-filtering.test.ts index e569b678..e69805a8 100644 --- a/tests/unit/api/biochem-rest-filtering.test.ts +++ b/tests/unit/api/biochem-rest-filtering.test.ts @@ -98,24 +98,6 @@ describe('biochem REST path local filter/sort/pagination', () => { expect(String(fetchMock.mock.calls[0]?.[0] ?? '')).toContain('limit=5000'); }); - it('ignores ontology column filters on compound REST payloads (Solr has no ontology field)', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [{ id: 'cpd00001', name: 'ATP', formula: '', aliases: [] }], - }); - vi.stubGlobal('fetch', fetchMock); - - const biochem = await import('@/lib/api/biochem'); - const res = await biochem.getCompoundsFromModelseedApi({ - limit: 10, - filterModel: { - items: [{ field: 'ontology', operator: 'contains', value: 'nope-miss' }], - quickFilterValues: [], - }, - }); - expect(res.docs).toHaveLength(1); - }); - it('refines quick search across compound fields locally (OR across searchFields)', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/tests/unit/api/biochem.test.ts b/tests/unit/api/biochem.test.ts index bb102e7b..f982041b 100644 --- a/tests/unit/api/biochem.test.ts +++ b/tests/unit/api/biochem.test.ts @@ -118,7 +118,7 @@ describe('getCompounds Solr query shape', () => { vi.restoreAllMocks(); }); - it('quick search must not reference ontology (undefined field on compounds_staging)', async () => { + it('quick search only references fields that exist on the compounds core', async () => { const biochemApi = await loadBiochemApi(); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve( @@ -143,7 +143,14 @@ describe('getCompounds Solr query shape', () => { const qRaw = u.searchParams.get('q'); expect(qRaw).toBeTruthy(); const q = decodeURIComponent(qRaw ?? ''); - expect(q).not.toMatch(/\bontology\b/i); + // Every field: token in q must be one the core actually defines. Solr answers an + // undefined field in q with a hard 400, and a 400 carries no CORS header, so the + // browser reports a CORS failure and points at the wrong layer. Three fields have + // reached production this way. + const COMPOUND_CORE_FIELDS = ['id', 'name', 'formula', 'synonyms', 'aliases']; + const queried = [...q.matchAll(/(\w+):/g)].map((mm) => mm[1]); + const unknown = [...new Set(queried)].filter((f) => !COMPOUND_CORE_FIELDS.includes(f)); + expect(unknown).toEqual([]); expect(q).toContain('cpd05323'); expect(q).toMatch(/formula|aliases|name|id/); }); diff --git a/tests/unit/api/biochemStoichiometry.test.ts b/tests/unit/api/biochemStoichiometry.test.ts index e2a544db..85bef740 100644 --- a/tests/unit/api/biochemStoichiometry.test.ts +++ b/tests/unit/api/biochemStoichiometry.test.ts @@ -124,9 +124,9 @@ describe('Solr stoichiometry support', () => { expect(nestedUrl.searchParams.get('fl')).toBe([ 'name', 'id', 'definition', 'reversibility', 'thermo_evidence', 'stoichiometry', 'status', 'aliases', 'ec_numbers', 'is_obsolete', - 'is_transport', 'ontology', 'pathways', 'notes', + 'is_transport', 'pathways', 'notes', 'compound', 'coefficient', 'compartment', 'is_reactant', 'participant_name', - 'participant_aliases', 'aliases', 'grade', 'doc_type', '_nest_path_', + 'grade', 'doc_type', '_nest_path_', '[child childFilter="doc_type:stoichiometry OR doc_type:thermo_evidence OR doc_type:thermo-evidence" limit=200]', ].join(',')); expect(nested.docs[0].participants).toEqual([{ @@ -150,7 +150,7 @@ describe('Solr stoichiometry support', () => { expect(reaction.participants).toHaveLength(1); await api.getReactions({ filterModel: { items: [], quickFilterValues: ['cpd05331'] } }); const legacyListUrl = new URL(dataUrl(fetchMock)); - expect(legacyListUrl.searchParams.get('fl')).toBe('name,id,definition,reversibility,thermo_evidence,stoichiometry,status,aliases,ec_numbers,is_obsolete,is_transport,ontology,pathways,notes'); + expect(legacyListUrl.searchParams.get('fl')).toBe('name,id,definition,reversibility,thermo_evidence,stoichiometry,status,aliases,ec_numbers,is_obsolete,is_transport,pathways,notes'); expect(legacyListUrl.searchParams.get('q')).toBe('(id:*cpd05331* OR name:*cpd05331* OR definition:*cpd05331* OR reversibility:*cpd05331* OR status:*cpd05331* OR ec_numbers:*cpd05331* OR aliases:*cpd05331* OR pathways:*cpd05331* OR stoichiometry:*cpd05331* OR notes:*cpd05331*)'); expect(legacyListUrl.searchParams.get('sort')).toBe('id asc'); await api.getReactions({ filterModel: { items: [], quickFilterValues: ['Glucoraphanin'] } }); @@ -176,8 +176,8 @@ describe('Solr stoichiometry support', () => { expect(nestedQuery).toContain('name:*Glucoraphanin*'); expect(nestedQuery).toContain('definition:*Glucoraphanin*'); expect(nestedQuery).not.toContain('stoichiometry:*'); - expect(nestedQuery).toContain('({!parent which="doc_type:reaction" v="doc_type:stoichiometry AND (compound:*Glucoraphanin* OR participant_name:*Glucoraphanin* OR participant_aliases:*Glucoraphanin* OR aliases:*Glucoraphanin*)"})'); - expect(nestedQuery).toContain('({!parent which="doc_type:reaction" v="doc_type:stoichiometry AND (compound:*cpd05331* OR participant_name:*cpd05331* OR participant_aliases:*cpd05331* OR aliases:*cpd05331*)"})'); + expect(nestedQuery).toContain('({!parent which="doc_type:reaction" v="doc_type:stoichiometry AND (compound:*Glucoraphanin* OR participant_name:*Glucoraphanin* OR {!join from=id to=compound fromIndex=compounds_staging}aliases:*Glucoraphanin*)"})'); + expect(nestedQuery).toContain('({!parent which="doc_type:reaction" v="doc_type:stoichiometry AND (compound:*cpd05331* OR participant_name:*cpd05331* OR {!join from=id to=compound fromIndex=compounds_staging}aliases:*cpd05331*)"})'); expect(nestedQuery).toContain(') AND ('); await api.findReactionsForCompound('cpd00002'); expect(new URL(dataUrl(fetchMock)).searchParams.get('q')).toContain('{!parent which="doc_type:reaction" v="doc_type:stoichiometry AND compound:cpd00002"}'); @@ -237,7 +237,6 @@ describe('reaction request regression matrix', () => { await api.getReactions({ filterModel: { items: [], quickFilterValues: ['RegistryAlias'] } }); const query = new URL(dataUrl(fetchMock)).searchParams.get('q') ?? ''; - expect(query).toContain('participant_aliases:*RegistryAlias*'); expect(query).toContain('aliases:*RegistryAlias*'); expect(query).toContain('participant_name:*RegistryAlias*'); expect(query).toContain('compound:*RegistryAlias*');