mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
add raw query editor and response table
This commit is contained in:
@@ -6,7 +6,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
Binary file not shown.
@@ -29,6 +29,7 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
||||
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
||||
import { useSettingsStore } from "@/state";
|
||||
import {
|
||||
@@ -27,19 +27,6 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowUp, Rows3 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
function isUrl(value: string) {
|
||||
return z.string().url().safeParse(value).success;
|
||||
}
|
||||
|
||||
const imageUrlRegex = new RegExp(
|
||||
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
|
||||
);
|
||||
|
||||
function isImageUrl(value: string) {
|
||||
return value.match(imageUrlRegex);
|
||||
}
|
||||
|
||||
export const DataTable = ({
|
||||
tableName,
|
||||
|
||||
@@ -6,5 +6,8 @@ export * from "./input";
|
||||
export * from "./label";
|
||||
export * from "./mode-toggle";
|
||||
export * from "./select";
|
||||
export * from "./sonner";
|
||||
export * from "./sql-data-table";
|
||||
export * from "./sql-data-table-cell";
|
||||
export * from "./switch";
|
||||
export * from "./table";
|
||||
|
||||
29
frontend/src/components/ui/sonner.tsx
Normal file
29
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useUiStore } from "@/state";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const theme = useUiStore.use.theme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
55
frontend/src/components/ui/sql-data-table-cell.tsx
Normal file
55
frontend/src/components/ui/sql-data-table-cell.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/state";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props<T> = {
|
||||
row: Row<T>;
|
||||
column_name: string;
|
||||
isDate?: boolean;
|
||||
isNumber?: boolean;
|
||||
};
|
||||
|
||||
function RawSqlDataTableCell<T>({
|
||||
isNumber,
|
||||
row,
|
||||
column_name,
|
||||
isDate,
|
||||
}: Props<T>) {
|
||||
const formatDates = useSettingsStore.use.formatDates();
|
||||
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
||||
const value = row.getValue(column_name) as any;
|
||||
let finalValue = value;
|
||||
if (formatDates && isDate) {
|
||||
finalValue = new Date(value as string).toLocaleString();
|
||||
}
|
||||
if (showImagesPreview && typeof value === "string" && isUrl(value)) {
|
||||
const isImage = isImageUrl(value);
|
||||
return (
|
||||
<a
|
||||
href={value}
|
||||
target={"_blank"}
|
||||
className={cn("hover:underline")}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className={"flex items-center justify-between break-all gap-4"}>
|
||||
{value}
|
||||
{isImage && (
|
||||
<img src={value} alt={"preview"} className="size-20 object-cover" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn("break-all", isNumber && "text-right")}>
|
||||
{finalValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SqlDataTableCell = memo(RawSqlDataTableCell);
|
||||
|
||||
SqlDataTableCell.displayName = "SqlDataTableCell";
|
||||
|
||||
export { SqlDataTableCell };
|
||||
123
frontend/src/components/ui/sql-data-table.tsx
Normal file
123
frontend/src/components/ui/sql-data-table.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Table as TableType } from "@tanstack/react-table";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { type ComponentPropsWithoutRef, memo } from "react";
|
||||
|
||||
type Props<TData> = {
|
||||
table: TableType<TData>;
|
||||
columnsLength: number;
|
||||
} & ComponentPropsWithoutRef<typeof Table>;
|
||||
|
||||
function RawSqlDataTable<TData>({
|
||||
table,
|
||||
columnsLength,
|
||||
style,
|
||||
...rest
|
||||
}: Props<TData>) {
|
||||
return (
|
||||
<Table
|
||||
style={{
|
||||
width: table.getCenterTotalSize(),
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sorted = header.column.getIsSorted();
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
className={"p-0 relative"}
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
title={
|
||||
header.column.getNextSortingOrder() === "asc"
|
||||
? "Sort ascending"
|
||||
: header.column.getNextSortingOrder() === "desc"
|
||||
? "Sort descending"
|
||||
: "Clear sort"
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"ml-2 size-4 opacity-0 transition-transform",
|
||||
sorted && "opacity-100",
|
||||
(sorted as string) === "desc" && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
{...{
|
||||
onDoubleClick: () => header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`,
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnsLength} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
const SqlDataTable = memo(RawSqlDataTable);
|
||||
|
||||
SqlDataTable.displayName = "SqlDataTable";
|
||||
|
||||
export { SqlDataTable };
|
||||
@@ -1,24 +1,44 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
type Valuable<T> = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] }
|
||||
type Valuable<T> = {
|
||||
[K in keyof T as T[K] extends null | undefined ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export function getValuable<T extends object, V = Valuable<T>>(obj: T): V {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(
|
||||
([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined')
|
||||
)
|
||||
) as V
|
||||
([, v]) =>
|
||||
!(
|
||||
(typeof v === "string" && !v.length) ||
|
||||
v === null ||
|
||||
typeof v === "undefined"
|
||||
),
|
||||
),
|
||||
) as V;
|
||||
}
|
||||
|
||||
export function prettyBytes(bytes: number): string {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']
|
||||
if (bytes === 0) return '0 B'
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
const size = bytes / Math.pow(1024, i)
|
||||
return `${size.toFixed(2)} ${units[i]}`
|
||||
const units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||
if (bytes === 0) return "0 B";
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / 1024 ** i;
|
||||
return `${size.toFixed(2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function isUrl(value: string) {
|
||||
return z.string().url().safeParse(value).success;
|
||||
}
|
||||
|
||||
const imageUrlRegex = new RegExp(
|
||||
/(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i,
|
||||
);
|
||||
|
||||
export function isImageUrl(value: string) {
|
||||
return value.match(imageUrlRegex);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/inter/800.css";
|
||||
import "./index.css";
|
||||
import { Toaster } from "@/components/ui";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
// Import the generated route tree
|
||||
@@ -35,6 +36,7 @@ if (rootElement && !rootElement.innerHTML) {
|
||||
initialIsOpen={false}
|
||||
buttonPosition={"bottom-left"}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -1,16 +1,77 @@
|
||||
import { Button, SqlDataTable, SqlDataTableCell } from "@/components/ui";
|
||||
import { useQueryRawSqlMutation } from "@/services/db";
|
||||
import { useUiStore } from "@/state";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/raw/")({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const [query, setQuery] = useState<string | undefined>("SELECT * FROM");
|
||||
const { mutate, data } = useQueryRawSqlMutation();
|
||||
const appTheme = useUiStore.use.theme();
|
||||
const themeToUse = appTheme === "light" ? "light" : "vs-dark";
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!query) return;
|
||||
|
||||
mutate({ query });
|
||||
};
|
||||
const columns = useMemo<ColumnDef<any>[]>(() => {
|
||||
if (!data) return [] as ColumnDef<any>[];
|
||||
|
||||
return Object.keys(data[0]).map((key) => ({
|
||||
header: key,
|
||||
accessorKey: key,
|
||||
cell: ({ row }) => <SqlDataTableCell row={row} column_name={key} />,
|
||||
})) as ColumnDef<any>[];
|
||||
}, [data]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Editor
|
||||
height="90vh"
|
||||
defaultLanguage="sql"
|
||||
defaultValue="SELECT * FROM table"
|
||||
/>
|
||||
<div className={"h-layout w-layout p-3"}>
|
||||
<div className={"flex flex-col gap-4 flex-1 h-full max-h-full pb-3"}>
|
||||
<Editor
|
||||
theme={themeToUse}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
className={"h-full border"}
|
||||
height={"35%"}
|
||||
language="sql"
|
||||
options={{
|
||||
tabFocusMode: true,
|
||||
detectIndentation: false,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
{data && <p>Result: {data.length} rows</p>}
|
||||
<Button disabled={!query} onClick={handleSubmit} className={"gap-2"}>
|
||||
Execute Query <ArrowRight className={"size-4"} />
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<div className="rounded-md border min-h-0 h-full overflow-auto w-full min-w-0">
|
||||
<SqlDataTable table={table} columnsLength={columns.length} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { HTTPError } from "ky";
|
||||
import { toast } from "sonner";
|
||||
import { DB_QUERY_KEYS } from "./db.query-keys";
|
||||
import { dbService } from "./db.service";
|
||||
import type {
|
||||
@@ -7,6 +9,7 @@ import type {
|
||||
GetTableForeignKeysArgs,
|
||||
GetTableIndexesArgs,
|
||||
GetTablesListArgs,
|
||||
QueryRawSqlArgs,
|
||||
} from "./db.types";
|
||||
|
||||
export const useDatabasesListQuery = () => {
|
||||
@@ -75,3 +78,19 @@ export const useTableForeignKeysQuery = (args: GetTableForeignKeysArgs) => {
|
||||
enabled: !!args.tableName && !!args.dbName,
|
||||
});
|
||||
};
|
||||
|
||||
export const useQueryRawSqlMutation = () => {
|
||||
return useMutation({
|
||||
onError: async (error) => {
|
||||
if (error instanceof HTTPError) {
|
||||
const errorJson = await error.response.json();
|
||||
console.log(errorJson);
|
||||
toast.error(errorJson.message);
|
||||
return;
|
||||
}
|
||||
toast.error(error.message);
|
||||
},
|
||||
mutationFn: ({ query }: QueryRawSqlArgs) =>
|
||||
dbService.queryRawSql({ query }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
GetTableIndexesArgs,
|
||||
GetTablesListArgs,
|
||||
GetTablesListResponse,
|
||||
QueryRawSqlArgs,
|
||||
QueryRawSqlResponse,
|
||||
TableColumns,
|
||||
TableForeignKeys,
|
||||
TableIndexes,
|
||||
@@ -59,6 +61,14 @@ class DbService {
|
||||
.get(`api/databases/${dbName}/tables/${tableName}/foreign-keys`)
|
||||
.json<TableForeignKeys>();
|
||||
}
|
||||
|
||||
queryRawSql({ query }: QueryRawSqlArgs) {
|
||||
return dbInstance
|
||||
.post("api/raw", {
|
||||
json: { query },
|
||||
})
|
||||
.json<QueryRawSqlResponse>();
|
||||
}
|
||||
}
|
||||
|
||||
export const dbService = new DbService();
|
||||
|
||||
@@ -69,6 +69,13 @@ export type GetTableForeignKeysArgs = {
|
||||
tableName: string;
|
||||
dbName: string;
|
||||
};
|
||||
|
||||
export type QueryRawSqlArgs = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type QueryRawSqlResponse = Array<Record<string, any>>;
|
||||
|
||||
export type TableForeignKey = {
|
||||
conname: string;
|
||||
deferrable: boolean;
|
||||
@@ -80,4 +87,5 @@ export type TableForeignKey = {
|
||||
on_delete: string;
|
||||
on_update: string;
|
||||
};
|
||||
|
||||
export type TableForeignKeys = TableForeignKey[];
|
||||
|
||||
Reference in New Issue
Block a user