Skip to content

Commit

Permalink
Expose GraphQL search anywhere query (#3688)
Browse files Browse the repository at this point in the history
The search is now using a dedicated query. The frontend code has also
been adapted to use this new query.

The search logic will try to look for a specific node if the search
parameter looks like a valid UUID. It will default to the previous
search behaviour in other cases.
  • Loading branch information
gmazoyer authored Jun 20, 2024
1 parent 1df2505 commit cb25aa5
Show file tree
Hide file tree
Showing 8 changed files with 203 additions and 8 deletions.
2 changes: 2 additions & 0 deletions backend/infrahub/graphql/queries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .ipam import InfrahubIPAddressGetNextAvailable, InfrahubIPPrefixGetNextAvailable
from .relationship import Relationship
from .resource_manager import InfrahubResourcePoolAllocated, InfrahubResourcePoolUtilization
from .search import InfrahubSearchAnywhere
from .status import InfrahubStatus
from .task import Task

Expand All @@ -15,6 +16,7 @@
"DiffSummary",
"DiffSummaryOld",
"InfrahubInfo",
"InfrahubSearchAnywhere",
"InfrahubStatus",
"InfrahubIPAddressGetNextAvailable",
"InfrahubIPPrefixGetNextAvailable",
Expand Down
78 changes: 78 additions & 0 deletions backend/infrahub/graphql/queries/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Optional

from graphene import Boolean, Field, Int, List, ObjectType, String
from infrahub_sdk.utils import extract_fields_first_node, is_valid_uuid

from infrahub.core.constants import InfrahubKind
from infrahub.core.manager import NodeManager

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo

from infrahub.core.protocols import CoreNode
from infrahub.graphql import GraphqlContext


class Node(ObjectType):
id = Field(String, required=True)
kind = Field(String, required=True, description="The node kind")


class NodeEdge(ObjectType):
node = Field(Node, required=True)


class NodeEdges(ObjectType):
count = Field(Int, required=True)
edges = Field(List(of_type=NodeEdge, required=True), required=False)


async def search_resolver(
root: dict, # pylint: disable=unused-argument
info: GraphQLResolveInfo,
q: str,
limit: int = 10,
partial_match: bool = True,
) -> dict[str, Any]:
context: GraphqlContext = info.context
response: dict[str, Any] = {}
result: list[CoreNode] = []

fields = await extract_fields_first_node(info)

if is_valid_uuid(q):
matching: Optional[CoreNode] = await NodeManager.get_one(
db=context.db, branch=context.branch, at=context.at, id=q
)
if matching:
result.append(matching)
else:
result.extend(
await NodeManager.query(
db=context.db,
branch=context.branch,
schema=InfrahubKind.NODE,
filters={"any__value": q},
limit=limit,
partial_match=partial_match,
)
)

if "edges" in fields and result:
response["edges"] = [{"node": {"id": obj.id, "kind": obj.get_kind()}} for obj in result]

if "count" in fields:
response["count"] = len(result)

return response


InfrahubSearchAnywhere = Field(
NodeEdges,
q=String(required=True),
limit=Int(required=False),
partial_match=Boolean(required=False),
resolver=search_resolver,
)
3 changes: 3 additions & 0 deletions backend/infrahub/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
InfrahubIPPrefixGetNextAvailable,
InfrahubResourcePoolAllocated,
InfrahubResourcePoolUtilization,
InfrahubSearchAnywhere,
InfrahubStatus,
Relationship,
Task,
Expand Down Expand Up @@ -92,6 +93,8 @@ class InfrahubBaseQuery(ObjectType):
InfrahubInfo = InfrahubInfo
InfrahubStatus = InfrahubStatus

InfrahubSearchAnywhere = InfrahubSearchAnywhere

InfrahubTask = Task

IPAddressGetNextAvailable = InfrahubIPAddressGetNextAvailable
Expand Down
95 changes: 95 additions & 0 deletions backend/tests/unit/graphql/queries/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from graphql import graphql

from infrahub.core.branch import Branch
from infrahub.core.node import Node
from infrahub.database import InfrahubDatabase
from infrahub.graphql import prepare_graphql_params

SEARCH_QUERY = """
query ($search: String!) {
InfrahubSearchAnywhere(q: $search) {
count
edges {
node {
id
kind
}
}
}
}
"""


async def test_search_anywhere_by_uuid(
db: InfrahubDatabase,
car_accord_main: Node,
car_camry_main: Node,
car_volt_main: Node,
car_prius_main: Node,
car_yaris_main: Node,
branch: Branch,
):
gql_params = prepare_graphql_params(db=db, include_subscription=False, branch=branch)

result = await graphql(
schema=gql_params.schema,
source=SEARCH_QUERY,
context_value=gql_params.context,
root_value=None,
variable_values={"search": car_accord_main.id},
)

assert result.errors is None
assert result.data
assert result.data["InfrahubSearchAnywhere"]["count"] == 1
assert result.data["InfrahubSearchAnywhere"]["edges"][0]["node"]["id"] == car_accord_main.id
assert result.data["InfrahubSearchAnywhere"]["edges"][0]["node"]["kind"] == car_accord_main.get_kind()


async def test_search_anywhere_by_string(
db: InfrahubDatabase,
person_john_main: Node,
person_jane_main: Node,
car_accord_main: Node,
car_camry_main: Node,
car_volt_main: Node,
car_prius_main: Node,
car_yaris_main: Node,
branch: Branch,
):
gql_params = prepare_graphql_params(db=db, include_subscription=False, branch=branch)

result = await graphql(
schema=gql_params.schema,
source=SEARCH_QUERY,
context_value=gql_params.context,
root_value=None,
variable_values={"search": "prius"},
)

assert result.errors is None
assert result.data
assert result.data["InfrahubSearchAnywhere"]["count"] == 1
assert result.data["InfrahubSearchAnywhere"]["edges"][0]["node"]["id"] == car_prius_main.id
assert result.data["InfrahubSearchAnywhere"]["edges"][0]["node"]["kind"] == car_prius_main.get_kind()

result = await graphql(
schema=gql_params.schema,
source=SEARCH_QUERY,
context_value=gql_params.context,
root_value=None,
variable_values={"search": "j"},
)

assert result.errors is None
assert result.data
assert result.data["InfrahubSearchAnywhere"]["count"] == 2

node_ids = []
node_kinds = []
for edge in result.data["InfrahubSearchAnywhere"]["edges"]:
node_ids.append(edge["node"]["id"])
node_kinds.append(edge["node"]["kind"])

assert node_ids == [person_john_main.id, person_jane_main.id]
assert node_kinds == [person_john_main.get_kind(), person_jane_main.get_kind()]
12 changes: 6 additions & 6 deletions frontend/app/src/components/search/search-nodes.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Skeleton } from "@/components/skeleton";
import { NODE_OBJECT, SCHEMA_ATTRIBUTE_KIND } from "@/config/constants";
import { NODE_OBJECT, SCHEMA_ATTRIBUTE_KIND, SEARCH_QUERY_NAME } from "@/config/constants";
import { getObjectDetailsPaginated } from "@/graphql/queries/objects/getObjectDetails";
import { SEARCH } from "@/graphql/queries/objects/search";
import { useDebounce } from "@/hooks/useDebounce";
Expand Down Expand Up @@ -36,7 +36,7 @@ export const SearchNodes = ({ query }: SearchProps) => {
);
}

const results = (data || previousData)?.[NODE_OBJECT];
const results = (data || previousData)?.[SEARCH_QUERY_NAME];

if (!results || results?.count === 0) return null;

Expand All @@ -54,16 +54,16 @@ export const SearchNodes = ({ query }: SearchProps) => {
type NodesOptionsProps = {
node: {
id: string;
__typename: string;
kind: string;
};
};

const NodesOptions = ({ node }: NodesOptionsProps) => {
const schemaList = useAtomValue(schemaState);
const genericList = useAtomValue(genericsState);

const schema = schemaList.find((s) => s.kind === node.__typename);
const generic = genericList.find((s) => s.kind === node.__typename);
const schema = schemaList.find((s) => s.kind === node.kind);
const generic = genericList.find((s) => s.kind === node.kind);

const schemaData = generic || schema;

Expand All @@ -86,7 +86,7 @@ const NodesOptions = ({ node }: NodesOptionsProps) => {

if (loading) return <SearchResultNodeSkeleton />;

const objectDetailsData = schemaData && data?.[node.__typename]?.edges[0]?.node;
const objectDetailsData = schemaData && data?.[node.kind]?.edges[0]?.node;
if (!objectDetailsData) return <div className="text-sm">No data found for this object</div>;

const url = constructPath(
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/src/config/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export const PROPOSED_CHANGES_EDITABLE_STATE = ["open", "closed"];

export const TASK_TAB = "tasks";

export const SEARCH_QUERY_NAME = "InfrahubSearchAnywhere";

export const SEARCH_ANY_FILTER = "any__value";

export const SEARCH_PARTIAL_MATCH = "partial_match";
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/src/graphql/queries/objects/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { gql } from "@apollo/client";

export const SEARCH = gql`
query Search($search: String!) {
CoreNode(any__value: $search, limit: 4, partial_match: true) {
InfrahubSearchAnywhere(q: $search, limit: 4, partial_match: true) {
count
edges {
node {
id
__typename
kind
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions frontend/app/tests/e2e/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,19 @@ test.describe("when searching an object", () => {
).toBeVisible();
});
});

test("display result when searching by uuid", async ({ page }) => {
await page.goto("/objects/InfraAutonomousSystem");

await page.getByRole('link', { name: 'AS174', exact: true }).click();
const uuid = await page.locator('dd').first().textContent() as string;

await test.step("open search anywhere modal", async () => {
await page.getByPlaceholder("Search anywhere").click();
await expect(page.getByTestId("search-anywhere")).toBeVisible();
});

await page.getByTestId('search-anywhere').getByPlaceholder('Search anywhere').fill(uuid);
await expect(page.getByRole('option', { name: 'AS174 174Autonomous System' })).toBeVisible();
});
});

0 comments on commit cb25aa5

Please sign in to comment.