Repository: aws/graph-explorer
Pull request #878: Re-add query editor for Gremlin
Author: login: kmcginnes
Created: 2025-04-14T19:52:09Z | Merged: 2025-05-15T22:00:22Z

<!--
Please read the [Code of Conduct](https://github.com/aws/graph-explorer/blob/main/CODE_OF_CONDUCT.md) and the [Contributing Guidelines](https://github.com/aws/graph-explorer/blob/main/CONTRIBUTING.md) before opening a pull request.
-->

## Description

This reverts commit 8ba891985fce12e45be581a08fda258371faf5a6.

This PR targets [aws:main-query-editor](https://github.com/aws/graph-explorer/tree/main-query-editor) branch instead of `main`.

I simply reverted the single commit from PR #862 and modified the changelog to account for conflicts. No other file had any conflicts, so they remain the same as when they were removed.

I also removed the feature flag since this is on its own branch, and updated the code to use Jotai instead of Recoil.

## Validation

- Smoke test

## Related Issues

<!--
  Link to the issue that is fixed by this PR (if there is one)
  e.g. Fixes #1234, Addresses #1234, Related to #1234, etc.
-->

- Resolves #863

### Check List

<!--
  ATTENTION
  Please follow this check list to ensure that you've followed all items before opening this PR
  You can check the items by adding an `x` between the brackets, like this: `[x]`
-->

- [x] I confirm that my contribution is made under the terms of the Apache 2.0
      license.
- [x] I have run `pnpm checks` to ensure code compiles and meets standards.
- [x] I have run `pnpm test` to check if all tests are passing.
- [x] I have covered new added functionality with unit tests if necessary.
- [x] I have added an entry in the `Changelog.md`.


Linked issue #1234: Remove all forward refs from codebase
Now that we use React 19 we can remove all existing forward ref usages and have ref be passed as a standard prop.

Diff:
diff --git a/Changelog.md b/Changelog.md
index 720887c3c..40af9c300 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -4,6 +4,8 @@
 
 ### Other changes
 
+- **Added** query editor for Gremlin connections
+  ([#878](https://github.com/aws/graph-explorer/pull/878))
 - **Added** ability to customize default neighbor expansion limit
   ([#925](https://github.com/aws/graph-explorer/pull/925))
 - **Improved** Improved connection details by moving the sync button near the
diff --git a/packages/graph-explorer/src/connector/emptyExplorer.ts b/packages/graph-explorer/src/connector/emptyExplorer.ts
index 406c8e139..387026b35 100644
--- a/packages/graph-explorer/src/connector/emptyExplorer.ts
+++ b/packages/graph-explorer/src/connector/emptyExplorer.ts
@@ -43,4 +43,5 @@ export const emptyExplorer: Explorer = {
       edge: null,
     };
   },
+  rawQuery: async () => toMappedQueryResults({}),
 };
diff --git a/packages/graph-explorer/src/connector/gremlin/gremlinExplorer.ts b/packages/graph-explorer/src/connector/gremlin/gremlinExplorer.ts
index d0d266f43..d5cfdcdb6 100644
--- a/packages/graph-explorer/src/connector/gremlin/gremlinExplorer.ts
+++ b/packages/graph-explorer/src/connector/gremlin/gremlinExplorer.ts
@@ -12,6 +12,7 @@ import { createLoggerFromConnection } from "@/core/connector";
 import { FeatureFlags, NormalizedConnection } from "@/core";
 import { vertexDetails } from "./vertexDetails";
 import { edgeDetails } from "./edgeDetails";
+import { rawQuery } from "./rawQuery";
 
 function _gremlinFetch(
   connection: NormalizedConnection,
@@ -136,5 +137,15 @@ export function createGremlinExplorer(
       );
       return result;
     },
+    async rawQuery(req, options) {
+      options ??= {};
+      options.queryId = v4();
+      remoteLogger.info("[Gremlin Explorer] Fetching raw query...");
+      const result = await rawQuery(
+        _gremlinFetch(connection, featureFlags, options),
+        req
+      );
+      return result;
+    },
   } satisfies Explorer;
 }
diff --git a/packages/graph-explorer/src/connector/gremlin/rawQuery.ts b/packages/graph-explorer/src/connector/gremlin/rawQuery.ts
new file mode 100644
index 000000000..6691f48fe
--- /dev/null
+++ b/packages/graph-explorer/src/connector/gremlin/rawQuery.ts
@@ -0,0 +1,42 @@
+import { logger, query } from "@/utils";
+import {
+  ErrorResponse,
+  RawQueryRequest,
+  RawQueryResponse,
+  toMappedQueryResults,
+} from "../useGEFetchTypes";
+import { GAnyValue, GremlinFetch } from "./types";
+import { mapResults } from "./mappers/mapResults";
+import isErrorResponse from "../utils/isErrorResponse";
+
+type Response = {
+  requestId: string;
+  status: {
+    message: string;
+    code: number;
+  };
+  result: {
+    data: GAnyValue;
+  };
+};
+
+export async function rawQuery(
+  gremlinFetch: GremlinFetch,
+  request: RawQueryRequest
+): Promise<RawQueryResponse> {
+  const template = query`${request.query}`;
+
+  if (template.length <= 0) {
+    return toMappedQueryResults({});
+  }
+
+  // Fetch the results
+  const data = await gremlinFetch<Response | ErrorResponse>(template);
+  if (isErrorResponse(data)) {
+    logger.error(data.detailedMessage);
+    throw new Error(data.detailedMessage);
+  }
+
+  // Map the results
+  return mapResults(data.result.data);
+}
diff --git a/packages/graph-explorer/src/connector/openCypher/openCypherExplorer.ts b/packages/graph-explorer/src/connector/openCypher/openCypherExplorer.ts
index 92a5e5b00..743448243 100644
--- a/packages/graph-explorer/src/connector/openCypher/openCypherExplorer.ts
+++ b/packages/graph-explorer/src/connector/openCypher/openCypherExplorer.ts
@@ -102,6 +102,13 @@ export function createOpenCypherExplorer(
         req
       );
     },
+    // eslint-disable-next-line @typescript-eslint/require-await
+    async rawQuery(_req, _options) {
+      remoteLogger.info("[openCypher Explorer] Fetching raw query...");
+      throw new Error(
+        "Raw query functionality is not implemented for openCypher"
+      );
+    },
   } satisfies Explorer;
 }
 
diff --git a/packages/graph-explorer/src/connector/queries.ts b/packages/graph-explorer/src/connector/queries.ts
index a84576b84..b7cd969b3 100644
--- a/packages/graph-explorer/src/connector/queries.ts
+++ b/packages/graph-explorer/src/connector/queries.ts
@@ -5,6 +5,8 @@ import {
   Explorer,
   KeywordSearchRequest,
   KeywordSearchResponse,
+  RawQueryRequest,
+  RawQueryResponse,
   SchemaResponse,
   toMappedQueryResults,
   VertexDetailsRequest,
@@ -144,6 +146,27 @@ export function edgeDetailsQuery(
   });
 }
 
+export function rawQueryQuery(
+  request: RawQueryRequest,
+  updateSchema: (entities: { vertices: Vertex[]; edges: Edge[] }) => void,
+  explorer: Explorer,
+  queryClient: QueryClient
+) {
+  return queryOptions({
+    queryKey: ["db", "raw-query", request, explorer, queryClient],
+    queryFn: async ({ signal }): Promise<RawQueryResponse> => {
+      const results = await explorer.rawQuery(request, { signal });
+
+      // Update the schema and the cache
+      updateVertexDetailsCache(explorer, queryClient, results.vertices);
+      updateEdgeDetailsCache(explorer, queryClient, results.edges);
+      updateSchema(results);
+
+      return results;
+    },
+  });
+}
+
 /** Sets the vertex details cache for the given vertices. */
 export function updateVertexDetailsCache(
   explorer: Explorer,
diff --git a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts
index 0e79b4b2f..4148db711 100644
--- a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts
+++ b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts
@@ -221,5 +221,10 @@ export function createSparqlExplorer(
         req
       );
     },
+    // eslint-disable-next-line @typescript-eslint/require-await
+    async rawQuery(_req, _options) {
+      remoteLogger.info("[SPARQL Explorer] Fetching raw query...");
+      throw new Error("Raw query functionality is not implemented for SPARQL");
+    },
   } satisfies Explorer;
 }
diff --git a/packages/graph-explorer/src/connector/useGEFetchTypes.ts b/packages/graph-explorer/src/connector/useGEFetchTypes.ts
index b50ff10cf..984ef0bdc 100644
--- a/packages/graph-explorer/src/connector/useGEFetchTypes.ts
+++ b/packages/graph-explorer/src/connector/useGEFetchTypes.ts
@@ -229,6 +229,12 @@ export type EdgeDetailsResponse = {
   edge: Edge | null;
 };
 
+export type RawQueryRequest = {
+  query: string;
+};
+
+export type RawQueryResponse = MappedQueryResults;
+
 /**
  * Abstracted interface to the common database queries used by
  * Graph Explorer.
@@ -260,4 +266,8 @@ export type Explorer = {
     req: EdgeDetailsRequest,
     options?: ExplorerRequestOptions
   ) => Promise<EdgeDetailsResponse>;
+  rawQuery: (
+    req: RawQueryRequest,
+    options?: ExplorerRequestOptions
+  ) => Promise<RawQueryResponse>;
 };
diff --git a/packages/graph-explorer/src/core/StateProvider/useResetState.ts b/packages/graph-explorer/src/core/StateProvider/useResetState.ts
index 591d7cc7d..455966c5a 100644
--- a/packages/graph-explorer/src/core/StateProvider/useResetState.ts
+++ b/packages/graph-explorer/src/core/StateProvider/useResetState.ts
@@ -21,6 +21,8 @@ import {
 import { isRestorePreviousSessionAvailableAtom } from "./graphSession";
 import { RESET, useAtomCallback } from "jotai/utils";
 import { useCallback } from "react";
+import { selectedTabAtom } from "@/modules/SearchSidebar";
+import { queryTextAtom } from "@/modules/SearchSidebar/QuerySearchTabContent";
 
 export default function useResetState() {
   return useAtomCallback(
@@ -45,6 +47,10 @@ export default function useResetState() {
       set(selectedAttributeAtom, RESET);
       set(partialMatchAtom, RESET);
 
+      // Query editor
+      set(selectedTabAtom, RESET);
+      set(queryTextAtom, RESET);
+
       // Previous session
       set(isRestorePreviousSessionAvailableAtom, RESET);
     }, [])
diff --git a/packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx b/packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
new file mode 100644
index 000000000..7a9302498
--- /dev/null
+++ b/packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
@@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
+      } else {
+        query.refetch();
+      }
+    },
+    [query, queryText, setQueryText]
+  );
+
+  return (
+    <div className="bg-background-default flex h-full flex-col">
+      <Form {...form}>
+        <form
+          className="border-divider flex shrink-0 flex-col gap-3 border-b p-3"
+          onSubmit={form.handleSubmit(executeQuery)}
+        >
+          <FormField
+            control={form.control}
+            name="query"
+            render={({ field }) => (
+              <FormItem>
+                <Label htmlFor="query" className="sr-only">
+                  Query
+                </Label>
+                <FormControl>
+                  <TextArea
+                    {...field}
+                    aria-label="Query"
+                    className="h-full min-h-[5lh] w-full font-mono text-lg"
+                    placeholder="e.g. g.V().limit(10)"
+                    onKeyDown={e => {
+                      if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+                        e.preventDefault();
+                        form.handleSubmit(executeQuery)();
+                      }
+                    }}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+          <div className="flex gap-6">
+            <Button className="w-full" variant="filled" type="submit">
+              <CornerDownRightIcon />
+              Run query
+            </Button>
+          </div>
+        </form>
+      </Form>
+
+      <SearchResultsList query={query} />
+    </div>
+  );
+}
+
+function useQuerySearch(query: string) {
+  const explorer = useExplorer();
+  const queryClient = useQueryClient();
+  const updateSchema = useUpdateSchemaFromEntities();
+  const delayedQuery = useDeferredValue(query);
+
+  return useQuery({
+    ...rawQueryQuery(
+      { query: delayedQuery },
+      updateSchema,
+      explorer,
+      queryClient
+    ),
+    staleTime: 0,
+  });
+}
diff --git a/packages/graph-explorer/src/modules/SearchSidebar/SearchSidebar.tsx b/packages/graph-explorer/src/modules/SearchSidebar/SearchSidebar.tsx
index c07c2bda8..16186f144 100644
--- a/packages/graph-explorer/src/modules/SearchSidebar/SearchSidebar.tsx
+++ b/packages/graph-explorer/src/modules/SearchSidebar/SearchSidebar.tsx
@@ -7,12 +7,53 @@ import {
 } from "@/components";
 import { FilterSearchTabContent } from "./FilterSearchTabContent";
 import { SidebarCloseButton } from "../SidebarCloseButton";
+import { useQueryEngine } from "@/core";
+import {
+  SidebarTabs,
+  SidebarTabsContent,
+  SidebarTabsList,
+  SidebarTabsTrigger,
+} from "@/components/SidebarTabs";
+import { QuerySearchTabContent } from "./QuerySearchTabContent";
+import { CodeIcon, ListFilterIcon } from "lucide-react";
 import { cn } from "@/utils";
+import { atomWithReset } from "jotai/utils";
+import { useAtom } from "jotai";
+
+export const selectedTabAtom = atomWithReset("filter");
 
 export function SearchSidebarPanel() {
+  const queryEngine = useQueryEngine();
+  const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom);
+
+  // Hide tabs when not gremlin or the query editor feature is turned off
+  if (queryEngine !== "gremlin") {
+    return (
+      <Layout className="overflow-y-auto">
+        <FilterSearchTabContent />
+      </Layout>
+    );
+  }
+
   return (
-    <Layout className="overflow-y-auto">
-      <FilterSearchTabContent />
+    <Layout>
+      <SidebarTabs defaultValue={selectedTab} onValueChange={setSelectedTab}>
+        <SidebarTabsList>
+          <SidebarTabsTrigger value="filter">
+            <ListFilterIcon /> Filter
+          </SidebarTabsTrigger>
+          <SidebarTabsTrigger value="query">
+            <CodeIcon />
+            Query
+          </SidebarTabsTrigger>
+        </SidebarTabsList>
+        <SidebarTabsContent value="filter">
+          <FilterSearchTabContent />
+        </SidebarTabsContent>
+        <SidebarTabsContent value="query">
+          <QuerySearchTabContent />
+        </SidebarTabsContent>
+      </SidebarTabs>
     </Layout>
   );
 }
diff --git a/packages/graph-explorer/src/utils/testing/createMockExplorer.ts b/packages/graph-explorer/src/utils/testing/createMockExplorer.ts
index 1492cbb1a..18f2d80ad 100644
--- a/packages/graph-explorer/src/utils/testing/createMockExplorer.ts
+++ b/packages/graph-explorer/src/utils/testing/createMockExplorer.ts
@@ -11,5 +11,6 @@ export function createMockExplorer(): Explorer {
     fetchSchema: vi.fn(),
     edgeDetails: vi.fn(),
     vertexDetails: vi.fn(),
+    rawQuery: vi.fn(),
   };
 }


Reviews:
user: login: andreachild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T16:52:33Z
user: login: andreachild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T17:07:20Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T17:18:52Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T17:21:42Z
user: login: andreachild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:09:31Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T18:14:35Z
user: login: andreachild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:22:47Z
user: login: andreachild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T18:31:11Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T19:06:39Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T19:18:26Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T19:19:31Z
user: login: kmcginnes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T19:19:38Z

Review comments:
diff_hunk: @@ -0,0 +1,42 @@
+import { logger, query } from "@/utils";
+import {
+  ErrorResponse,
+  RawQueryRequest,
+  RawQueryResponse,
+  toMappedQueryResults,
+} from "../useGEFetchTypes";
+import { GAnyValue, GremlinFetch } from "./types";
+import { mapResults } from "./mappers/mapResults";
+import isErrorResponse from "../utils/isErrorResponse";
+
+type Response = {
+  requestId: string;
+  status: {
+    message: string;
+    code: number;
+  };
+  result: {
+    data: GAnyValue;
path: packages/graph-explorer/src/connector/gremlin/rawQuery.ts
user: login: andreachild
body: Taking look at the types of `GAnyValue` there are some missing that are present in the documentation https://tinkerpop.apache.org/docs/3.7.2/dev/io/#_core. For example float and timestamp and uuid. Do we need to worry about this since now the user can execute any kind of query?
created_at: 2025-05-15T16:52:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+import { logger, query } from "@/utils";
+import {
+  ErrorResponse,
+  RawQueryRequest,
+  RawQueryResponse,
+  toMappedQueryResults,
+} from "../useGEFetchTypes";
+import { GAnyValue, GremlinFetch } from "./types";
+import { mapResults } from "./mappers/mapResults";
+import isErrorResponse from "../utils/isErrorResponse";
+
+type Response = {
+  requestId: string;
+  status: {
+    message: string;
+    code: number;
+  };
+  result: {
+    data: GAnyValue;
+  };
+};
+
+export async function rawQuery(
+  gremlinFetch: GremlinFetch,
+  request: RawQueryRequest
+): Promise<RawQueryResponse> {
+  const template = query`${request.query}`;
+
+  if (template.length <= 0) {
+    return toMappedQueryResults({});
path: packages/graph-explorer/src/connector/gremlin/rawQuery.ts
user: login: andreachild
body: Nit: should this be an error instead?
created_at: 2025-05-15T17:07:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+import { logger, query } from "@/utils";
+import {
+  ErrorResponse,
+  RawQueryRequest,
+  RawQueryResponse,
+  toMappedQueryResults,
+} from "../useGEFetchTypes";
+import { GAnyValue, GremlinFetch } from "./types";
+import { mapResults } from "./mappers/mapResults";
+import isErrorResponse from "../utils/isErrorResponse";
+
+type Response = {
+  requestId: string;
+  status: {
+    message: string;
+    code: number;
+  };
+  result: {
+    data: GAnyValue;
+  };
+};
+
+export async function rawQuery(
+  gremlinFetch: GremlinFetch,
+  request: RawQueryRequest
+): Promise<RawQueryResponse> {
+  const template = query`${request.query}`;
+
+  if (template.length <= 0) {
+    return toMappedQueryResults({});
path: packages/graph-explorer/src/connector/gremlin/rawQuery.ts
user: login: kmcginnes
body: No, I believe an empty set of results is appropriate for an empty query string.
created_at: 2025-05-15T17:18:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,42 @@
+import { logger, query } from "@/utils";
+import {
+  ErrorResponse,
+  RawQueryRequest,
+  RawQueryResponse,
+  toMappedQueryResults,
+} from "../useGEFetchTypes";
+import { GAnyValue, GremlinFetch } from "./types";
+import { mapResults } from "./mappers/mapResults";
+import isErrorResponse from "../utils/isErrorResponse";
+
+type Response = {
+  requestId: string;
+  status: {
+    message: string;
+    code: number;
+  };
+  result: {
+    data: GAnyValue;
path: packages/graph-explorer/src/connector/gremlin/rawQuery.ts
user: login: kmcginnes
body: Added a new issue to look in to this. For this PR, I'm not concerned about it.

- #928
created_at: 2025-05-15T17:21:42Z
author_association: COLLABORATOR
diff_hunk: @@ -144,6 +146,27 @@ export function edgeDetailsQuery(
   });
 }
 
+export function rawQueryQuery(
path: packages/graph-explorer/src/connector/queries.ts
user: login: andreachild
body: Nit: isn't `rawQuery` sufficient? 😆 
created_at: 2025-05-15T18:09:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,6 +146,27 @@ export function edgeDetailsQuery(
   });
 }
 
+export function rawQueryQuery(
path: packages/graph-explorer/src/connector/queries.ts
user: login: kmcginnes
body: lol. Two different queries.

1. The query the user provided
2. The query options for `useQuery`
created_at: 2025-05-15T18:14:35Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: andreachild
body: Just checking my understanding, this means that if the user given query was modified by fixing indentation and whitespace, the modified query will replace the given query in the text box?
created_at: 2025-05-15T18:22:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
+      } else {
+        query.refetch();
+      }
+    },
+    [query, queryText, setQueryText]
+  );
+
+  return (
+    <div className="bg-background-default flex h-full flex-col">
+      <Form {...form}>
+        <form
+          className="border-divider flex shrink-0 flex-col gap-3 border-b p-3"
+          onSubmit={form.handleSubmit(executeQuery)}
+        >
+          <FormField
+            control={form.control}
+            name="query"
+            render={({ field }) => (
+              <FormItem>
+                <Label htmlFor="query" className="sr-only">
+                  Query
+                </Label>
+                <FormControl>
+                  <TextArea
+                    {...field}
+                    aria-label="Query"
+                    className="h-full min-h-[5lh] w-full font-mono text-lg"
+                    placeholder="e.g. g.V().limit(10)"
+                    onKeyDown={e => {
+                      if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+                        e.preventDefault();
+                        form.handleSubmit(executeQuery)();
+                      }
+                    }}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+          <div className="flex gap-6">
+            <Button className="w-full" variant="filled" type="submit">
+              <CornerDownRightIcon />
+              Run query
+            </Button>
+          </div>
+        </form>
+      </Form>
+
+      <SearchResultsList query={query} />
+    </div>
+  );
+}
+
+function useQuerySearch(query: string) {
+  const explorer = useExplorer();
+  const queryClient = useQueryClient();
+  const updateSchema = useUpdateSchemaFromEntities();
+  const delayedQuery = useDeferredValue(query);
+
+  return useQuery({
+    ...rawQueryQuery(
+      { query: delayedQuery },
+      updateSchema,
+      explorer,
+      queryClient
+    ),
+    staleTime: 0,
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: andreachild
body: What affect does this have on caching of query results?
created_at: 2025-05-15T18:31:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: kmcginnes
body: The original idea was that the query should only execute when the form is submitted (button is clicked) and if the query text was the same, we should refetch the query. Otherwise, TanStack Query wouldn't execute again because the text was the same. It would just give you the cached results.

It turns out it didn't work right. So the next PR is changing how all this works.
created_at: 2025-05-15T19:06:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
+      } else {
+        query.refetch();
+      }
+    },
+    [query, queryText, setQueryText]
+  );
+
+  return (
+    <div className="bg-background-default flex h-full flex-col">
+      <Form {...form}>
+        <form
+          className="border-divider flex shrink-0 flex-col gap-3 border-b p-3"
+          onSubmit={form.handleSubmit(executeQuery)}
+        >
+          <FormField
+            control={form.control}
+            name="query"
+            render={({ field }) => (
+              <FormItem>
+                <Label htmlFor="query" className="sr-only">
+                  Query
+                </Label>
+                <FormControl>
+                  <TextArea
+                    {...field}
+                    aria-label="Query"
+                    className="h-full min-h-[5lh] w-full font-mono text-lg"
+                    placeholder="e.g. g.V().limit(10)"
+                    onKeyDown={e => {
+                      if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+                        e.preventDefault();
+                        form.handleSubmit(executeQuery)();
+                      }
+                    }}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+          <div className="flex gap-6">
+            <Button className="w-full" variant="filled" type="submit">
+              <CornerDownRightIcon />
+              Run query
+            </Button>
+          </div>
+        </form>
+      </Form>
+
+      <SearchResultsList query={query} />
+    </div>
+  );
+}
+
+function useQuerySearch(query: string) {
+  const explorer = useExplorer();
+  const queryClient = useQueryClient();
+  const updateSchema = useUpdateSchemaFromEntities();
+  const delayedQuery = useDeferredValue(query);
+
+  return useQuery({
+    ...rawQueryQuery(
+      { query: delayedQuery },
+      updateSchema,
+      explorer,
+      queryClient
+    ),
+    staleTime: 0,
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: kmcginnes
body: After a bit of researching just now, none.

My intent was to essentially "turn off" caching for this query, but that's not how TanStack Query works.

My next PR will change how all this works since it uses `useMutation` instead of `useQuery`, which does not even involve the cache.
created_at: 2025-05-15T19:18:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
+      } else {
+        query.refetch();
+      }
+    },
+    [query, queryText, setQueryText]
+  );
+
+  return (
+    <div className="bg-background-default flex h-full flex-col">
+      <Form {...form}>
+        <form
+          className="border-divider flex shrink-0 flex-col gap-3 border-b p-3"
+          onSubmit={form.handleSubmit(executeQuery)}
+        >
+          <FormField
+            control={form.control}
+            name="query"
+            render={({ field }) => (
+              <FormItem>
+                <Label htmlFor="query" className="sr-only">
+                  Query
+                </Label>
+                <FormControl>
+                  <TextArea
+                    {...field}
+                    aria-label="Query"
+                    className="h-full min-h-[5lh] w-full font-mono text-lg"
+                    placeholder="e.g. g.V().limit(10)"
+                    onKeyDown={e => {
+                      if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+                        e.preventDefault();
+                        form.handleSubmit(executeQuery)();
+                      }
+                    }}
+                  />
+                </FormControl>
+              </FormItem>
+            )}
+          />
+          <div className="flex gap-6">
+            <Button className="w-full" variant="filled" type="submit">
+              <CornerDownRightIcon />
+              Run query
+            </Button>
+          </div>
+        </form>
+      </Form>
+
+      <SearchResultsList query={query} />
+    </div>
+  );
+}
+
+function useQuerySearch(query: string) {
+  const explorer = useExplorer();
+  const queryClient = useQueryClient();
+  const updateSchema = useUpdateSchemaFromEntities();
+  const delayedQuery = useDeferredValue(query);
+
+  return useQuery({
+    ...rawQueryQuery(
+      { query: delayedQuery },
+      updateSchema,
+      explorer,
+      queryClient
+    ),
+    staleTime: 0,
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: kmcginnes
body: The next PR, which will need to be rebased and re-targeted to this new trunk.

- https://github.com/aws/graph-explorer/pull/855
created_at: 2025-05-15T19:19:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+import {
+  Button,
+  Form,
+  FormControl,
+  FormField,
+  FormItem,
+  Label,
+  TextArea,
+} from "@/components";
+import { rawQueryQuery } from "@/connector";
+import { useExplorer, useUpdateSchemaFromEntities } from "@/core";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { CornerDownRightIcon } from "lucide-react";
+import { SearchResultsList } from "./SearchResultsList";
+import { useCallback, useDeferredValue } from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { logger } from "@/utils";
+import { useAtom } from "jotai";
+import { atomWithReset } from "jotai/utils";
+
+const formDataSchema = z.object({
+  query: z.string().default(""),
+});
+
+type FormData = z.infer<typeof formDataSchema>;
+
+export const queryTextAtom = atomWithReset("");
+
+export function QuerySearchTabContent() {
+  const [queryText, setQueryText] = useAtom(queryTextAtom);
+  const form = useForm<FormData>({
+    resolver: zodResolver(formDataSchema),
+    defaultValues: {
+      query: queryText,
+    },
+  });
+
+  const query = useQuerySearch(queryText);
+
+  const executeQuery = useCallback(
+    (data: FormData) => {
+      logger.debug("Executing query", data);
+      if (data.query !== queryText) {
+        setQueryText(data.query);
path: packages/graph-explorer/src/modules/SearchSidebar/QuerySearchTabContent.tsx
user: login: kmcginnes
body: The next PR, which will need to be rebased and re-targeted to this new trunk.

- https://github.com/aws/graph-explorer/pull/855
created_at: 2025-05-15T19:19:38Z
author_association: COLLABORATOR

PR comments:
user: login: kmcginnes
created_at: 2025-05-15T19:55:33Z
body: Rebased the `main-query-editor` on `main`, then rebased this branch on `main-query-editor`
author_association: COLLABORATOR
user: login: andreachild
created_at: 2025-05-15T20:08:17Z
body: LGTM
author_association: CONTRIBUTOR