diff --git a/swh/graphql/app.py b/swh/graphql/app.py --- a/swh/graphql/app.py +++ b/swh/graphql/app.py @@ -33,6 +33,7 @@ resolvers.branch_target, resolvers.release_target, resolvers.directory_entry_target, + resolvers.search_result_target, resolvers.binary_string, scalars.id_scalar, scalars.datetime_scalar, diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py --- a/swh/graphql/backends/archive.py +++ b/swh/graphql/backends/archive.py @@ -3,7 +3,10 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +from typing import List + from swh.graphql import server +from swh.model.swhids import CoreSWHID class Archive: @@ -19,6 +22,7 @@ if url_pattern is None: return self.storage.origin_list(page_token=after, limit=first) + # FIXME, change to use swh-search in a different backend return self.storage.origin_search( url_pattern=url_pattern, page_token=after, limit=first ) @@ -74,6 +78,25 @@ directory_id, limit=first, page_token=after ) - def get_content(self, content_id): - # FIXME, only for tests - return self.storage.content_find({"sha1_git": content_id}) + def get_contents(self, content_ids: List): + # FIXME, include other algos + return self.storage.content_get(contents=content_ids, algo="sha1_git") + + def search_in_swhids(self, swhid) -> List: + # query iff a valid swhid + swhid = CoreSWHID.from_string(swhid) + swhid_type = swhid.object_type.value + mapping = { + "rev": self.get_revisions, + "rel": self.get_releases, + "cnt": self.get_contents, + } + return mapping[swhid_type]([swhid.object_id]) + + def search_in_metadata(self, query) -> List: + return [] + + def search_in_origins(self, query) -> List: + # use this with the get_origins method + # validate the query language as a key expression in the utils + return self.get_origins(url_pattern=query, first=1) diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py --- a/swh/graphql/resolvers/content.py +++ b/swh/graphql/resolvers/content.py @@ -19,8 +19,7 @@ """ def _get_content_by_id(self, content_id): - content = archive.Archive().get_content(content_id) - return content[0] if content else None + return (archive.Archive().get_contents([content_id]) or None)[0] @property def checksum(self): @@ -46,6 +45,12 @@ return self._get_content_by_id(self.kwargs.get("swhid").object_id) +class SearchContentNode(BaseContentNode): + """ + Node resolver for a content requested from a search result node + """ + + class TargetContentNode(BaseContentNode): """ Node resolver for a content requested from a diff --git a/swh/graphql/resolvers/release.py b/swh/graphql/resolvers/release.py --- a/swh/graphql/resolvers/release.py +++ b/swh/graphql/resolvers/release.py @@ -42,6 +42,12 @@ return self._get_release_by_id(self.kwargs.get("swhid").object_id) +class SearchReleaseNode(BaseReleaseNode): + """ + Node resolver for a release requested from a search result node + """ + + class TargetReleaseNode(BaseReleaseNode): """ Node resolver for a release requested as a target diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -14,6 +14,7 @@ RevisionNode, TargetRevisionNode, ) +from .search import SearchResultConnection from .snapshot import ( OriginSnapshotConnection, SnapshotNode, @@ -67,6 +68,7 @@ "revision-parents": ParentRevisionConnection, "revision-log": LogRevisionConnection, "directory-entries": DirectoryEntryConnection, + "search": SearchResultConnection, } if resolver_type not in mapping: raise AttributeError(f"Invalid connection type: {resolver_type}") diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -39,6 +39,7 @@ branch_target: UnionType = UnionType("BranchTarget") release_target: UnionType = UnionType("ReleaseTarget") directory_entry_target: UnionType = UnionType("DirectoryEntryTarget") +search_result_target: UnionType = UnionType("SearchResultTarget") # Node resolvers # A node resolver should return an instance of BaseNode @@ -239,12 +240,21 @@ return resolver(obj, info, **kw) +@query.field("search") +def search_resolver( + obj, info: GraphQLResolveInfo, **kw +) -> rs.search.SearchResultConnection: + resolver = get_connection_resolver("search") + return resolver(obj, info, **kw) + + # Any other type of resolver @release_target.type_resolver @directory_entry_target.type_resolver @branch_target.type_resolver +@search_result_target.type_resolver def union_resolver(obj, *_) -> str: """ Generic resolver for all the union types diff --git a/swh/graphql/resolvers/revision.py b/swh/graphql/resolvers/revision.py --- a/swh/graphql/resolvers/revision.py +++ b/swh/graphql/resolvers/revision.py @@ -55,6 +55,15 @@ return self._get_revision_by_id(self.kwargs.get("swhid").object_id) +class SearchRevisionNode(BaseRevisionNode): + """ + Node resolver for a revision requested from a search result node + """ + + # the parent obj (self.obj) (SearchResultNode) has the model revision object + # no need to get data from backend again + + class TargetRevisionNode(BaseRevisionNode): """ Node resolver for a revision requested as a target diff --git a/swh/graphql/resolvers/search.py b/swh/graphql/resolvers/search.py new file mode 100644 --- /dev/null +++ b/swh/graphql/resolvers/search.py @@ -0,0 +1,87 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from swh.graphql.backends import archive +from swh.storage.interface import PagedResult + +from .base_connection import BaseConnection +from .base_node import BaseNode +from .content import SearchContentNode +from .release import SearchReleaseNode +from .revision import SearchRevisionNode + + +class SearchResultNode(BaseNode): + @property + def targetType(self): + return self._node.__class__.__name__.lower() + + @property + def match(self): + return None + + @property + def target(self): + # return the right node resolver + + # not using the ariadne resolver here + # it is not needed as the result field will never + # accept extra arguments + + # node_data is already avialble here + resolvers = { + "revision": SearchRevisionNode, + "release": SearchReleaseNode, + "content": SearchContentNode, + } + resolver = resolvers[self.targetType] + return resolver(obj=self, info=self.info, node_data=self._node) + + +class SearchResultConnection(BaseConnection): + """ + Category is a mandatory argument, get one category per + query + different categories can be requested in one request with + alias in query + eg: + query mysearch { + ori-search: search(query: "test", category: "origin") { + type + } + meta-search: search(query: "test", category: "metadata") { + type + } + swhid-search: search(query: "test", category: "swhid") { + type + } + } + """ + + # add more categories as needed + # advanced query language will be handled implicitly in + # metadata and origins + + _node_class = SearchResultNode + + def _get_paged_result(self): + query = self.kwargs.get("query") + category = self.kwargs.get("category") + # FIXME, use a search factory instead, either in this module + # or in a separate package + if category == "swhid": + results = PagedResult( + results=archive.Archive().search_in_swhids(query), next_page_token=None + ) + elif category == "metadata": + # FIXME, validate the query language as a key expression in the utils + results = archive.Archive().search_in_metadata(query) + elif category == "origin": + # FIXME, validate the query language as a key expression in the utils + # this will return a homogeneous list of origin types + # still using the same SearchResult type to return result + # FIXME, maybe it is better to add a type OriginSearchResult + results = archive.Archive().search_in_origins(query) + return results diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -805,6 +805,54 @@ status: String } +""" +Connection to search results +""" +type SearchResultConnection { + """ + List of search result edges + """ + edges: [SearchResultEdge] + + """ + List of search result objects + """ + nodes: [SearchResult] + + """ + Information for pagination + """ + pageInfo: PageInfo! + + """ + Total number of origin objects in the connection + """ + totalCount: Int +} + +type SearchResultEdge { + """ + Cursor to request the next page after the item + """ + cursor: String! + + """ + Search result object + """ + node: SearchResult +} + +union SearchResultTarget = Revision | Release | Branch | Content | Directory | Snapshot | Origin + +""" +A search result object +""" +type SearchResult { + match: Int + target: SearchResultTarget + targetType: String! # FIXME, use enum +} + """ The query root of the GraphQL interface. """ @@ -903,4 +951,12 @@ """ swhid: SWHID! ): Content + + """ + Search the archive + """ + search( + query: String! + category: String! # FIXME, use enum + ): SearchResultConnection }