From f22df766a582c942bea63194f5b18209725ace88 Mon Sep 17 00:00:00 2001 From: Sam Seaver Date: Wed, 16 Sep 2026 12:34:28 -0500 Subject: [PATCH 1/2] Remove ontology; point participant search at the field that now exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ontology is gone from the biochemistry JSON and from both Solr schemas, but the UI still carried it: two type declarations, two special cases that existed only to filter it out of compound queries, a detail row on each of the reaction and compound pages, an entry in the reaction fl list, a comment explaining the trap, and two tests asserting the workaround. All removed. A field that no longer exists anywhere should not leave nine references behind to be rediscovered. The ontology guard test is kept but generalised. It asserted "q must not contain ontology", which protects against exactly one field; it now extracts every field: token from the generated query and asserts each is one the core defines. That would have caught ontology, stoichiometry and participant_aliases alike -- three fields that have reached production as undefined-field 400s, each fixed individually and none of them prevented. participant_aliases now exists: the compiler emits it on every stoichiometry child and the schema declares it (ModelSEEDDatabase 0cd43867), so the join clause is restored and the aliases clause beside it is dropped. That one matched nothing -- child documents never carried aliases -- and its presence made the gap look like a working fallback. Verified against the live cores rather than mocks: the exact query the browser sends returns 200 on both, and ChEBI 30616, which is not ATP's name, not its ModelSEED id and not in any reaction field, now finds 4,702 reactions where it previously found none. 423 unit tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../biochem/compounds/[id]/page.tsx | 3 --- .../biochem/reactions/[id]/page.tsx | 7 ------ lib/api/biochem.ts | 22 +++++-------------- tests/unit/api/biochem-rest-filtering.test.ts | 18 --------------- tests/unit/api/biochem.test.ts | 11 ++++++++-- tests/unit/api/biochemStoichiometry.test.ts | 11 +++++----- 6 files changed, 19 insertions(+), 53 deletions(-) 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..7e73f7a1 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -114,7 +114,6 @@ export interface Reaction { ec_numbers: string[]; is_obsolete: string; is_transport: boolean; - ontology: string; pathways: string[]; notes: string[]; abbreviation?: string; @@ -146,7 +145,6 @@ export interface Compound { deltagerr: number; abbreviation: string; aliases: string[]; - ontology: string; inchikey?: string; smiles?: string; is_cofactor?: boolean; @@ -461,7 +459,7 @@ 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})"})`, + `({!parent which="${parentDocTypeFilter('reactions')}" v="doc_type:stoichiometry AND (compound:${wildcard} OR participant_name:${wildcard} OR participant_aliases:${wildcard})"})`, ); } if (nestedThermoEvidenceQuickSearch) { @@ -519,11 +517,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 +793,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 +1149,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 +1157,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_', + 'participant_aliases', '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..5884addc 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_', + 'participant_aliases', '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 participant_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*)"})'); 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*'); From 22991bd7b03ff8c5a38494bca5c6c21a9d565dff Mon Sep 17 00:00:00 2001 From: Sam Seaver Date: Wed, 16 Sep 2026 13:27:27 -0500 Subject: [PATCH 2/2] Read participant aliases by cross-core join, not a denormalised field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching reactions by a participating compound's alias now reads the aliases from the compounds core at query time: {!join from=id to=compound fromIndex=${SOLR_COMPOUNDS_COLLECTION}}aliases:X inside the existing stoichiometry block join, replacing a participant_aliases field copied onto all 262,346 stoichiometry children. Same answers, one copy of the data, and editing a compound's aliases takes effect without reposting the 56,012 reactions that reference it. fromIndex comes from SOLR_COMPOUNDS_COLLECTION so it follows the environment -- compounds_staging when the UI is querying reactions_staging. Getting that wrong resolves the clause against the other environment's data rather than erroring, which is why it is threaded from config instead of written literally. Verified against the live cores, not mocks. The three-way OR unions correctly: L-Glutamine gives 237 reactions by participant name and 266 through the join, so the alias path adds 29 the name search misses; cpd00053 gives 200 through the compound branch alone; ChEBI 30616 gives 4,702 through the join and 0 by name or id. The join is really applied rather than parsed away: remove the {!join} prefix and the same query returns 0, because children have no aliases field. 423 unit tests pass. participant_aliases also leaves the fl list. It was only ever fetched to be read back; the join needs nothing returned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- lib/api/biochem.ts | 11 +++++++++-- tests/unit/api/biochemStoichiometry.test.ts | 6 +++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 7e73f7a1..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'; @@ -459,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})"})`, + // 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) { @@ -1157,7 +1164,7 @@ const RXN_VISIBLE = [ const RXN_VISIBLE_NESTED = [ ...RXN_VISIBLE, 'compound', 'coefficient', 'compartment', 'is_reactant', 'participant_name', - 'participant_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]', ]; diff --git a/tests/unit/api/biochemStoichiometry.test.ts b/tests/unit/api/biochemStoichiometry.test.ts index 5884addc..85bef740 100644 --- a/tests/unit/api/biochemStoichiometry.test.ts +++ b/tests/unit/api/biochemStoichiometry.test.ts @@ -126,7 +126,7 @@ describe('Solr stoichiometry support', () => { 'stoichiometry', 'status', 'aliases', 'ec_numbers', 'is_obsolete', 'is_transport', 'pathways', 'notes', 'compound', 'coefficient', 'compartment', 'is_reactant', 'participant_name', - 'participant_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([{ @@ -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*)"})'); - expect(nestedQuery).toContain('({!parent which="doc_type:reaction" v="doc_type:stoichiometry AND (compound:*cpd05331* OR participant_name:*cpd05331* OR participant_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"}');