Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Query placeholder #239

Merged
merged 10 commits into from
Jan 20, 2025
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
Loading