diff --git a/projects/contract/search.ts b/projects/contract/search.ts index acc64c54d..e2f1cff1c 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 cfe18b917..eba3a3754 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 cea3b54ad..528285479 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 effcfa734..bf9593b00 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 bef0d4a65..d3703fe84 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 5f33504e5..79ad2222a 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 4cd53995f..c71193262 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 c4757b785..8d8e32311 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 209b73fd3..89b254473 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 7ee1aad3c..b7e9abbdf 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 #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/api-types/known-problems.json b/tools/api-types/known-problems.json index abbdd1271..b31cca65c 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 479a3efa4..0aab2797c 100644 --- a/tools/content-node/query-search.spec.ts +++ b/tools/content-node/query-search.spec.ts @@ -382,6 +382,49 @@ describe('/query', () => { ]); }); + it("is Java's 500 for a grouping without its count of groups or of matches", async () => { + // 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); + 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('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-diagram-endpoints.ts b/tools/content-node/search-diagram-endpoints.ts index 7fc4d0e42..e4f51d1ee 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 e628c1de0..f760044ac 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 @@ -246,12 +256,16 @@ interface SearchResult { */ 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 }); 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 +424,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,