Skip to content

Commit

Permalink
feat(explorer): sql editor (#3276)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Ingersoll <[email protected]>
  • Loading branch information
karooolis and holic authored Oct 18, 2024
1 parent d4c10c1 commit 3a80bed
Show file tree
Hide file tree
Showing 11 changed files with 430 additions and 87 deletions.
5 changes: 5 additions & 0 deletions .changeset/itchy-countries-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/explorer": patch
---

Explore page now has a full-featured SQL editor with syntax highlighting, autocomplete, and query validation.
1 change: 1 addition & 0 deletions packages/explorer/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default function config() {
output: "standalone",
webpack: (config) => {
config.externals.push("pino-pretty", "lokijs", "encoding");
config.resolve.fallback = { fs: false };
return config;
},
redirects: async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@latticexyz/store-indexer": "workspace:*",
"@latticexyz/store-sync": "workspace:*",
"@latticexyz/world": "workspace:*",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
Expand All @@ -61,13 +62,16 @@
"cmdk": "1.0.0",
"debug": "^4.3.4",
"lucide-react": "^0.408.0",
"monaco-editor": "^0.52.0",
"next": "14.2.5",
"node-sql-parser": "^5.3.3",
"nuqs": "^1.19.2",
"query-string": "^9.1.0",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"sonner": "^1.5.0",
"sql-autocomplete": "^1.1.1",
"tailwind-merge": "^1.12.0",
"tsup": "^6.7.0",
"viem": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function Explorer() {

return (
<>
{indexer.type !== "sqlite" && <SQLEditor />}
{indexer.type !== "sqlite" && <SQLEditor table={table} />}
<TableSelector tables={tables} />
<TablesViewer table={table} query={query} />
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,84 @@
"use client";

import { PlayIcon } from "lucide-react";
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Table } from "@latticexyz/config";
import Editor from "@monaco-editor/react";
import { Button } from "../../../../../../components/ui/Button";
import { Form, FormControl, FormField, FormItem } from "../../../../../../components/ui/Form";
import { Input } from "../../../../../../components/ui/Input";
import { Form, FormField } from "../../../../../../components/ui/Form";
import { cn } from "../../../../../../utils";
import { monacoOptions } from "./consts";
import { useMonacoSuggestions } from "./useMonacoSuggestions";
import { useQueryValidator } from "./useQueryValidator";

type Props = {
table?: Table;
};

export function SQLEditor({ table }: Props) {
const [isFocused, setIsFocused] = useState(false);
const [query, setQuery] = useQueryState("query", { defaultValue: "" });
const validateQuery = useQueryValidator(table);
useMonacoSuggestions(table);

export function SQLEditor() {
const [query, setQuery] = useQueryState("query");
const form = useForm({
defaultValues: {
query: query || "",
query,
},
});

const handleSubmit = form.handleSubmit((data) => {
setQuery(data.query);
if (validateQuery(data.query)) {
setQuery(data.query);
}
});

useEffect(() => {
form.reset({ query: query || "" });
form.reset({ query });
}, [query, form]);

return (
<Form {...form}>
<form onSubmit={handleSubmit}>
<div className="relative">
<FormField
name="query"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} className="pr-[90px]" />
</FormControl>
</FormItem>
)}
/>

<Button className="absolute right-1 top-1 h-8 px-4" type="submit">
<PlayIcon className="mr-1.5 h-3 w-3" /> Run
</Button>
</div>
<form
className={cn(
"relative flex w-full flex-grow items-center justify-center bg-black align-middle",
"h-10 max-h-10 rounded-md border px-3 py-2 ring-offset-background",
{
"outline-none ring-2 ring-ring ring-offset-2": isFocused,
},
)}
onSubmit={handleSubmit}
>
<FormField
name="query"
render={({ field }) => (
<Editor
width="100%"
height="21px"
theme="hc-black"
value={field.value}
options={monacoOptions}
language="sql"
onChange={(value) => field.onChange(value)}
onMount={(editor) => {
editor.onDidFocusEditorText(() => {
setIsFocused(true);
});

editor.onDidBlurEditorText(() => {
setIsFocused(false);
});
}}
loading={null}
/>
)}
/>

<Button className="absolute right-1 top-1 h-8 px-4" type="submit">
<PlayIcon className="mr-1.5 h-3 w-3" /> Run
</Button>
</form>
</Form>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { editor } from "monaco-editor/esm/vs/editor/editor.api";

export const monacoOptions: editor.IStandaloneEditorConstructionOptions = {
fontSize: 14,
fontWeight: "normal",
wordWrap: "off",
lineNumbers: "off",
lineNumbersMinChars: 0,
overviewRulerLanes: 0,
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
lineDecorationsWidth: 0,
glyphMargin: false,
folding: false,
scrollBeyondLastColumn: 0,
scrollbar: {
horizontal: "hidden",
vertical: "hidden",
alwaysConsumeMouseWheel: false,
handleMouseWheel: false,
},
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: "never",
seedSearchStringFromSelection: "never",
},
minimap: { enabled: false },
wordBasedSuggestions: "off",
links: false,
occurrencesHighlight: "off",
cursorStyle: "line-thin",
renderLineHighlight: "none",
contextmenu: false,
roundedSelection: false,
hover: {
delay: 100,
},
acceptSuggestionOnEnter: "on",
automaticLayout: true,
fixedOverflowWidgets: true,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useCallback } from "react";
import { useMonaco } from "@monaco-editor/react";

export function useMonacoErrorMarker() {
const monaco = useMonaco();
return useCallback(
({ message, startColumn, endColumn }: { message: string; startColumn: number; endColumn: number }) => {
if (monaco) {
monaco.editor.setModelMarkers(monaco.editor.getModels()[0], "sql", [
{
severity: monaco.MarkerSeverity.Error,
message,
startLineNumber: 1,
startColumn,
endLineNumber: 1,
endColumn,
},
]);
}
},
[monaco],
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { Table } from "@latticexyz/config";
import { useMonaco } from "@monaco-editor/react";
import { useQueryAutocomplete } from "./useQueryAutocomplete";

const monacoSuggestionsMap = {
KEYWORD: "Keyword",
TABLE: "Field",
COLUMN: "Field",
} as const;

export function useMonacoSuggestions(table?: Table) {
const monaco = useMonaco();
const queryAutocomplete = useQueryAutocomplete(table);

useEffect(() => {
if (!monaco) return;

const provider = monaco.languages.registerCompletionItemProvider("sql", {
triggerCharacters: [" ", ".", ","],

provideCompletionItems: (model, position) => {
if (!queryAutocomplete) {
return { suggestions: [] };
}

const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});

const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
startColumn: word.startColumn,
endLineNumber: position.lineNumber,
endColumn: word.endColumn,
};

const suggestions = queryAutocomplete
.autocomplete(textUntilPosition)
.map(({ value, optionType }) => ({
label: value,
kind: monaco.languages.CompletionItemKind[monacoSuggestionsMap[optionType]],
insertText: value,
range,
// move keyword optionType to the top of suggestions list
sortText: optionType !== "KEYWORD" ? "0" : "1",
}))
.filter(({ label }) => !!label);

return { suggestions };
},
});

return () => provider.dispose();
}, [monaco, queryAutocomplete]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useParams } from "next/navigation";
import { SQLAutocomplete, SQLDialect } from "sql-autocomplete";
import { Address } from "viem";
import { useMemo } from "react";
import { Table } from "@latticexyz/config";
import { useChain } from "../../../../hooks/useChain";
import { constructTableName } from "../../../../utils/constructTableName";

export function useQueryAutocomplete(table?: Table) {
const { id: chainId } = useChain();
const { worldAddress } = useParams<{ worldAddress: Address }>();

return useMemo(() => {
if (!table || !worldAddress || !chainId) return null;

const tableName = constructTableName(table, worldAddress as Address, chainId);
const columnNames = Object.keys(table.schema);

return new SQLAutocomplete(SQLDialect.PLpgSQL, [tableName], columnNames);
}, [table, worldAddress, chainId]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useParams } from "next/navigation";
import { Parser } from "node-sql-parser";
import { Address } from "viem";
import { useCallback } from "react";
import { Table } from "@latticexyz/config";
import { useMonaco } from "@monaco-editor/react";
import { useChain } from "../../../../hooks/useChain";
import { constructTableName } from "../../../../utils/constructTableName";
import { useMonacoErrorMarker } from "./useMonacoErrorMarker";

const sqlParser = new Parser();

export function useQueryValidator(table?: Table) {
const monaco = useMonaco();
const { worldAddress } = useParams();
const { id: chainId } = useChain();
const setErrorMarker = useMonacoErrorMarker();

return useCallback(
(value: string) => {
if (!monaco || !table) return true;

try {
const ast = sqlParser.astify(value);
if ("columns" in ast && Array.isArray(ast.columns)) {
for (const column of ast.columns) {
const columnName = column.expr.column;
if (!Object.keys(table.schema).includes(columnName)) {
setErrorMarker({
message: `Column '${columnName}' does not exist in the table schema.`,
startColumn: value.indexOf(columnName) + 1,
endColumn: value.indexOf(columnName) + columnName.length + 1,
});
return false;
}
}
}

if ("from" in ast && Array.isArray(ast.from)) {
for (const tableInfo of ast.from) {
if ("table" in tableInfo) {
const selectedTableName = tableInfo.table;
const tableName = constructTableName(table, worldAddress as Address, chainId);

if (selectedTableName !== tableName) {
setErrorMarker({
message: `Only '${tableName}' is available for this query.`,
startColumn: value.indexOf(selectedTableName) + 1,
endColumn: value.indexOf(selectedTableName) + selectedTableName.length + 1,
});
return false;
}
}
}
}

monaco.editor.setModelMarkers(monaco.editor.getModels()[0], "sql", []);
return true;
} catch (error) {
if (error instanceof Error) {
setErrorMarker({
message: error.message,
startColumn: 1,
endColumn: value.length + 1,
});
}
return false;
}
},
[monaco, table, setErrorMarker, worldAddress, chainId],
);
}
Loading

0 comments on commit 3a80bed

Please sign in to comment.