From 509390c15dd2cf8379319d44a630a5d20cb80afc Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 27 Oct 2026 00:36:37 +0000 Subject: [PATCH 1/2] Spec 016 T013: the search answers in the contract ResultGroup, GroupedResult, DiagramResult, TargetResult and IconResult, each taking its entry type as an argument: the service writes them with the entries it builds, the site and the pathway browser read them with the view. A group's entries and type name are optional, as the service leaves them out at times; the search page settles both once on arrival, and the pathway browser shows only named type facets, as the site does. Solr's grouping without matches is Java's 500, as one without ngroups was. The search routes' known problems are 0 (from 11,452 before spec 016); the whole is 12,164. Co-Authored-By: Claude Opus 5.5 --- projects/contract/search.ts | 46 +++++++++++++++++++ .../description-tab/icon/icon.component.html | 6 ++- .../src/app/model/search.model.ts | 37 +++++---------- .../src/app/services/icon.service.ts | 2 +- .../app/viewport/search/search.component.ts | 10 +++- .../interactor-detail.component.ts | 2 +- .../src/app/search/search.component.ts | 30 ++++++++---- .../website-angular/src/model/icon.model.ts | 7 +-- .../website-angular/src/model/search.model.ts | 23 +++++----- specs/016-api-contract/tasks.md | 5 +- tools/api-types/known-problems.json | 2 - tools/content-node/query-search.spec.ts | 17 +++++++ .../content-node/search-diagram-endpoints.ts | 8 +++- tools/content-node/search-endpoints.ts | 40 ++++++++++------ 14 files changed, 159 insertions(+), 76 deletions(-) diff --git a/projects/contract/search.ts b/projects/contract/search.ts index acc64c54..e2f1cff1 100644 --- a/projects/contract/search.ts +++ b/projects/contract/search.ts @@ -257,3 +257,49 @@ export interface PathwayOfEntry { name?: Highlighted; hasEHLD?: boolean; } + +// -- The search answers ------------------------------------------------------- +// +// Each takes its entry type as an argument: the service writes them with the +// entries it builds, a reader reads them with the view it needs. + +/** One group of results: a type's, or "Results" for a search not grouped. */ +export interface ResultGroup { + /** Left out when the group has none (an ungrouped search that found none). */ + entries?: Entry[]; + /** Left out for a group Solr names with nothing. */ + typeName?: string; + /** How many Solr found. */ + entriesCount: number; + /** How many are written here. */ + rowCount: number; +} + +/** A term the search found nothing for, and whether it names a resource; an empty term or resource is left out. */ +export interface TargetResult { + term?: string; + resource?: string | number | boolean; + target: boolean; +} + +/** `/search/query`: the groups, the rows written, the groups and matches Solr counted. */ +export interface GroupedResult { + results: ResultGroup[]; + rowCount: number; + numberOfGroups: number; + numberOfMatches: number; + /** For a search that found nothing: the terms, and whether each is a target. */ + targetResults?: TargetResult[]; +} + +/** `/search/diagram/{id}` and `/search/fireworks`: the entries, their type facets, Solr's count. */ +export interface DiagramResult { + /** Left out when there are none. */ + entries?: Entry[]; + /** Left out when there are none. */ + facets?: FacetCount[]; + found: number; +} + +/** `/search/icon/query`: the icon library's page, one ungrouped group of icons. */ +export type IconResult = ResultGroup; diff --git a/projects/pathway-browser/src/app/details/tabs/description-tab/icon/icon.component.html b/projects/pathway-browser/src/app/details/tabs/description-tab/icon/icon.component.html index cfe18b91..eba3a375 100644 --- a/projects/pathway-browser/src/app/details/tabs/description-tab/icon/icon.component.html +++ b/projects/pathway-browser/src/app/details/tabs/description-tab/icon/icon.component.html @@ -1,3 +1,7 @@ - +
diff --git a/projects/pathway-browser/src/app/model/search.model.ts b/projects/pathway-browser/src/app/model/search.model.ts index cea3b54a..52828547 100644 --- a/projects/pathway-browser/src/app/model/search.model.ts +++ b/projects/pathway-browser/src/app/model/search.model.ts @@ -1,4 +1,11 @@ -import type { IconEntry, PathwayOfEntry, SearchEntryView } from '../../../../contract/search.ts'; +import type { + DiagramResult, + FacetCount, + GroupedResult, + IconEntry, + PathwayOfEntry, + SearchEntryView, +} from '../../../../contract/search.ts'; export namespace Search { export const EMPTY_RESULTS = { found: 0, facets: [], entries: [] } as Search.Result; @@ -21,17 +28,9 @@ export namespace Search { export type Entry = SearchEntryView; export type PathwayOf = PathwayOfEntry; - export interface Facet { - name: string; - count: number; - } - - // firework and diagram - export interface Result { - entries: Entry[]; - facets: Facet[]; - found: number; - } + // A diagram's or the species overview's search: the contract's (spec 016). + export type Facet = FacetCount; + export type Result = DiagramResult; // Add a sub-namespace for Icon-specific results export namespace Icon { @@ -40,18 +39,6 @@ export namespace Search { // A query for icons answers whatever its groups hold: a query matching // no icon answers proteins, compounds and the like, any kind. - export interface EntryResult { - entries: Search.Entry[]; - typeName: string; - entriesCount: number; - rowCount: number; - } - - export interface Result { - results: EntryResult[]; - rowCount: number; - numberOfGroups: number; - numberOfMatches: number; - } + export type Result = GroupedResult; } } diff --git a/projects/pathway-browser/src/app/services/icon.service.ts b/projects/pathway-browser/src/app/services/icon.service.ts index effcfa73..bf9593b0 100644 --- a/projects/pathway-browser/src/app/services/icon.service.ts +++ b/projects/pathway-browser/src/app/services/icon.service.ts @@ -268,7 +268,7 @@ export class IconService { // No result group at all is no icon, not an error. map((response) => { const [group] = response.results; - const [entry] = group?.typeName === 'Icon' ? group.entries : []; + const [entry] = group?.typeName === 'Icon' ? (group.entries ?? []) : []; return entry && isIcon(entry) ? entry : null; }), switchMap((entry) => { diff --git a/projects/pathway-browser/src/app/viewport/search/search.component.ts b/projects/pathway-browser/src/app/viewport/search/search.component.ts index bef0d4a6..d3703fe8 100644 --- a/projects/pathway-browser/src/app/viewport/search/search.component.ts +++ b/projects/pathway-browser/src/app/viewport/search/search.component.ts @@ -396,7 +396,13 @@ export class SearchComponent { // TODO find a way to avoid the break in scroll when loading a new page export class SearchDataSource extends DataSource { private readonly _result = signal(undefined); - readonly facets = computed(() => this._result()?.facets || []); + // A count Solr gives under no name is written without one: nothing to show + // or filter by, as on the site's search page. + readonly facets = computed(() => + (this._result()?.facets || []).filter( + (facet): facet is Search.Facet & { name: string } => !!facet.name + ) + ); readonly found = computed(() => this._result()?.found || 0); readonly isLoading = signal(false); /** Pages that could not be loaded, until asked for again. */ @@ -484,7 +490,7 @@ export class SearchDataSource extends DataSource { if (!current()) return; this._result.set(result); if (this.cachedEntries.length !== result.found) this.cachedEntries.length = result.found; - this.cachedEntries.splice(page * this.pageSize, this.pageSize, ...result.entries); + this.cachedEntries.splice(page * this.pageSize, this.pageSize, ...(result.entries ?? [])); this.dataStream.next(this.cachedEntries); markFailed(false); this.isLoading.set(false); diff --git a/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts index 5f33504e..79ad2222 100644 --- a/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts +++ b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts @@ -122,7 +122,7 @@ export class InteractorDetailComponent implements OnInit { // The first hit that is a reference: when nothing matches as an // interactor the search answers unfiltered, and an Icon can lead. const entry = search?.results - .flatMap((group) => group.entries) + .flatMap((group) => group.entries ?? []) .find((hit) => hit.databaseName && hit.referenceIdentifier); if (entry) { // The search is highlighted: an identifier or name that matched diff --git a/projects/website-angular/src/app/search/search.component.ts b/projects/website-angular/src/app/search/search.component.ts index 4cd53995..c7119326 100644 --- a/projects/website-angular/src/app/search/search.component.ts +++ b/projects/website-angular/src/app/search/search.component.ts @@ -5,7 +5,8 @@ import { SearchFilters, Facet, NamedFacetCount, - ResultGroup, + ShownGroup, + ShownResult, } from '../../model/search.model'; import { Component, @@ -108,7 +109,7 @@ export class SearchComponent implements OnInit, OnDestroy { this.cdr.markForCheck(); } suggestedTerms: string[] = []; - results: SearchResult | null = null; + results: ShownResult | null = null; facets: FacetResponse | null = null; loading = false; error = ''; @@ -310,7 +311,15 @@ export class SearchComponent implements OnInit, OnDestroy { this.hasNoResults = false; } else { // Successful API response - check if we have results - const res = results as SearchResult; + const answer = results as SearchResult; + const res: ShownResult = { + ...answer, + results: answer.results.map((group) => ({ + ...group, + entries: group.entries ?? [], + typeName: group.typeName ?? '', + })), + }; // Solr's true total across the whole result set; per-group // pagination chops each `group.entries` down to ~30, so we // must NOT recompute this from entries.length later. @@ -391,7 +400,7 @@ export class SearchComponent implements OnInit, OnDestroy { typeName: 'Pages', entries: pageEntries, entriesCount: pageEntries.length, - } as ResultGroup, + } as ShownGroup, ]; } // Keep Solr's biology total; add Pages count (when shown); zero @@ -445,6 +454,7 @@ export class SearchComponent implements OnInit, OnDestroy { return of({ results: [], rowCount: 0, + numberOfGroups: 0, numberOfMatches: 0, }); } @@ -479,7 +489,7 @@ export class SearchComponent implements OnInit, OnDestroy { for (const result of results) { if (!result?.results.length) continue; const group = result.results.find((g) => g.typeName === 'Protein'); - if (group) allProteins.push(...group.entries); + if (group) allProteins.push(...(group.entries ?? [])); } // Group by referenceIdentifier, else the entry's own id (its stId, or @@ -700,18 +710,18 @@ export class SearchComponent implements OnInit, OnDestroy { } // Per-group pagination - getGroupPage(group: ResultGroup): number { + getGroupPage(group: ShownGroup): number { return this.groupPages[group.typeName] || 0; } - getGroupTotalPages(group: ResultGroup): number { + getGroupTotalPages(group: ShownGroup): number { if (group.typeName === 'Protein') { return Math.ceil(this.uniqueProteins.length / this.groupPageSize) || 1; } return Math.ceil(group.entriesCount / this.groupPageSize); } - getGroupPageEntries(group: ResultGroup): SearchEntry[] { + getGroupPageEntries(group: ShownGroup): SearchEntry[] { if (group.typeName === 'Protein') { // While still loading all proteins, show nothing (loading message is displayed) if (this.proteinLoading) return []; @@ -726,7 +736,7 @@ export class SearchComponent implements OnInit, OnDestroy { return this.groupPageEntries[group.typeName] || group.entries.slice(0, this.groupPageSize); } - goToGroupPage(group: ResultGroup, page: number): void { + goToGroupPage(group: ShownGroup, page: number): void { const total = this.getGroupTotalPages(group); if (page < 0 || page >= total) return; @@ -760,7 +770,7 @@ export class SearchComponent implements OnInit, OnDestroy { }); } - getGroupPageNumbers(group: ResultGroup): (number | '...')[] { + getGroupPageNumbers(group: ShownGroup): (number | '...')[] { return this.buildPageNumbers(this.getGroupPage(group), this.getGroupTotalPages(group)); } diff --git a/projects/website-angular/src/model/icon.model.ts b/projects/website-angular/src/model/icon.model.ts index c4757b78..8d8e3231 100644 --- a/projects/website-angular/src/model/icon.model.ts +++ b/projects/website-angular/src/model/icon.model.ts @@ -2,10 +2,5 @@ export type { IconCategory, IconFacetResponse } from '../../../contract/search.t // One icon: the contract's (spec 016). export type { IconEntry } from '../../../contract/search.ts'; -import type { IconEntry } from '../../../contract/search.ts'; -export interface IconResult { - /** Left out when nothing matches. */ - entries?: IconEntry[]; - entriesCount: number; -} +export type { IconResult } from '../../../contract/search.ts'; diff --git a/projects/website-angular/src/model/search.model.ts b/projects/website-angular/src/model/search.model.ts index 209b73fd..89b25447 100644 --- a/projects/website-angular/src/model/search.model.ts +++ b/projects/website-angular/src/model/search.model.ts @@ -6,18 +6,19 @@ export type { SearchEntryView as SearchEntry } from '../../../contract/search.ts'; import type { SearchEntryView } from '../../../contract/search.ts'; -export interface ResultGroup { - typeName: string; - entries: SearchEntryView[]; - entriesCount: number; - rowCount: number; -} +// A search's answer, as the contract has it, with the view's entries. +import type { GroupedResult, ResultGroup as Group } from '../../../contract/search.ts'; +type ResultGroup = Group; +export type SearchResult = GroupedResult; -export interface SearchResult { - results: ResultGroup[]; - rowCount: number; - numberOfMatches: number; -} +/** + * A group as the search page keeps it: its entries and name settled when the + * answer arrives, an absent list as none and an absent name as empty text. + * The answer leaves both out at times (an ungrouped page past the end, a + * group Solr names with nothing); the page reads them everywhere. + */ +export type ShownGroup = ResultGroup & Required>; +export type ShownResult = Omit & { results: ShownGroup[] }; export type { Facet, FacetResponse, NamedFacetCount } from '../../../contract/search.ts'; diff --git a/specs/016-api-contract/tasks.md b/specs/016-api-contract/tasks.md index 7ee1aad3..385378ca 100644 --- a/specs/016-api-contract/tasks.md +++ b/specs/016-api-contract/tasks.md @@ -60,7 +60,7 @@ description: 'Tasks for spec 016: one API contract for the content service and t - [x] T010 [US2] Make the site's `SearchEntry` and `IconEntry` re-export the contract's, and narrow by kind where `search.component.ts`, `interactor-detail.component.ts`, `icon-lib.component.ts` and `icon-detail.component.ts` read kind-specific fields. - Escape or render `Highlighted` fields as each reader does today, with no change visible on the page. - Check the search page and an icon page in a browser. - - Done in #691. The site's `SearchEntry` is the contract's `SearchEntryView`: any kind, what every kind carries required, the rest optional (FR-003's view, since the search page shows every kind side by side and a template cannot read a field one kind lacks); `IconEntry` is the contract's kind. Two readers changed: the protein forms' key drops a fallback `id` already covers (`id` is the stId, or the dbId, never empty), and an icon's image is found by `id`, the same value as `stId` but never highlighted. From #690's review, the icon library too (#691): its search is highlighted, and asking beta's service for `R-ICO-012449` there answers `stId` as `R-ICO-012449`, `id` plain, so its image, link, fetch and shown id were built from markup; they take `id` now, as does the pathway browser's icon load. From #691's review (#697): the search page printed a highlighted reference identifier as text, `PTEN (P60484)` for a search of P60484, seen in the browser against beta's services; it is rendered as its neighbours are (`[innerHTML]`) and reads `PTEN (P60484)`. An icon's alt text and the pathway browser's icon link take `id`, and the interactor page strips a highlighted name as it strips the identifier. Nothing else visible changes; the search, search-error and icon-library end-to-end specs pass (46 and 78 tests with the specs below). The single icon page reads only type changes; no recording opens one (the `gap` T014 closes). + - Done in #691. The site's `SearchEntry` is the contract's `SearchEntryView`: any kind, what every kind carries required, the rest optional (FR-003's view, since the search page shows every kind side by side and a template cannot read a field one kind lacks); `IconEntry` is the contract's kind. Two readers changed: the protein forms' key drops a fallback `id` already covers (`id` is the stId, or the dbId, never empty), and an icon's image is found by `id`, the same value as `stId` but never highlighted. From #690's review, the icon library too (#691): its search is highlighted, and asking beta's service for `R-ICO-012449` there answers `stId` as `R-ICO-012449`, `id` plain, so its image, link, fetch and shown id were built from markup; they take `id` now, as does the pathway browser's icon load. From #691's review (#697): the search page printed a highlighted reference identifier as text, `PTEN (P60484)` for a search of P60484, seen in the browser against beta's services; it is rendered as its neighbours are (`[innerHTML]`) and reads `PTEN (P60484)`. An icon's alt text and the pathway browser's icon link take `id`, and the interactor page strips a highlighted name as it strips the identifier. Nothing else visible changes; the search, search-error and icon-library end-to-end specs passed for #691 (46 and 78 tests with the specs below). The single icon page reads only type changes; no recording opens one (the `gap` T014 closes). - [x] T011 [US2] Make the pathway browser's `Search.Entry` and `Search.Icon.Entry` re-export the contract's, keeping the value `EMPTY_RESULTS` in the namespace. Adjust `viewport/search/search.component.ts`, `services/icon.service.ts` and `details/tabs/description-tab/icon/icon.component.ts`, and check the diagram search in a browser. - Done in #691. `Search.Entry` is the view, `Search.Icon.Entry` the icon kind, and an icon query's groups hold any kind (three of the five recorded answers are proteins, compounds or drugs), so `fetchIcon` takes an entry only when it is an icon, not deleted, besides its group's name. Its unit spec's icon is now one as the search writes it, and a deleted icon in an icon group gives none (failing without the check). The flag button takes `id`. The in-diagram search, flagging, pathway-browser, entity popup, structures and pathway-data specs pass; their recordings answer the icon query with icons (R-ICO-013990, R-ICO-012536) and with other kinds. - [x] T012 [US2] Recount `tools/api-types/known-problems.json`. Record what remains on the search routes, each item with its reason, in this task's PR and here. @@ -72,7 +72,8 @@ description: 'Tasks for spec 016: one API contract for the content service and t **Independent test**: every search route's known problems are at zero or explained. -- [ ] T013 [US3] Write `ResultGroup`, `GroupedResult` (with `numberOfGroups`), the diagram and species-overview result types and `IconResult` (with `rowCount` and `typeName`) in `projects/contract/search.ts`, and point their writers in `search-endpoints.ts` and `search-diagram-endpoints.ts`, and their readers, at them. +- [x] T013 [US3] Write `ResultGroup`, `GroupedResult` (with `numberOfGroups`), the diagram and species-overview result types and `IconResult` (with `rowCount` and `typeName`) in `projects/contract/search.ts`, and point their writers in `search-endpoints.ts` and `search-diagram-endpoints.ts`, and their readers, at them. + - Done in #700. The contract's `ResultGroup`, `GroupedResult`, `DiagramResult`, `TargetResult` and `IconResult`, each taking its entry type as a named argument: the service writes them with its `WrittenEntry`, a reader reads them with the view. A group's `entries` is optional (an ungrouped page past the end has none) and so is its `typeName` (a group Solr names with nothing); the search page settles both once when the answer arrives (`ShownGroup`), and the pathway browser's search shows only named type facets, as the site's does. A target's empty term or resource is left out, so both are optional. Solr's grouping without `matches` is now Java's 500, as one without `ngroups` was (SolrJ unboxes both; failing without the check). The search routes' known problems are 0, the whole 12,164. From #697's review, the pathway browser's icon tooltip takes `iconName`, never highlighted. - [ ] T014 [US3] Record an answer for `/search/icon/{id}` (already in `tools/api-types/calls.ts` as `IconEntry`, with a `gap`) through the e2e suite and remove the `gap` (record rewrites three HARs: check the diff). Type its writer. - [ ] T015 [US3] Tick spec 013 T019 in `specs/013-server-types/tasks.md` with T002–T014's PR numbers. diff --git a/tools/api-types/known-problems.json b/tools/api-types/known-problems.json index abbdd127..b31cca65 100644 --- a/tools/api-types/known-problems.json +++ b/tools/api-types/known-problems.json @@ -15,8 +15,6 @@ "GET /data/query/{id} as { displayName?: string; name?: string[] }": 221, "GET /data/species/main as Species[]": 16, "GET /exporter/reaction/{id}/diagram as ReactionJson": 704, - "GET /search/icon/query as IconResult": 4, - "GET /search/query as SearchResult": 12, "POST /data/orthologies/ids/species/{dbId} as { [p: string]: SelectableObject }": 3, "POST /interactors/psicquic/molecules/{resource}/details as Interactors": 1560 } diff --git a/tools/content-node/query-search.spec.ts b/tools/content-node/query-search.spec.ts index 479a3efa..02531d6b 100644 --- a/tools/content-node/query-search.spec.ts +++ b/tools/content-node/query-search.spec.ts @@ -382,6 +382,23 @@ describe('/query', () => { ]); }); + it("is Java's 500 for a grouping without its count of groups or of matches", async () => { + // SolrJ unboxes both; a missing one is its NullPointerException. + for (const missing of ['ngroups', 'matches']) { + answer = (handler) => { + if (handler.endsWith('/facet')) return facets(8); + const solr = grouped([['Protein', [doc('1', 'TP53')]]]); + delete (solr.grouped.type_facet as Record)[missing]; + return solr; + }; + const response = await get('query?query=TP53'); + expect(response.status, missing).toBe(500); + expect(((await response.json()) as { messages: string[] }).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); + } + }); + it('leaves out the type of a group with none', async () => { answer = (handler) => handler.endsWith('/facet') ? facets(8) : grouped([[null, [doc('1', 'TP53')]]]); diff --git a/tools/content-node/search-diagram-endpoints.ts b/tools/content-node/search-diagram-endpoints.ts index 7fc4d0e4..e4f51d1e 100644 --- a/tools/content-node/search-diagram-endpoints.ts +++ b/tools/content-node/search-diagram-endpoints.ts @@ -10,7 +10,8 @@ import { hashSetOf, javaSplit, javaTrim } from './java-text.ts'; import { longPastRange, resolveId } from './objects.ts'; import { read } from './graph.ts'; import type { SolrAnswer } from './solr.ts'; -import type { FacetContainer } from './search-core.ts'; +import type { FacetContainer, WrittenEntry } from './search-core.ts'; +import type { DiagramResult } from '../../projects/contract/search.ts'; import { OCCURRENCE_READS, blankBoolean, @@ -114,7 +115,10 @@ function typeFacets(answer: SolrAnswer | null | undefined) { */ type EntriesResult = ReturnType; -function entriesResult(answer: SolrAnswer | null | undefined, options = {}) { +function entriesResult( + answer: SolrAnswer | null | undefined, + options = {} +): DiagramResult { const entries = (answer?.response?.docs ?? []).map((doc) => searchEntry(doc, null, options)); const facets = typeFacets(answer); return { diff --git a/tools/content-node/search-endpoints.ts b/tools/content-node/search-endpoints.ts index e628c1de..c9fab8b8 100644 --- a/tools/content-node/search-endpoints.ts +++ b/tools/content-node/search-endpoints.ts @@ -6,8 +6,15 @@ */ import type { Endpoint, Request } from './endpoint.ts'; import type { SolrAnswer, SolrGrouping } from './solr.ts'; -import type { FacetContainer, TargetResult } from './search-core.ts'; -import type { Facet, FacetResponse, IconFacetResponse } from '../../projects/contract/search.ts'; +import type { FacetContainer, TargetResult, WrittenEntry } from './search-core.ts'; +import type { + Facet, + FacetResponse, + GroupedResult, + IconFacetResponse, + ResultGroup, + TargetResult as ContractTarget, +} from '../../projects/contract/search.ts'; import { blankBoolean, facetContainer, @@ -218,7 +225,10 @@ async function facetsOf(query: FacetQuery, forceFilters: boolean, signal: AbortS * "Results", with Solr's count and how many came -- NON_EMPTY leaving out no * entries. `highlight` is false where Java ignores the highlighting. */ -function searchResult(answer: SolrAnswer | null | undefined, highlight: boolean) { +function searchResult( + answer: SolrAnswer | null | undefined, + highlight: boolean +): ResultGroup { const docs = answer?.response?.docs ?? []; const highlighting = highlight ? (answer?.highlighting ?? {}) : {}; const entries = docs.map((doc) => searchEntry(doc, highlighting)); @@ -230,14 +240,14 @@ function searchResult(answer: SolrAnswer | null | undefined, highlight: boolean) }; } -/** search-core's GroupedResult: the results, rows written, groups, matches; targets for none. */ -interface SearchResult { - results: unknown[]; - rowCount: number; - numberOfGroups: unknown; - numberOfMatches: unknown; +/** + * search-core's GroupedResult as it is built: the contract's, but for the + * targets, which keep their empty terms and resources until the answer is + * written (searchAnswer). + */ +type SearchResult = Omit, 'targetResults'> & { targetResults?: TargetResult[]; -} +}; /** * SolrConverter.parseClusteredResponse: the first grouping's groups, each @@ -250,8 +260,11 @@ function groupedEntries(answer: SolrAnswer | null | undefined): SearchResult | n if (!grouping) return null; if (grouping.ngroups === undefined || grouping.ngroups === null) throw Object.assign(new Error('a grouping without ngroups'), { unexpected: true }); + // SolrJ reads matches as ngroups is read, unboxed: none is the same failure. + if (grouping.matches === undefined) + throw Object.assign(new Error('a grouping without matches'), { unexpected: true }); const highlighting = answer?.highlighting ?? {}; - const results: unknown[] = []; + const results: ResultGroup[] = []; let rows = 0; for (const group of grouping.groups ?? []) { const entries = (group.doclist?.docs ?? []).map((doc) => searchEntry(doc, highlighting)); @@ -410,8 +423,9 @@ function searchAnswer(request: Request, query: string, result: SearchResult | nu const { targetResults, ...written } = result; // NON_EMPTY, in a 200: a target's null or empty term or resource is // left out (a query with a leading space has an empty first term). - const kept = (text: unknown) => text !== null && text !== undefined && text !== ''; - const targets = targetResults?.map(({ term, resource, target }) => ({ + const kept = (text: T | null | undefined): text is T => + text !== null && text !== undefined && text !== ''; + const targets = targetResults?.map(({ term, resource, target }): ContractTarget => ({ ...(kept(term) ? { term } : {}), ...(kept(resource) ? { resource } : {}), target, From 9be961b5a43f5adc6df78b43bda133af1599c113 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 27 Oct 2026 00:59:12 +0000 Subject: [PATCH 2/2] Spec 016, from #700's review: a grouping without matches, as SolrJ reads it SolrJ does not unbox a grouping's matches: extractGroupedInfo skips a grouping that has none (its bytecode), and the converter reads the first grouping it kept. So the service reads the first grouping with matches, and none is no result -- the same 500 for the one grouping this index asks for, and the right one where several are answered. The comment, the spec and tasks.md said SolrJ unboxes it; they say what it does now. Co-Authored-By: Claude Opus 5.5 --- specs/016-api-contract/tasks.md | 2 +- tools/content-node/query-search.spec.ts | 28 ++++++++++++++++++++++++- tools/content-node/search-endpoints.ts | 9 ++++---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/specs/016-api-contract/tasks.md b/specs/016-api-contract/tasks.md index 385378ca..b7e9abbd 100644 --- a/specs/016-api-contract/tasks.md +++ b/specs/016-api-contract/tasks.md @@ -73,7 +73,7 @@ description: 'Tasks for spec 016: one API contract for the content service and t **Independent test**: every search route's known problems are at zero or explained. - [x] T013 [US3] Write `ResultGroup`, `GroupedResult` (with `numberOfGroups`), the diagram and species-overview result types and `IconResult` (with `rowCount` and `typeName`) in `projects/contract/search.ts`, and point their writers in `search-endpoints.ts` and `search-diagram-endpoints.ts`, and their readers, at them. - - Done in #700. The contract's `ResultGroup`, `GroupedResult`, `DiagramResult`, `TargetResult` and `IconResult`, each taking its entry type as a named argument: the service writes them with its `WrittenEntry`, a reader reads them with the view. A group's `entries` is optional (an ungrouped page past the end has none) and so is its `typeName` (a group Solr names with nothing); the search page settles both once when the answer arrives (`ShownGroup`), and the pathway browser's search shows only named type facets, as the site's does. A target's empty term or resource is left out, so both are optional. Solr's grouping without `matches` is now Java's 500, as one without `ngroups` was (SolrJ unboxes both; failing without the check). The search routes' known problems are 0, the whole 12,164. From #697's review, the pathway browser's icon tooltip takes `iconName`, never highlighted. + - Done in #702. The contract's `ResultGroup`, `GroupedResult`, `DiagramResult`, `TargetResult` and `IconResult`, each taking its entry type as a named argument: the service writes them with its `WrittenEntry`, a reader reads them with the view. A group's `entries` is optional (an ungrouped page past the end has none) and so is its `typeName` (a group Solr names with nothing); the search page settles both once when the answer arrives (`ShownGroup`), and the pathway browser's search shows only named type facets, as the site's does. A target's empty term or resource is left out, so both are optional. A grouping without `matches` is skipped, as SolrJ's `QueryResponse.extractGroupedInfo` skips it (read from its bytecode, from #700's review): the first grouping that has its matches is read, and none is no result, Java's 500 (a spec of two groupings, the first without matches, fails on the first-grouping reading). The search routes' known problems are 0, the whole 12,164. From #697's review, the pathway browser's icon tooltip takes `iconName`, never highlighted. - [ ] T014 [US3] Record an answer for `/search/icon/{id}` (already in `tools/api-types/calls.ts` as `IconEntry`, with a `gap`) through the e2e suite and remove the `gap` (record rewrites three HARs: check the diff). Type its writer. - [ ] T015 [US3] Tick spec 013 T019 in `specs/013-server-types/tasks.md` with T002–T014's PR numbers. diff --git a/tools/content-node/query-search.spec.ts b/tools/content-node/query-search.spec.ts index 02531d6b..0aab2797 100644 --- a/tools/content-node/query-search.spec.ts +++ b/tools/content-node/query-search.spec.ts @@ -383,7 +383,8 @@ describe('/query', () => { }); it("is Java's 500 for a grouping without its count of groups or of matches", async () => { - // SolrJ unboxes both; a missing one is its NullPointerException. + // ngroups: GroupedResult unboxes it. matches: SolrJ skips the grouping, + // leaving no result, which Java cannot build a SearchResult from. for (const missing of ['ngroups', 'matches']) { answer = (handler) => { if (handler.endsWith('/facet')) return facets(8); @@ -399,6 +400,31 @@ describe('/query', () => { } }); + it('reads the first grouping that has its matches, as SolrJ keeps only those', async () => { + answer = (handler) => { + if (handler.endsWith('/facet')) return facets(8); + const kept = grouped([['Protein', [doc('1', 'TP53')]]]).grouped.type_facet; + return { + grouped: { + // First, and without matches: skipped. + species: { + ngroups: 1, + groups: [{ groupValue: 'Homo sapiens', doclist: { numFound: 5, docs: [] } }], + }, + type_facet: kept, + }, + }; + }; + const body = (await (await get('query?query=TP53')).json()) as { + results: { typeName?: string }[]; + numberOfMatches: number; + }; + expect([body.results.map((group) => group.typeName), body.numberOfMatches]).toEqual([ + ['Protein'], + 8, + ]); + }); + it('leaves out the type of a group with none', async () => { answer = (handler) => handler.endsWith('/facet') ? facets(8) : grouped([[null, [doc('1', 'TP53')]]]); diff --git a/tools/content-node/search-endpoints.ts b/tools/content-node/search-endpoints.ts index c9fab8b8..f760044a 100644 --- a/tools/content-node/search-endpoints.ts +++ b/tools/content-node/search-endpoints.ts @@ -256,13 +256,14 @@ type SearchResult = Omit, 'targetResults'> & { */ function groupedEntries(answer: SolrAnswer | null | undefined): SearchResult | null { const grouped: Record = answer?.grouped ?? {}; - const grouping = Object.values(grouped)[0]; + // SolrJ (QueryResponse.extractGroupedInfo) skips a grouping without + // matches; the converter reads the first it kept, and none is no result. + const grouping = Object.values(grouped).find( + (each): each is SolrGrouping & { matches: number } => each.matches !== undefined + ); if (!grouping) return null; if (grouping.ngroups === undefined || grouping.ngroups === null) throw Object.assign(new Error('a grouping without ngroups'), { unexpected: true }); - // SolrJ reads matches as ngroups is read, unboxed: none is the same failure. - if (grouping.matches === undefined) - throw Object.assign(new Error('a grouping without matches'), { unexpected: true }); const highlighting = answer?.highlighting ?? {}; const results: ResultGroup[] = []; let rows = 0;