Skip to content

Commit

Permalink
Merge pull request #239 from keppere/query_placeholder
Browse files Browse the repository at this point in the history
Query placeholder
  • Loading branch information
invisal authored Jan 20, 2025
2 parents 5a1b493 + 3059a6c commit 43b2d75
Show file tree
Hide file tree
Showing 6 changed files with 748 additions and 4 deletions.
4 changes: 3 additions & 1 deletion src/components/gui/sql-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CodeMirror, {
Extension,
ReactCodeMirrorRef,
} from "@uiw/react-codemirror";
import { LanguageSupport } from "@codemirror/language";
import { indentUnit, LanguageSupport } from "@codemirror/language";
import {
acceptCompletion,
completionStatus,
Expand Down Expand Up @@ -153,6 +153,7 @@ const SqlEditor = forwardRef<ReactCodeMirrorRef, SqlEditorProps>(
},
}),
keyExtensions,
indentUnit.of(" "),
sqlDialect,
tooltipExtension,
tableNameHighlightPlugin,
Expand Down Expand Up @@ -184,6 +185,7 @@ const SqlEditor = forwardRef<ReactCodeMirrorRef, SqlEditorProps>(
drawSelection: false,
}}
theme={theme}
indentWithTab={false}
value={value}
height="100%"
onChange={onChange}
Expand Down
88 changes: 88 additions & 0 deletions src/components/gui/tabs/query-placeholder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useMemo } from "react";

interface Props {
placeholders: Record<string, string>;
onChange: (placeHolders: Record<string, string>) => void;
}

export function QueryPlaceholder({
placeholders,
onChange,
}: Props): JSX.Element {
const placeholderCount = Object.keys(placeholders).length;
const emptyPlaceholderCount = Object.values(placeholders).filter(
(v) => v === ""
).length;
const hasEmptyPlaceholder = emptyPlaceholderCount > 0;

const placeholderTable = useMemo(() => {
return (
<div className="overflow-auto max-h-[400px] relative border rounded">
<table className="border-separate border-spacing-0 w-full text-sm">
<thead className="top-0 sticky">
<tr className="bg-secondary h-[35px] text-xs">
<th className="border-r text-left px-2">Variables</th>
<th className="text-left px-2">Value</th>
</tr>
</thead>
<tbody>
{Object.entries(placeholders).map(([key, value]) => (
<tr key={key}>
<td className="px-4 py-2 border-t border-r">{key}</td>
<td className="px-4 py-2 border-t">
<input
type="text"
className="font-mono bg-inherit w-full h-full outline-none border-0"
placeholder="Please fill your value"
value={value ?? ""}
onChange={(e) => {
const newValue = e.currentTarget.value;
const newPlaceHolders = {
...placeholders,
[key]: newValue,
};
onChange(newPlaceHolders);
}}
></input>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}, [placeholders, onChange]);

return (
<Popover>
<PopoverTrigger>
<div className={buttonVariants({ variant: "ghost", size: "sm" })}>
{hasEmptyPlaceholder && (
<div className="flex items-center justify-center h-full pr-2">
<div className="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
)}
Placeholders
<span className="ml-1 text-xs">
{placeholderCount - emptyPlaceholderCount} / {placeholderCount}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-[400px]">
{placeholderTable}

<p className="text-sm mt-2">
Use <span className="font-mono bg-muted">&apos;&apos;</span> for an
empty string. If the value is a number, it will automatically be cast
to a number. To specify a numeric string, wrap it in single quote.
</p>
</PopoverContent>
</Popover>
);
}
81 changes: 78 additions & 3 deletions src/components/gui/tabs/query-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { format } from "sql-formatter";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LucideGrid,
LucideMessageSquareWarning,
Expand Down Expand Up @@ -51,6 +51,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { isExplainQueryPlan } from "../query-explanation";
import ExplainResultTab from "../tabs-result/explain-result-tab";
import { tokenizeSql } from "@/lib/sql/tokenizer";
import { QueryPlaceholder } from "./query-placeholder";
import { escapeSqlValue, extractInputValue } from "@/drivers/sqlite/sql-helper";
import { sendAnalyticEvents } from "@/lib/tracking";

interface QueryWindowProps {
initialCode?: string;
Expand Down Expand Up @@ -84,6 +88,28 @@ export default function QueryWindow({
initialNamespace ?? "Unsaved Query"
);
const [savedKey, setSavedKey] = useState<string | undefined>(initialSavedKey);
const [placeholders, setPlaceholders] = useState<Record<string, string>>({});

useEffect(() => {
const timer = setTimeout(() => {
setPlaceholders((prev) => {
const newPlaceholders: Record<string, string> = {};
const token = tokenizeSql(code, databaseDriver.getFlags().dialect);

const foundPlaceholders = token
.filter((t) => t.type === "PLACEHOLDER")
.map((t) => t.value.slice(1));

for (const foundPlaceholder of foundPlaceholders) {
newPlaceholders[foundPlaceholder] = prev[foundPlaceholder] ?? "";
}

return newPlaceholders;
});
}, 1000);

return () => clearTimeout(timer);
}, [code, databaseDriver]);

const onFormatClicked = () => {
try {
Expand Down Expand Up @@ -137,6 +163,47 @@ export default function QueryWindow({
setProgress(undefined);
setQueryTabIndex(0);

for (let i = 0; i < finalStatements.length; i++) {
const token = tokenizeSql(
finalStatements[i],
databaseDriver.getFlags().dialect
);

// Defensive measurement
if (token.join("") === finalStatements[i]) {
sendAnalyticEvents([
{ name: "tokenize_mismatch", data: { token, finalStatements } },
]);

toast.error("Failed to tokenize SQL statement");

return;
}

const variables = token
.filter((t) => t.type === "PLACEHOLDER")
.map((t) => t.value.slice(1));

if (
variables.length > 0 &&
variables.some((p) => placeholders[p] === "")
) {
toast.error("Please fill in all placeholders");
return;
}

finalStatements[i] = token
.map((t) => {
if (t.type === "PLACEHOLDER") {
return escapeSqlValue(
extractInputValue(placeholders[t.value.slice(1)])
);
}
return t.value;
})
.join("");
}

multipleQuery(databaseDriver, finalStatements, (currentProgress) => {
setProgress(currentProgress);
})
Expand Down Expand Up @@ -317,7 +384,7 @@ export default function QueryWindow({
</div>
</div>
</div>
<div className="grow overflow-hidden p-2 dark:bg-neutral-950 bg-neutral-50">
<div className="grow overflow-hidden p-2">
<SqlEditor
ref={editorRef}
dialect={databaseDriver.getFlags().dialect}
Expand All @@ -343,11 +410,19 @@ export default function QueryWindow({
/>
</div>
<div className="grow-0 shrink-0">
<div className="flex gap-1 pb-2 px-2">
<div className="flex gap-1 pb-1 px-2">
<div className="grow items-center flex text-xs mr-2 gap-2 pl-4">
<div>Ln {lineNumber}</div>
<div>Col {columnNumber + 1}</div>
</div>
<div>
{Object.keys(placeholders).length > 0 && (
<QueryPlaceholder
placeholders={placeholders}
onChange={setPlaceholders}
/>
)}
</div>

<Tooltip>
<TooltipTrigger asChild>
Expand Down
17 changes: 17 additions & 0 deletions src/drivers/sqlite/sql-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ export function escapeSqlValue(value: unknown) {
throw new Error(value.toString() + " is unrecongize type of value");
}

export function extractInputValue(input: string): string | number {
const trimmedInput = input.trim();
if (
(trimmedInput.startsWith('"') && trimmedInput.endsWith('"')) ||
(trimmedInput.startsWith("'") && trimmedInput.endsWith("'"))
) {
return trimmedInput.slice(1, -1).toString();
}

const parsedNumber = parseFloat(trimmedInput);
if (!isNaN(parsedNumber)) {
return parsedNumber;
}

return trimmedInput.toString();
}

export function convertSqliteType(
type: string | undefined
): TableColumnDataType | undefined {
Expand Down
Loading

0 comments on commit 43b2d75

Please sign in to comment.