Page Menu
Home
Software Heritage
Search
Configure Global Search
Log In
Files
F9311682
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
53 KB
Subscribers
None
View Options
diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py
index c253232..738b340 100644
--- a/swh/graphql/backends/archive.py
+++ b/swh/graphql/backends/archive.py
@@ -1,79 +1,91 @@
# 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
+import os
+from typing import Any, Dict, Optional
+
from swh.graphql import server
+from swh.model.model import Sha1Git
from swh.model.swhids import ObjectType
class Archive:
def __init__(self):
self.storage = server.get_storage()
def get_origin(self, url):
return self.storage.origin_get([url])[0]
def get_origins(self, after=None, first=50):
return self.storage.origin_list(page_token=after, limit=first)
def get_origin_visits(self, origin_url, after=None, first=50):
return self.storage.origin_visit_get(origin_url, page_token=after, limit=first)
def get_origin_visit(self, origin_url, visit_id):
return self.storage.origin_visit_get_by(origin_url, visit_id)
def get_origin_latest_visit(self, origin_url):
return self.storage.origin_visit_get_latest(origin_url)
def get_visit_status(self, origin_url, visit_id, after=None, first=50):
return self.storage.origin_visit_status_get(
origin_url, visit_id, page_token=after, limit=first
)
def get_latest_visit_status(self, origin_url, visit_id):
return self.storage.origin_visit_status_get_latest(origin_url, visit_id)
def get_origin_snapshots(self, origin_url):
return self.storage.origin_snapshot_get_all(origin_url)
def get_snapshot_branches(
self, snapshot, after=b"", first=50, target_types=None, name_include=None
):
return self.storage.snapshot_get_branches(
snapshot,
branches_from=after,
branches_count=first,
target_types=target_types,
branch_name_include_substring=name_include,
)
def get_revisions(self, revision_ids):
return self.storage.revision_get(revision_ids=revision_ids)
def get_revision_log(self, revision_ids, after=None, first=50):
return self.storage.revision_log(revisions=revision_ids, limit=first)
def get_releases(self, release_ids):
return self.storage.release_get(releases=release_ids)
+ def get_directory_entry_by_path(
+ self, directory_id: Sha1Git, path: str
+ ) -> Optional[Dict[str, Any]]:
+ paths = [x.encode() for x in path.strip(os.path.sep).split(os.path.sep)]
+ return self.storage.directory_entry_get_by_path(
+ directory=directory_id, paths=paths
+ )
+
def get_directory_entries(self, directory_id, after=None, first=50):
return self.storage.directory_get_entries(
directory_id, limit=first, page_token=after
)
def is_object_available(self, object_id: str, object_type: ObjectType) -> bool:
mapping = {
ObjectType.CONTENT: self.storage.content_missing_per_sha1_git,
ObjectType.DIRECTORY: self.storage.directory_missing,
ObjectType.RELEASE: self.storage.release_missing,
ObjectType.REVISION: self.storage.revision_missing,
ObjectType.SNAPSHOT: self.storage.snapshot_missing,
}
return not list(mapping[object_type]([object_id]))
def get_contents(self, checksums: dict):
return self.storage.content_find(checksums)
def get_content_data(self, content_sha1):
return self.storage.content_get_data(content_sha1)
diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py
index e5335b2..71e91ca 100644
--- a/swh/graphql/resolvers/content.py
+++ b/swh/graphql/resolvers/content.py
@@ -1,93 +1,93 @@
# 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 typing import Union
from .base_node import BaseSWHNode
-from .directory_entry import DirectoryEntryNode
+from .directory_entry import BaseDirectoryEntryNode
from .release import BaseReleaseNode
from .snapshot_branch import BaseSnapshotBranchNode
class BaseContentNode(BaseSWHNode):
"""
Base resolver for all the content nodes
"""
def _get_content_by_hash(self, checksums: dict):
content = self.archive.get_contents(checksums)
# in case of a conflict, return the first element
return content[0] if content else None
@property
def checksum(self):
# FIXME, use a Node instead
return {k: v.hex() for (k, v) in self._node.hashes().items()}
@property
def id(self):
return self._node.sha1_git
@property
def data(self):
# FIXME, return a Node object
# FIXME, add more ways to retrieve data like binary string
archive_url = "https://archive.softwareheritage.org/api/1/"
content_sha1 = self._node.hashes()["sha1"]
return {
"url": f"{archive_url}content/sha1:{content_sha1.hex()}/raw/",
}
@property
def fileType(self):
# FIXME, fetch data from the indexers
return None
@property
def language(self):
# FIXME, fetch data from the indexers
return None
@property
def license(self):
# FIXME, fetch data from the indexers
return None
def is_type_of(self):
# is_type_of is required only when resolving a UNION type
# This is for ariadne to return the right type
return "Content"
class ContentNode(BaseContentNode):
"""
Node resolver for a content requested directly with its SWHID
"""
def _get_node_data(self):
checksums = {"sha1_git": self.kwargs.get("swhid").object_id}
return self._get_content_by_hash(checksums)
class HashContentNode(BaseContentNode):
"""
Node resolver for a content requested with one or more checksums
"""
def _get_node_data(self):
checksums = dict(self.kwargs.get("checksums"))
return self._get_content_by_hash(checksums)
class TargetContentNode(BaseContentNode):
"""
Node resolver for a content requested as a target
This request could be from directory entry, release or a branch
"""
- obj: Union[DirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode]
+ obj: Union[BaseDirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode]
def _get_node_data(self):
return self._get_content_by_hash(checksums={"sha1_git": self.obj.target_hash})
diff --git a/swh/graphql/resolvers/directory.py b/swh/graphql/resolvers/directory.py
index fc07520..1776ae6 100644
--- a/swh/graphql/resolvers/directory.py
+++ b/swh/graphql/resolvers/directory.py
@@ -1,72 +1,71 @@
# 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 typing import Union
from swh.model.model import Directory
from swh.model.swhids import ObjectType
from .base_node import BaseSWHNode
from .release import BaseReleaseNode
from .revision import BaseRevisionNode
from .snapshot_branch import BaseSnapshotBranchNode
class BaseDirectoryNode(BaseSWHNode):
"""
Base resolver for all the directory nodes
"""
def _get_directory_by_id(self, directory_id):
# Return a Directory model object
# entries is initialized as empty
# Same pattern is used in snapshot
return Directory(id=directory_id, entries=())
def is_type_of(self):
return "Directory"
class DirectoryNode(BaseDirectoryNode):
"""
Node resolver for a directory requested directly with its SWHID
"""
def _get_node_data(self):
swhid = self.kwargs.get("swhid")
- # path = ""
if (
swhid.object_type == ObjectType.DIRECTORY
and self.archive.is_object_available(swhid.object_id, swhid.object_type)
):
# _get_directory_by_id is not making any backend call
# hence the is_directory_available validation
return self._get_directory_by_id(swhid.object_id)
return None
class RevisionDirectoryNode(BaseDirectoryNode):
"""
Node resolver for a directory requested from a revision
"""
obj: BaseRevisionNode
def _get_node_data(self):
# self.obj.directory_hash is the requested directory Id
return self._get_directory_by_id(self.obj.directory_hash)
class TargetDirectoryNode(BaseDirectoryNode):
"""
Node resolver for a directory requested as a target
"""
- from .directory_entry import DirectoryEntryNode
+ from .directory_entry import BaseDirectoryEntryNode
- obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, DirectoryEntryNode]
+ obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, BaseDirectoryEntryNode]
def _get_node_data(self):
return self._get_directory_by_id(self.obj.target_hash)
diff --git a/swh/graphql/resolvers/directory_entry.py b/swh/graphql/resolvers/directory_entry.py
index e1aea34..b19ddf2 100644
--- a/swh/graphql/resolvers/directory_entry.py
+++ b/swh/graphql/resolvers/directory_entry.py
@@ -1,43 +1,54 @@
# 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.utils import utils
from swh.storage.interface import PagedResult
from .base_connection import BaseConnection
from .base_node import BaseNode
-class DirectoryEntryNode(BaseNode):
- """
- Node resolver for a directory entry
- """
-
+class BaseDirectoryEntryNode(BaseNode):
@property
def target_hash(self): # for DirectoryNode
return self._node.target
@property
def targetType(self): # To support the schema naming convention
return self._node.type
+class DirectoryEntryNode(BaseDirectoryEntryNode):
+ """
+ Node resolver for a directory entry requested with a
+ directory SWHID and a relative path
+ """
+
+ def _get_node_data(self):
+ # STORAGE-TODO, archive is returning a dict
+ # return DirectoryEntry object instead
+ return self.archive.get_directory_entry_by_path(
+ directory_id=self.kwargs.get("swhid").object_id,
+ path=self.kwargs.get("path"),
+ )
+
+
class DirectoryEntryConnection(BaseConnection):
"""
Connection resolver for entries in a directory
"""
from .directory import BaseDirectoryNode
obj: BaseDirectoryNode
- _node_class = DirectoryEntryNode
+ _node_class = BaseDirectoryEntryNode
def _get_paged_result(self) -> PagedResult:
# FIXME, using dummy(local) pagination, move pagination to backend
# To remove localpagination, just drop the paginated call
# STORAGE-TODO
entries = self.archive.get_directory_entries(self.obj.swhid.object_id).results
return utils.paginated(entries, self._get_first_arg(), self._get_after_arg())
diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py
index 438bc5b..fb85b22 100644
--- a/swh/graphql/resolvers/resolver_factory.py
+++ b/swh/graphql/resolvers/resolver_factory.py
@@ -1,84 +1,85 @@
# 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 .content import ContentNode, HashContentNode, TargetContentNode
from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode
-from .directory_entry import DirectoryEntryConnection
+from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode
from .origin import OriginConnection, OriginNode, TargetOriginNode
from .release import ReleaseNode, TargetReleaseNode
from .revision import (
LogRevisionConnection,
ParentRevisionConnection,
RevisionNode,
TargetRevisionNode,
)
from .search import ResolveSwhidConnection, SearchConnection
from .snapshot import (
OriginSnapshotConnection,
SnapshotNode,
TargetSnapshotNode,
VisitSnapshotNode,
)
from .snapshot_branch import AliasSnapshotBranchNode, SnapshotBranchConnection
from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode
from .visit_status import LatestVisitStatusNode, VisitStatusConnection
def get_node_resolver(resolver_type):
# FIXME, replace with a proper factory method
mapping = {
"origin": OriginNode,
"visit": OriginVisitNode,
"latest-visit": LatestVisitNode,
"latest-status": LatestVisitStatusNode,
"visit-snapshot": VisitSnapshotNode,
"snapshot": SnapshotNode,
"branch-alias": AliasSnapshotBranchNode,
"branch-revision": TargetRevisionNode,
"branch-release": TargetReleaseNode,
"branch-directory": TargetDirectoryNode,
"branch-content": TargetContentNode,
"branch-snapshot": TargetSnapshotNode,
"revision": RevisionNode,
"revision-directory": RevisionDirectoryNode,
"release": ReleaseNode,
"release-revision": TargetRevisionNode,
"release-release": TargetReleaseNode,
"release-directory": TargetDirectoryNode,
"release-content": TargetContentNode,
"directory": DirectoryNode,
+ "directory-entry": DirectoryEntryNode,
"content": ContentNode,
"content-by-hash": HashContentNode,
"dir-entry-dir": TargetDirectoryNode,
"dir-entry-file": TargetContentNode,
"search-result-origin": TargetOriginNode,
"search-result-snapshot": TargetSnapshotNode,
"search-result-revision": TargetRevisionNode,
"search-result-release": TargetReleaseNode,
"search-result-directory": TargetDirectoryNode,
"search-result-content": TargetContentNode,
}
if resolver_type not in mapping:
raise AttributeError(f"Invalid node type: {resolver_type}")
return mapping[resolver_type]
def get_connection_resolver(resolver_type):
# FIXME, replace with a proper factory method
mapping = {
"origins": OriginConnection,
"origin-visits": OriginVisitConnection,
"origin-snapshots": OriginSnapshotConnection,
"visit-status": VisitStatusConnection,
"snapshot-branches": SnapshotBranchConnection,
"revision-parents": ParentRevisionConnection,
"revision-log": LogRevisionConnection,
"directory-entries": DirectoryEntryConnection,
"resolve-swhid": ResolveSwhidConnection,
"search": SearchConnection,
}
if resolver_type not in mapping:
raise AttributeError(f"Invalid connection type: {resolver_type}")
return mapping[resolver_type]
diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py
index 2be89f2..60992bb 100644
--- a/swh/graphql/resolvers/resolvers.py
+++ b/swh/graphql/resolvers/resolvers.py
@@ -1,299 +1,307 @@
# 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
"""
High level resolvers
"""
# Any schema attribute can be resolved by any of the following ways
# and in the following priority order
# - In this module using a decorator (eg: @visitstatus.field("snapshot")
# Every object (type) is expected to resolve this way as they can accept arguments
# eg: origin.visits takes arguments to paginate
# - As a property in the Node object (eg: resolvers.visit.BaseVisitNode.id)
# Every scalar is expected to resolve this way
# - As an attribute/item in the object/dict returned by a backend (eg: Origin.url)
from ariadne import ObjectType, UnionType
from graphql.type import GraphQLResolveInfo
from swh.graphql import resolvers as rs
from swh.graphql.utils import utils
from .resolver_factory import get_connection_resolver, get_node_resolver
query: ObjectType = ObjectType("Query")
origin: ObjectType = ObjectType("Origin")
visit: ObjectType = ObjectType("Visit")
visit_status: ObjectType = ObjectType("VisitStatus")
snapshot: ObjectType = ObjectType("Snapshot")
snapshot_branch: ObjectType = ObjectType("Branch")
revision: ObjectType = ObjectType("Revision")
release: ObjectType = ObjectType("Release")
directory: ObjectType = ObjectType("Directory")
directory_entry: ObjectType = ObjectType("DirectoryEntry")
search_result: ObjectType = ObjectType("SearchResult")
binary_string: ObjectType = ObjectType("BinaryString")
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
@query.field("origin")
def origin_resolver(obj: None, info: GraphQLResolveInfo, **kw) -> rs.origin.OriginNode:
""" """
resolver = get_node_resolver("origin")
return resolver(obj, info, **kw)
@origin.field("latestVisit")
def latest_visit_resolver(
obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw
) -> rs.visit.LatestVisitNode:
""" """
resolver = get_node_resolver("latest-visit")
return resolver(obj, info, **kw)
@query.field("visit")
def visit_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.visit.OriginVisitNode:
""" """
resolver = get_node_resolver("visit")
return resolver(obj, info, **kw)
@visit.field("latestStatus")
def latest_visit_status_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.visit_status.LatestVisitStatusNode:
""" """
resolver = get_node_resolver("latest-status")
return resolver(obj, info, **kw)
@query.field("snapshot")
def snapshot_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.snapshot.SnapshotNode:
""" """
resolver = get_node_resolver("snapshot")
return resolver(obj, info, **kw)
@visit_status.field("snapshot")
def visit_snapshot_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.snapshot.VisitSnapshotNode:
resolver = get_node_resolver("visit-snapshot")
return resolver(obj, info, **kw)
@snapshot_branch.field("target")
def snapshot_branch_target_resolver(
obj: rs.snapshot_branch.BaseSnapshotBranchNode, info: GraphQLResolveInfo, **kw
):
"""
Snapshot branch target can be a revision, release, directory,
content, snapshot or a branch itself (alias type)
"""
resolver_type = f"branch-{obj.type}"
resolver = get_node_resolver(resolver_type)
return resolver(obj, info, **kw)
@query.field("revision")
def revision_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.revision.RevisionNode:
resolver = get_node_resolver("revision")
return resolver(obj, info, **kw)
@revision.field("directory")
def revision_directory_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.directory.RevisionDirectoryNode:
resolver = get_node_resolver("revision-directory")
return resolver(obj, info, **kw)
@query.field("release")
def release_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.release.ReleaseNode:
resolver = get_node_resolver("release")
return resolver(obj, info, **kw)
@release.field("target")
def release_target_resolver(obj, info: GraphQLResolveInfo, **kw):
"""
release target can be a release, revision,
directory or content
obj is release here, target type is
obj.target_type
"""
resolver_type = f"release-{obj.target_type.value}"
resolver = get_node_resolver(resolver_type)
return resolver(obj, info, **kw)
@query.field("directory")
def directory_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.directory.DirectoryNode:
resolver = get_node_resolver("directory")
return resolver(obj, info, **kw)
+@query.field("directoryEntry")
+def directory_entry_resolver(
+ obj: None, info: GraphQLResolveInfo, **kw
+) -> rs.directory.DirectoryNode:
+ resolver = get_node_resolver("directory-entry")
+ return resolver(obj, info, **kw)
+
+
@directory_entry.field("target")
def directory_entry_target_resolver(
- obj: rs.directory_entry.DirectoryEntryNode, info: GraphQLResolveInfo, **kw
+ obj: rs.directory_entry.BaseDirectoryEntryNode, info: GraphQLResolveInfo, **kw
):
"""
directory entry target can be a directory or a content
"""
resolver_type = f"dir-entry-{obj.type}"
resolver = get_node_resolver(resolver_type)
return resolver(obj, info, **kw)
@query.field("content")
def content_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.content.ContentNode:
resolver = get_node_resolver("content")
return resolver(obj, info, **kw)
@search_result.field("target")
def search_result_target_resolver(
obj: rs.search.SearchResultNode, info: GraphQLResolveInfo, **kw
):
resolver_type = f"search-result-{obj.type}"
resolver = get_node_resolver(resolver_type)
return resolver(obj, info, **kw)
@query.field("contentByHash")
def content_by_hash_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.content.ContentNode:
resolver = get_node_resolver("content-by-hash")
return resolver(obj, info, **kw)
# Connection resolvers
# A connection resolver should return an instance of BaseConnection
@query.field("origins")
def origins_resolver(
obj: None, info: GraphQLResolveInfo, **kw
) -> rs.origin.OriginConnection:
resolver = get_connection_resolver("origins")
return resolver(obj, info, **kw)
@origin.field("visits")
def visits_resolver(
obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw
) -> rs.visit.OriginVisitConnection:
resolver = get_connection_resolver("origin-visits")
return resolver(obj, info, **kw)
@origin.field("snapshots")
def origin_snapshots_resolver(
obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw
) -> rs.snapshot.OriginSnapshotConnection:
""" """
resolver = get_connection_resolver("origin-snapshots")
return resolver(obj, info, **kw)
@visit.field("status")
def visitstatus_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.visit_status.VisitStatusConnection:
resolver = get_connection_resolver("visit-status")
return resolver(obj, info, **kw)
@snapshot.field("branches")
def snapshot_branches_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.snapshot_branch.SnapshotBranchConnection:
resolver = get_connection_resolver("snapshot-branches")
return resolver(obj, info, **kw)
@revision.field("parents")
def revision_parents_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.revision.ParentRevisionConnection:
resolver = get_connection_resolver("revision-parents")
return resolver(obj, info, **kw)
@revision.field("revisionLog")
def revision_log_resolver(obj, info, **kw):
resolver = get_connection_resolver("revision-log")
return resolver(obj, info, **kw)
@directory.field("entries")
-def directory_entry_resolver(
+def directory_entries_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.directory_entry.DirectoryEntryConnection:
resolver = get_connection_resolver("directory-entries")
return resolver(obj, info, **kw)
@query.field("resolveSwhid")
def search_swhid_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.search.ResolveSwhidConnection:
resolver = get_connection_resolver("resolve-swhid")
return resolver(obj, info, **kw)
@query.field("search")
def search_resolver(
obj, info: GraphQLResolveInfo, **kw
) -> rs.search.ResolveSwhidConnection:
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
"""
return obj.is_type_of()
@binary_string.field("text")
def binary_string_text_resolver(obj, *args, **kw):
return obj.decode(utils.ENCODING, "replace")
@binary_string.field("base64")
def binary_string_base64_resolver(obj, *args, **kw):
return utils.get_b64_string(obj)
diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql
index 0d61a85..8ae62f1 100644
--- a/swh/graphql/schema/schema.graphql
+++ b/swh/graphql/schema/schema.graphql
@@ -1,1063 +1,1078 @@
"""
SoftWare Heritage persistent Identifier
"""
scalar SWHID
"""
ISO-8601 encoded date string
"""
scalar DateTime
"""
Content identifier in the form hash-type:hash-value
"""
scalar ContentHash
"""
Object with an id
"""
interface Node {
"""
Id of the object. This is for caching purpose and
should not be used outside the GraphQL API
"""
id: ID!
}
"""
SWH merkle node object with a SWHID
"""
interface MerkleNode {
"""
SWHID of the object
"""
swhid: SWHID!
}
"""
Information about pagination
"""
type PageInfo {
"""
Cursor to request the next page in the connection
"""
endCursor: String
"""
Are there more pages in the connection?
"""
hasNextPage: Boolean!
}
"""
Binary strings; different encodings
"""
type BinaryString {
"""
Utf-8 encoded value, any non Unicode character will be replaced
"""
text: String
"""
base64 encoded value
"""
base64: String
}
"""
Connection to origins
"""
type OriginConnection {
"""
List of origin edges
"""
edges: [OriginEdge]
"""
List of origin objects
"""
nodes: [Origin]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of origin objects in the connection
"""
totalCount: Int
}
"""
Edge in origin connection
"""
type OriginEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Origin object
"""
node: Origin
}
"""
A software origin object
"""
type Origin implements Node {
"""
Unique identifier
"""
id: ID!
"""
Origin URL
"""
url: String!
"""
Connection to all the visit objects for the origin
"""
visits(
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after this cursor
"""
after: String
): VisitConnection!
"""
Latest visit object for the origin
"""
latestVisit: Visit
"""
Connection to all the snapshots for the origin
"""
snapshots(
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after this cursor
"""
after: String
): SnapshotConnection
}
"""
Connection to origin visits
"""
type VisitConnection {
"""
List of visit edges
"""
edges: [VisitEdge]
"""
List of visit objects
"""
nodes: [Visit]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of visit objects in the connection
"""
totalCount: Int
}
"""
Edge in origin visit connection
"""
type VisitEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Visit object
"""
node: Visit
}
"""
An origin visit object
"""
type Visit implements Node {
"""
Unique identifier
"""
id: ID!
"""
Visit number for the origin
"""
visitId: Int
"""
Visit date ISO-8601 encoded
"""
date: DateTime!
"""
Type of the origin visited. Eg: git/hg/svn/tar/deb
"""
type: String
"""
Connection to all the status objects for the visit
"""
status(
"""
Returns the first _n_ elements from the list
"""
first: Int
"""
Returns the page after this cursor
"""
after: String
): VisitStatusConnection
"""
Latest status object for the Visit
"""
latestStatus: VisitStatus
}
"""
Connection to visit status
"""
type VisitStatusConnection {
"""
List of visit status edges
"""
edges: [VisitStatusEdge]
"""
List of visit status objects
"""
nodes: [VisitStatus]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of visit status objects in the connection
"""
totalCount: Int
}
"""
Edge in visit status connection
"""
type VisitStatusEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Visit status object
"""
node: VisitStatus
}
"""
A visit status object
"""
type VisitStatus {
"""
Status string of the visit (either full, partial or ongoing)
"""
status: String!
"""
ISO-8601 encoded date string
"""
date: DateTime!
"""
Snapshot object
"""
snapshot: Snapshot
"""
Type of the origin visited. Eg: git/hg/svn/tar/deb
"""
type: String
}
"""
Connection to snapshots
"""
type SnapshotConnection {
"""
List of snapshot edges
"""
edges: [SnapshotEdge]
"""
List of snapshot objects
"""
nodes: [Snapshot]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of snapshot objects in the connection
"""
totalCount: Int
}
"""
Edge in snapshot connection
"""
type SnapshotEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Snapshot object
"""
node: Snapshot
}
"""
A snapshot object
"""
type Snapshot implements MerkleNode & Node {
"""
Unique identifier
"""
id: ID!
"""
SWHID of the snapshot object
"""
swhid: SWHID!
"""
Connection to all the snapshot branches
"""
branches(
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after this cursor
"""
after: String
"""
Filter by branch target types
"""
types: [BranchTargetType]
"""
Filter by branch name
"""
nameInclude: String
): BranchConnection
}
"""
Connection to snapshot branches
"""
type BranchConnection {
"""
List of branch edges
"""
edges: [BranchConnectionEdge]
"""
List of branch objects
"""
nodes: [Branch]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of branch objects in the connection
"""
totalCount: Int
}
"""
Edge in snapshot branch connection
"""
type BranchConnectionEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Branch object
"""
node: Branch
}
"""
A user object
"""
type Person {
"""
User's email address
"""
email: BinaryString
"""
User's name
"""
name: BinaryString
"""
User's full name
"""
fullname: BinaryString
}
"""
Possible branch target objects
"""
union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot
"""
Possible Branch target types
"""
enum BranchTargetType {
revision
release
alias
content
directory
snapshot
}
"""
A snapshot branch object
"""
type Branch {
"""
Branch name
"""
name: BinaryString
"""
Type of Branch target
"""
targetType: BranchTargetType
"""
Branch target object
"""
target: BranchTarget
}
"""
Connection to revisions
"""
type RevisionConnection {
"""
List of revision edges
"""
edges: [RevisionEdge]
"""
List of revision objects
"""
nodes: [Revision]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of revision objects in the connection
"""
totalCount: Int
}
"""
Edge in revision connection
"""
type RevisionEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Revision object
"""
node: Revision
}
"""
A revision object
"""
type Revision implements MerkleNode & Node {
"""
Unique identifier
"""
id: ID!
"""
SWHID of the revision object
"""
swhid: SWHID!
"""
Message associated to the revision
"""
message: BinaryString
"""
"""
author: Person
"""
"""
committer: Person
"""
Revision date ISO-8601 encoded
"""
date: DateTime
"""
Type of the revision, eg: git/hg
"""
type: String
"""
The unique directory object that revision points to
"""
directory: Directory
"""
Connection to all the parents of the revision
"""
parents(
"""
Returns the first _n_ elements from the list
"""
first: Int
"""
Returns the page after this cursor
"""
after: String
): RevisionConnection
"""
Connection to all the revisions heading to this one
aka the commit log
"""
revisionLog(
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after the cursor
"""
after: String
): RevisionConnection
}
"""
Possible release target objects
"""
union ReleaseTarget = Release | Revision | Directory | Content
"""
Possible release target types
"""
enum ReleaseTargetType {
release
revision
content
directory
}
"""
A release object
"""
type Release implements MerkleNode & Node {
"""
Unique identifier
"""
id: ID!
"""
SWHID of the release object
"""
swhid: SWHID!
"""
The name of the release
"""
name: BinaryString
"""
The message associated to the release
"""
message: BinaryString
"""
"""
author: Person
"""
Release date ISO-8601 encoded
"""
date: DateTime
"""
Type of release target
"""
targetType: ReleaseTargetType
"""
Release target object
"""
target: ReleaseTarget
}
"""
Connection to directory entries
"""
type DirectoryEntryConnection {
"""
List of directory entry edges
"""
edges: [DirectoryEntryEdge]
"""
List of directory entry objects
"""
nodes: [DirectoryEntry]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of directory entry objects in the connection
"""
totalCount: Int
}
"""
Edge in directory entry connection
"""
type DirectoryEntryEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
Directory entry object
"""
node: DirectoryEntry
}
"""
Possible directory entry target objects
"""
union DirectoryEntryTarget = Directory | Content
"""
Possible directory entry types
"""
enum DirectoryEntryTargetType {
dir
file
rev
}
"""
A directory entry object
"""
type DirectoryEntry {
"""
The directory entry name
"""
name: BinaryString
"""
Directory entry object type; can be file, dir or rev
"""
targetType: DirectoryEntryTargetType
"""
Directory entry target object
"""
target: DirectoryEntryTarget
}
"""
A directory object
"""
type Directory implements MerkleNode & Node {
"""
Unique identifier
"""
id: ID!
"""
SWHID of the directory object
"""
swhid: SWHID!
"""
Connection to the directory entries
"""
entries(
"""
Returns the first _n_ elements from the list
"""
first: Int
"""
Returns the page after this cursor
"""
after: String
): DirectoryEntryConnection
}
"""
An object with different content checksums
"""
type ContentChecksum {
blake2s256: String
sha1: String
sha1_git: String
sha256: String
}
"""
Object with different content data representations
"""
type ContentData {
"""
URL to download the file data
"""
url: String
}
type ContentFileType {
"""
Detected content encoding
"""
encoding: String
"""
Detected MIME type of the content
"""
mimetype: String
}
type ContentLanguage {
"""
Detected programming language if any
"""
lang: String
}
type ContentLicense {
"""
Array of strings containing the detected license names
"""
licenses: [String]
}
"""
A content object
"""
type Content implements MerkleNode & Node {
"""
Unique identifier
"""
id: ID!
"""
SWHID of the content object
"""
swhid: SWHID!
"""
Checksums for the content
"""
checksum: ContentChecksum
"""
Length of the content in bytes
"""
length: Int
"""
Content status, visible or hidden
"""
status: String
"""
File content
"""
data: ContentData
"""
Information about the content MIME type
"""
fileType: ContentFileType
"""
Information about the programming language used in the content
"""
language: ContentLanguage
"""
Information about the license of the content
"""
license: ContentLicense
}
"""
Connection to SearchResults
"""
type SearchResultConnection {
"""
List of SearchResult edges
"""
edges: [SearchResultEdge]
"""
List of SearchResult objects
"""
nodes: [SearchResult]
"""
Information for pagination
"""
pageInfo: PageInfo!
"""
Total number of result objects in the connection
"""
totalCount: Int
}
"""
Edge in SearchResult connection
"""
type SearchResultEdge {
"""
Cursor to request the next page after the item
"""
cursor: String!
"""
SearchResult object
"""
node: SearchResult
}
union SearchResultTarget = Origin | Revision | Release | Content | Directory | Snapshot
enum SearchResultTargetType {
origin
revision
release
content
directory
snapshot
}
"""
A SearchResult object
"""
type SearchResult {
"""
Result target type
"""
targetType: SearchResultTargetType
"""
Result target object
"""
target: SearchResultTarget
}
"""
The query root of the GraphQL interface.
"""
type Query {
"""
Get an origin with its url
"""
origin(
"""
URL of the Origin
"""
url: String!
): Origin
"""
Get a Connection to all the origins
"""
origins(
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after the cursor
"""
after: String
"""
Filter origins with a URL pattern
"""
urlPattern: String
): OriginConnection
"""
Get the visit object with an origin URL and a visit id
"""
visit(
"""
URL of the origin
"""
originUrl: String!
"""
Visit id to get
"""
visitId: Int!
): Visit
"""
Get the snapshot with a SWHID
"""
snapshot(
"""
SWHID of the snapshot object
"""
swhid: SWHID!
): Snapshot
"""
Get the revision with a SWHID
"""
revision(
"""
SWHID of the revision object
"""
swhid: SWHID!
): Revision
"""
Get the release with a SWHID
"""
release(
"""
SWHID of the release object
"""
swhid: SWHID!
): Release
"""
Get the directory with a SWHID
"""
directory(
"""
SWHID of the directory object
"""
swhid: SWHID!
): Directory
+ """
+ Get a directory entry with directory SWHID and a path
+ """
+ directoryEntry(
+ """
+ SWHID of the directory object
+ """
+ swhid: SWHID!
+
+ """
+ Relative path to the requested object
+ """
+ path: String!
+ ): DirectoryEntry
+
"""
Get the content with a SWHID
"""
content(
"""
SWHID of the content object
"""
swhid: SWHID!
): Content
"""
Get the content by one or more hashes
Use multiple hashes for an accurate result
"""
contentByHash(
"""
List of hashType:hashValue strings
"""
checksums: [ContentHash]!
): Content
"""
Resolve the given SWHID to an object
"""
resolveSwhid(
"""
SWHID to look for
"""
swhid: SWHID!
): SearchResultConnection!
"""
Search in SWH
"""
search(
"""
String to search for
"""
query: String!
"""
Returns the first _n_ elements from the list
"""
first: Int!
"""
Returns the page after the cursor
"""
after: String
): SearchResultConnection!
}
diff --git a/swh/graphql/tests/data.py b/swh/graphql/tests/data.py
index beed8b6..1c9f983 100644
--- a/swh/graphql/tests/data.py
+++ b/swh/graphql/tests/data.py
@@ -1,107 +1,130 @@
# 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.model.model import ObjectType, Release, Revision, RevisionType
+from swh.model.model import (
+ Directory,
+ DirectoryEntry,
+ ObjectType,
+ Release,
+ Revision,
+ RevisionType,
+)
from swh.model.tests import swh_model_data
def populate_search_data(search):
search.origin_update({"url": origin.url} for origin in get_origins())
def get_origins():
return swh_model_data.ORIGINS
def get_snapshots():
return swh_model_data.SNAPSHOTS
def get_releases():
return swh_model_data.RELEASES
def get_revisions():
return swh_model_data.REVISIONS
def get_contents():
return swh_model_data.CONTENTS
def get_directories():
return swh_model_data.DIRECTORIES
def get_releases_with_target():
"""
GraphQL will not return a target object unless the target id
is present in the DB.
Return release objects with real targets instead of dummy
targets in swh.model.tests.swh_model_data
"""
with_revision = Release(
name=b"v0.0.1",
target_type=ObjectType.REVISION,
target=get_revisions()[0].id,
message=b"foo",
synthetic=False,
)
with_release = Release(
name=b"v0.0.1",
target_type=ObjectType.RELEASE,
target=get_releases()[0].id,
message=b"foo",
synthetic=False,
)
with_directory = Release(
name=b"v0.0.1",
target_type=ObjectType.DIRECTORY,
target=get_directories()[0].id,
message=b"foo",
synthetic=False,
)
with_content = Release(
name=b"v0.0.1",
target_type=ObjectType.CONTENT,
target=get_contents()[0].sha1_git,
message=b"foo",
synthetic=False,
)
return [with_revision, with_release, with_directory, with_content]
def get_revisions_with_parents():
"""
Revisions with real revisions as parents
"""
return [
Revision(
message=b"hello",
date=swh_model_data.DATES[0],
committer=swh_model_data.COMMITTERS[0],
author=swh_model_data.COMMITTERS[0],
committer_date=swh_model_data.DATES[0],
type=RevisionType.GIT,
directory=b"\x01" * 20,
synthetic=False,
parents=(get_revisions()[0].id, get_revisions()[1].id),
)
]
+def get_directories_with_nested_path():
+ return [
+ Directory(
+ entries=(
+ DirectoryEntry(
+ name=b"sub-dir",
+ perms=0o644,
+ type="dir",
+ target=get_directories()[1].id,
+ ),
+ )
+ )
+ ]
+
+
GRAPHQL_EXTRA_TEST_OBJECTS = {
"release": get_releases_with_target(),
"revision": get_revisions_with_parents(),
+ "directory": get_directories_with_nested_path(),
}
def populate_dummy_data(storage):
for object_type, objects in swh_model_data.TEST_OBJECTS.items():
method = getattr(storage, object_type + "_add")
method(objects)
for object_type, objects in GRAPHQL_EXTRA_TEST_OBJECTS.items():
method = getattr(storage, object_type + "_add")
method(objects)
diff --git a/swh/graphql/tests/functional/test_directory_entry.py b/swh/graphql/tests/functional/test_directory_entry.py
index 411435a..05c286d 100644
--- a/swh/graphql/tests/functional/test_directory_entry.py
+++ b/swh/graphql/tests/functional/test_directory_entry.py
@@ -1,37 +1,115 @@
# 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
import pytest
+from swh.graphql import server
+from swh.model.swhids import CoreSWHID, ObjectType
+
from . import utils
-from ..data import get_directories
+from ..data import get_directories, get_directories_with_nested_path
+
+
+def test_get_directory_entry_missing_path(client):
+ directory = get_directories()[0]
+ path = "missing"
+ query_str = """
+ {
+ directoryEntry(swhid: "%s", path: "%s") {
+ name {
+ text
+ }
+ targetType
+ target {
+ ...on Content {
+ swhid
+ }
+ }
+ }
+ }
+ """ % (
+ directory.swhid(),
+ path,
+ )
+ utils.assert_missing_object(client, query_str, "directoryEntry")
+
+
+@pytest.mark.parametrize(
+ "directory", get_directories() + get_directories_with_nested_path()
+)
+def test_get_directory_entry(client, directory):
+ storage = server.get_storage()
+ query_str = """
+ {
+ directoryEntry(swhid: "%s", path: "%s") {
+ name {
+ text
+ }
+ targetType
+ target {
+ ...on Content {
+ swhid
+ }
+ ...on Directory {
+ swhid
+ }
+ }
+ }
+ }
+ """
+ for entry in storage.directory_ls(directory.id, recursive=True):
+ if entry["type"] == "rev":
+ # FIXME, Revision is not supported as a directory entry target yet
+ continue
+ query = query_str % (
+ directory.swhid(),
+ entry["name"].decode(),
+ )
+ data, _ = utils.get_query_response(
+ client,
+ query,
+ )
+ swhid = None
+ if entry["type"] == "file" and entry["sha1_git"] is not None:
+ swhid = CoreSWHID(
+ object_type=ObjectType.CONTENT, object_id=entry["sha1_git"]
+ )
+ elif entry["type"] == "dir" and entry["target"] is not None:
+ swhid = CoreSWHID(
+ object_type=ObjectType.DIRECTORY, object_id=entry["target"]
+ )
+ assert data["directoryEntry"] == {
+ "name": {"text": entry["name"].decode()},
+ "target": {"swhid": str(swhid)} if swhid else None,
+ "targetType": entry["type"],
+ }
@pytest.mark.parametrize("directory", get_directories())
def test_get_directory_entry_connection(client, directory):
query_str = """
{
directory(swhid: "%s") {
swhid
entries {
nodes {
targetType
name {
text
}
}
}
}
}
"""
data, _ = utils.get_query_response(client, query_str % directory.swhid())
directory_entries = data["directory"]["entries"]["nodes"]
assert len(directory_entries) == len(directory.entries)
output = [
{"name": {"text": de.name.decode()}, "targetType": de.type}
for de in directory.entries
]
for each_entry in output:
assert each_entry in directory_entries
diff --git a/swh/graphql/tests/unit/resolvers/test_resolvers.py b/swh/graphql/tests/unit/resolvers/test_resolvers.py
index 1093cef..99fc127 100644
--- a/swh/graphql/tests/unit/resolvers/test_resolvers.py
+++ b/swh/graphql/tests/unit/resolvers/test_resolvers.py
@@ -1,131 +1,131 @@
# 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
import pytest
from swh.graphql import resolvers
from swh.graphql.resolvers import resolvers as rs
class TestResolvers:
""" """
@pytest.fixture
def dummy_node(self):
return {"test": "test"}
@pytest.mark.parametrize(
"resolver_func, node_cls",
[
(rs.origin_resolver, resolvers.origin.OriginNode),
(rs.visit_resolver, resolvers.visit.OriginVisitNode),
(rs.latest_visit_resolver, resolvers.visit.LatestVisitNode),
(
rs.latest_visit_status_resolver,
resolvers.visit_status.LatestVisitStatusNode,
),
(rs.snapshot_resolver, resolvers.snapshot.SnapshotNode),
(rs.visit_snapshot_resolver, resolvers.snapshot.VisitSnapshotNode),
(rs.revision_resolver, resolvers.revision.RevisionNode),
(rs.revision_directory_resolver, resolvers.directory.RevisionDirectoryNode),
(rs.release_resolver, resolvers.release.ReleaseNode),
(rs.directory_resolver, resolvers.directory.DirectoryNode),
(rs.content_resolver, resolvers.content.ContentNode),
],
)
def test_node_resolver(self, mocker, dummy_node, resolver_func, node_cls):
mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node)
node_obj = resolver_func(None, None)
# assert the _get_node method is called on the right object
assert isinstance(node_obj, node_cls)
assert mock_get.assert_called
@pytest.mark.parametrize(
"resolver_func, connection_cls",
[
(rs.origins_resolver, resolvers.origin.OriginConnection),
(rs.visits_resolver, resolvers.visit.OriginVisitConnection),
(rs.origin_snapshots_resolver, resolvers.snapshot.OriginSnapshotConnection),
(rs.visitstatus_resolver, resolvers.visit_status.VisitStatusConnection),
(
rs.snapshot_branches_resolver,
resolvers.snapshot_branch.SnapshotBranchConnection,
),
(rs.revision_parents_resolver, resolvers.revision.ParentRevisionConnection),
# (rs.revision_log_resolver, resolvers.revision.LogRevisionConnection),
(
- rs.directory_entry_resolver,
+ rs.directory_entries_resolver,
resolvers.directory_entry.DirectoryEntryConnection,
),
],
)
def test_connection_resolver(self, resolver_func, connection_cls):
connection_obj = resolver_func(None, None)
# assert the right object is returned
assert isinstance(connection_obj, connection_cls)
@pytest.mark.parametrize(
"branch_type, node_cls",
[
("revision", resolvers.revision.TargetRevisionNode),
("release", resolvers.release.TargetReleaseNode),
("directory", resolvers.directory.TargetDirectoryNode),
("content", resolvers.content.TargetContentNode),
("snapshot", resolvers.snapshot.TargetSnapshotNode),
],
)
def test_snapshot_branch_target_resolver(
self, mocker, dummy_node, branch_type, node_cls
):
obj = mocker.Mock(type=branch_type)
mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node)
node_obj = rs.snapshot_branch_target_resolver(obj, None)
assert isinstance(node_obj, node_cls)
assert mock_get.assert_called
@pytest.mark.parametrize(
"target_type, node_cls",
[
("revision", resolvers.revision.TargetRevisionNode),
("release", resolvers.release.TargetReleaseNode),
("directory", resolvers.directory.TargetDirectoryNode),
("content", resolvers.content.TargetContentNode),
],
)
def test_release_target_resolver(self, mocker, dummy_node, target_type, node_cls):
obj = mocker.Mock(target_type=(mocker.Mock(value=target_type)))
mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node)
node_obj = rs.release_target_resolver(obj, None)
assert isinstance(node_obj, node_cls)
assert mock_get.assert_called
@pytest.mark.parametrize(
"target_type, node_cls",
[
("dir", resolvers.directory.TargetDirectoryNode),
("file", resolvers.content.TargetContentNode),
],
)
def test_directory_entry_target_resolver(
self, mocker, dummy_node, target_type, node_cls
):
obj = mocker.Mock(type=target_type)
mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node)
node_obj = rs.directory_entry_target_resolver(obj, None)
assert isinstance(node_obj, node_cls)
assert mock_get.assert_called
def test_union_resolver(self, mocker):
obj = mocker.Mock()
obj.is_type_of.return_value = "test"
assert rs.union_resolver(obj) == "test"
def test_binary_string_text_resolver(self):
text = rs.binary_string_text_resolver(b"test", None)
assert text == "test"
def test_binary_string_base64_resolver(self):
b64string = rs.binary_string_base64_resolver(b"test", None)
assert b64string == "dGVzdA=="
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Thu, Jul 3, 10:27 AM (2 w, 4 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3275591
Attached To
rDGQL GraphQL API
Event Timeline
Log In to Comment