1
1
import { Injectable } from '@nestjs/common' ;
2
- import { isNotFalsy } from '@seedcompany/common' ;
2
+ import { isNotNil , setHas , setOf } from '@seedcompany/common' ;
3
+ import { uniqBy } from 'lodash' ;
4
+ import { ValueOf } from 'type-fest' ;
3
5
import { ID , NotFoundException , ServerException , Session } from '~/common' ;
4
- import { ResourceResolver , ResourcesHost } from '~/core' ;
6
+ import { ResourceMap , ResourceResolver , ResourcesHost } from '~/core/resources ' ;
5
7
import { Privileges } from '../authorization' ;
6
8
import { PartnerService } from '../partner' ;
7
9
import {
8
10
SearchableMap ,
9
11
SearchInput ,
10
12
SearchOutput ,
11
13
SearchResult ,
12
- SearchResultMap ,
13
14
SearchResultTypes ,
14
15
} from './dto' ;
15
16
import { SearchRepository } from './search.repository' ;
@@ -19,12 +20,20 @@ type HydratorMap = {
19
20
} ;
20
21
type Hydrator < R > = ( id : ID , session : Session ) => Promise < R > ;
21
22
23
+ type Match < Types extends string > = ValueOf < {
24
+ [ Type in Types ] : {
25
+ type : Type ;
26
+ id : ID ;
27
+ matchedProps : readonly string [ ] ;
28
+ } ;
29
+ } > ;
30
+
22
31
@Injectable ( )
23
32
export class SearchService {
24
33
// mapping of base nodes to functions that,
25
34
// given id & session, will return the object.
26
35
/* eslint-disable @typescript-eslint/naming-convention */
27
- private readonly hydrators : HydratorMap = {
36
+ private readonly customHydrators : HydratorMap = {
28
37
PartnerByOrg : async ( ...args ) => ( {
29
38
...( await this . partners . readOnePartnerByOrgId ( ...args ) ) ,
30
39
__typename : 'Partner' ,
@@ -33,68 +42,93 @@ export class SearchService {
33
42
/* eslint-enable @typescript-eslint/naming-convention */
34
43
35
44
constructor (
36
- private readonly resourceHost : ResourcesHost ,
37
- private readonly resources : ResourceResolver ,
45
+ private readonly resources : ResourcesHost ,
46
+ private readonly resourceResolver : ResourceResolver ,
38
47
private readonly privileges : Privileges ,
39
48
private readonly partners : PartnerService ,
40
49
private readonly repo : SearchRepository ,
41
50
) { }
42
51
43
52
async search ( input : SearchInput , session : Session ) : Promise < SearchOutput > {
44
- // if type isn't specified default to all types
45
- const inputTypes = input . type || SearchResultTypes ;
53
+ const types = input . type
54
+ ? setOf (
55
+ // Expand interfaces to their concretes
56
+ // This is needed here now, because below we confirm the match results
57
+ // are within this `types` filter
58
+ // and those results are the concretes not the interfaces.
59
+ input . type
60
+ . flatMap ( ( type ) =>
61
+ this . resources . getImplementations ( this . resources . enhance ( type ) ) ,
62
+ )
63
+ . flatMap ( ( type ) =>
64
+ setHas ( SearchResultTypes , type . name ) ? type . name : [ ] ,
65
+ ) ,
66
+ )
67
+ : // if a type filter isn't specified default to all types
68
+ SearchResultTypes ;
46
69
47
- const types = [
48
- ...inputTypes ,
49
- // Add Organization label when searching for Partners we can search for
50
- // Partner by organization name
51
- ...( inputTypes . includes ( 'Partner' ) ? ( [ 'Organization' ] as const ) : [ ] ) ,
52
- ] ;
70
+ const resourceTypes = new Set < keyof ResourceMap > ( types ) ;
71
+ // Include dependency types for types that have identifiers in sub-resources.
72
+ types . has ( 'Partner' ) && resourceTypes . add ( 'Organization' ) ;
53
73
54
74
// Search for nodes based on input, only returning their id and "type"
55
75
// which is based on their first valid search label.
56
- const results = await this . repo . search ( { ...input , type : types } ) ;
76
+ const results = await this . repo . search ( {
77
+ ...input ,
78
+ type : [ ...resourceTypes ] ,
79
+ } ) ;
57
80
58
- const ResourceMap = this . resourceHost . getMap ( ) ;
59
-
60
- // Individually convert each result (id & type) to its search result
61
- // based on this.hydrators
62
- const hydrated = await Promise . all (
81
+ const maybeHydrated = await Promise . all (
63
82
results
64
- . map ( ( { node, matchedProps } ) => ( {
65
- type : this . resources . resolveTypeByBaseNode (
66
- node ,
67
- ) as keyof SearchableMap ,
68
- id : node . properties . id ,
69
- matchedProps,
70
- } ) )
71
- // Map Org results to Org & Partner results based on types asked for
72
- . flatMap ( ( result ) =>
73
- result . type !== 'Organization'
74
- ? result
75
- : [
76
- ...( inputTypes . includes ( 'Organization' ) ? [ result ] : [ ] ) ,
77
- // If matched Organization, include Partner implicitly
78
- ...( inputTypes . includes ( 'Partner' )
79
- ? [
80
- {
81
- id : result . id , // hydrator knows this is an org id not partner
82
- type : 'PartnerByOrg' as const ,
83
- matchedProps : [ 'organization' as const ] ,
84
- } ,
85
- ]
86
- : [ ] ) ,
87
- ] ,
88
- )
83
+ // Normalize result & resolve type from neo4j data
84
+ . map ( ( { node, matchedProps } ) => {
85
+ const result = {
86
+ type : this . resourceResolver . resolveTypeByBaseNode ( node ) ,
87
+ id : node . properties . id ,
88
+ matchedProps,
89
+ } ;
90
+ return result as Match < keyof ResourceMap > ;
91
+ } )
92
+ // Ensure resource types matched are within the search type filters
93
+ // and handle special cases.
94
+ . flatMap < Match < keyof SearchableMap > > ( ( result ) => {
95
+ if ( result . type === 'Organization' ) {
96
+ return [
97
+ ...( types . has ( 'Organization' ) ? [ result ] : [ ] ) ,
98
+ ...( types . has ( 'Partner' )
99
+ ? [
100
+ {
101
+ type : 'PartnerByOrg' ,
102
+ id : result . id ,
103
+ matchedProps : [ 'organization' ] ,
104
+ } as const ,
105
+ ]
106
+ : [ ] ) ,
107
+ ] ;
108
+ }
109
+
110
+ // This is a sanity/type check.
111
+ // Functionally, we shouldn't have any results at this point that
112
+ // aren't within the `types` filter.
113
+ // However, this does require that the logic above is in sync with
114
+ // the resources & type filters.
115
+ return setHas ( types , result . type )
116
+ ? ( result as Extract < typeof result , { type : keyof SearchableMap } > )
117
+ : [ ] ;
118
+ } )
119
+ // Do hydration data loading for each identified resource.
89
120
. map (
90
- async ( { id , matchedProps , type } ) : Promise < SearchResult | null > => {
121
+ async ( { type , id , matchedProps } ) : Promise < SearchResult | null > => {
91
122
const hydrator = this . hydrate ( type ) ;
92
123
const hydrated = await hydrator ( id , session ) ;
93
- if ( ! hydrated || ! ( hydrated . __typename in ResourceMap ) ) {
124
+ if (
125
+ ! hydrated ||
126
+ ! ( hydrated . __typename in this . resources . getEnhancedMap ( ) )
127
+ ) {
94
128
return null ;
95
129
}
96
130
97
- const resource = this . resourceHost . getByName ( hydrated . __typename ) ;
131
+ const resource = this . resources . getByName ( hydrated . __typename ) ;
98
132
const perms = this . privileges . for ( session , resource , hydrated ) . all ;
99
133
return matchedProps . some ( ( key ) =>
100
134
// @ts -expect-error strict typing is hard for this dynamic use case.
@@ -105,21 +139,30 @@ export class SearchService {
105
139
} ,
106
140
) ,
107
141
) ;
142
+ const hydrated = maybeHydrated . filter ( isNotNil ) ;
143
+
144
+ // It is possible that to have two different matches that end up resolving
145
+ // to the same resource, so they need to be de-duped.
146
+ // For example, a language name and ethnologue code are both matched,
147
+ // but in hydrating we convert the ethnologue language to a regular language.
148
+ // Only at this point can we check for this convergence.
149
+ const items = uniqBy ( hydrated , ( result ) => result . id ) ;
108
150
109
151
return {
110
- items : hydrated . filter ( isNotFalsy ) . slice ( 0 , input . count ) ,
152
+ items : items . slice ( 0 , input . count ) ,
111
153
} ;
112
154
}
113
155
114
156
private hydrate < K extends keyof SearchableMap > ( type : K ) {
115
157
return async (
116
158
...args : Parameters < Hydrator < any > >
117
159
) : Promise < SearchResult | null > => {
118
- const hydrator = this . hydrators [ type ] as Hydrator < SearchResultMap [ K ] > ;
160
+ const hydrator =
161
+ type in this . customHydrators ? this . customHydrators [ type ] : undefined ;
119
162
try {
120
163
const obj = hydrator
121
164
? await hydrator ( ...args )
122
- : await this . resources . lookup ( type , ...args ) ;
165
+ : await this . resourceResolver . lookup ( type , ...args ) ;
123
166
return obj as SearchResult ;
124
167
} catch ( err ) {
125
168
if ( err instanceof NotFoundException ) return null ;
0 commit comments