mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
add postgres where clause for table data query.
add ui for where clause
This commit is contained in:
13
TODO.md
Normal file
13
TODO.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
## Databases
|
||||||
|
|
||||||
|
- [ ] Fully support PostgreSQL and MySQL
|
||||||
|
- [ ] Add support for SQLite
|
||||||
|
- [ ] Add support for MSSQL
|
||||||
|
- [ ] Add support for MongoDB
|
||||||
|
- [ ] Add support for Redis
|
||||||
|
|
||||||
|
## Nice to have
|
||||||
|
|
||||||
|
- [ ] Seed database with fake data (something like mockaroo, but taking advantage of db introspection)
|
||||||
@@ -24,7 +24,11 @@ export interface Driver {
|
|||||||
): Promise<any[]>;
|
): Promise<any[]>;
|
||||||
getTableData(
|
getTableData(
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
args: WithSortPagination<{ tableName: string; dbName: string }>,
|
args: WithSortPagination<{
|
||||||
|
tableName: string;
|
||||||
|
dbName: string;
|
||||||
|
whereQuery?: string;
|
||||||
|
}>,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
count: number;
|
count: number;
|
||||||
data: Record<string, any>[];
|
data: Record<string, any>[];
|
||||||
|
|||||||
@@ -159,22 +159,31 @@ export class PostgresDriver implements Driver {
|
|||||||
page,
|
page,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
sortField,
|
sortField,
|
||||||
}: WithSortPagination<{ tableName: string; dbName: string }>,
|
whereQuery,
|
||||||
|
}: WithSortPagination<{
|
||||||
|
tableName: string;
|
||||||
|
dbName: string;
|
||||||
|
whereQuery?: string;
|
||||||
|
}>,
|
||||||
) {
|
) {
|
||||||
const sql = await this.queryRunner(credentials);
|
const sql = await this.queryRunner(credentials);
|
||||||
|
|
||||||
const offset = (perPage * page).toString();
|
const offset = (perPage * page).toString();
|
||||||
const rows = sql`
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM ${sql(dbName)}.${sql(tableName)}`;
|
|
||||||
|
|
||||||
const tables = sql`
|
const rowsQuery = `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM "${dbName}"."${tableName}"
|
||||||
|
${whereQuery ? `WHERE ${whereQuery}` : ""}`;
|
||||||
|
|
||||||
|
const tablesQuery = `
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM ${sql(dbName)}.${sql(tableName)}
|
FROM "${dbName}"."${tableName}"
|
||||||
${sortField ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` : sql``}
|
${whereQuery ? `WHERE ${whereQuery}` : ""}
|
||||||
|
${sortField ? `ORDER BY "${sortField}" ${sortDesc ? "DESC" : "ASC"}` : ""}
|
||||||
LIMIT ${perPage} OFFSET ${offset}
|
LIMIT ${perPage} OFFSET ${offset}
|
||||||
`;
|
`;
|
||||||
|
const rows = sql.unsafe(rowsQuery);
|
||||||
|
const tables = sql.unsafe(tablesQuery);
|
||||||
const [[count], data] = await Promise.all([rows, tables]);
|
const [[count], data] = await Promise.all([rows, tables]);
|
||||||
|
|
||||||
void sql.end();
|
void sql.end();
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ const app = new Elysia({ prefix: "/api" })
|
|||||||
"/databases/:dbName/tables/:tableName/data",
|
"/databases/:dbName/tables/:tableName/data",
|
||||||
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
const { tableName, dbName } = params;
|
||||||
const { perPage = "50", page = "0", sortField, sortDesc } = query;
|
const {
|
||||||
|
perPage = "50",
|
||||||
|
page = "0",
|
||||||
|
sortField,
|
||||||
|
sortDesc,
|
||||||
|
whereQuery,
|
||||||
|
} = query;
|
||||||
const credentials = await jwt.verify(auth.value);
|
const credentials = await jwt.verify(auth.value);
|
||||||
|
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
@@ -111,6 +117,7 @@ const app = new Elysia({ prefix: "/api" })
|
|||||||
page: Number.parseInt(page, 10),
|
page: Number.parseInt(page, 10),
|
||||||
sortField,
|
sortField,
|
||||||
sortDesc: sortDesc === "true",
|
sortDesc: sortDesc === "true",
|
||||||
|
whereQuery,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
FormInput,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
import { cn, isImageUrl, isUrl } from "@/lib/utils";
|
||||||
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
import { useTableColumnsQuery, useTableDataQuery } from "@/services/db";
|
||||||
@@ -25,8 +27,9 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { ArrowUp, Rows3 } from "lucide-react";
|
import { ArrowUp, Info, Rows3, Search } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
export const DataTable = ({
|
export const DataTable = ({
|
||||||
tableName,
|
tableName,
|
||||||
@@ -43,14 +46,16 @@ export const DataTable = ({
|
|||||||
onPageIndexChange: (pageIndex: number) => void;
|
onPageIndexChange: (pageIndex: number) => void;
|
||||||
onPageSizeChange: (pageSize: number) => void;
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
|
const whereQueryForm = useForm<{ whereQuery: string }>();
|
||||||
const formatDates = useSettingsStore.use.formatDates();
|
const formatDates = useSettingsStore.use.formatDates();
|
||||||
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
const showImagesPreview = useSettingsStore.use.showImagesPreview();
|
||||||
|
const [whereQuery, setWhereQuery] = useState("");
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
|
||||||
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
const { data: details } = useTableColumnsQuery({ dbName, tableName });
|
||||||
const { data } = useTableDataQuery({
|
const { data, refetch } = useTableDataQuery({
|
||||||
|
whereQuery,
|
||||||
tableName,
|
tableName,
|
||||||
dbName,
|
dbName,
|
||||||
perPage: pageSize,
|
perPage: pageSize,
|
||||||
@@ -58,7 +63,6 @@ export const DataTable = ({
|
|||||||
sortDesc: sorting[0]?.desc,
|
sortDesc: sorting[0]?.desc,
|
||||||
sortField: sorting[0]?.id,
|
sortField: sorting[0]?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
const paginationUpdater: OnChangeFn<PaginationState> = (args) => {
|
||||||
if (typeof args === "function") {
|
if (typeof args === "function") {
|
||||||
const newArgs = args({
|
const newArgs = args({
|
||||||
@@ -87,7 +91,7 @@ export const DataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"whitespace-nowrap",
|
"whitespace-nowrap ",
|
||||||
data_type === "integer" && "text-right",
|
data_type === "integer" && "text-right",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -170,6 +174,37 @@ export const DataTable = ({
|
|||||||
<div className={"flex gap-4 items-center justify-between"}>
|
<div className={"flex gap-4 items-center justify-between"}>
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Rows3 /> {tableName}
|
<Rows3 /> {tableName}
|
||||||
|
<form
|
||||||
|
className={"flex gap-2"}
|
||||||
|
onSubmit={whereQueryForm.handleSubmit((data) => {
|
||||||
|
if (data.whereQuery === whereQuery) {
|
||||||
|
void refetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWhereQuery(data.whereQuery);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormInput
|
||||||
|
name={"whereQuery"}
|
||||||
|
control={whereQueryForm.control}
|
||||||
|
placeholder="WHERE clause"
|
||||||
|
/>
|
||||||
|
<Button variant={"outline"} size={"icon"} type={"submit"}>
|
||||||
|
<Search className={"size-[1.2rem] shrink-0"} />
|
||||||
|
</Button>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
Your input will be prefixed with WHERE and appended to the raw
|
||||||
|
SQL query.
|
||||||
|
<div>You can use AND, OR, NOT, BETWEEN, LIKE, =, etc.</div>
|
||||||
|
<div>To remove the WHERE clause, submit an empty input.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Info className={"size-4 shrink-0 cursor-help"} />
|
||||||
|
</Tooltip>
|
||||||
|
</form>
|
||||||
</h1>
|
</h1>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -227,6 +262,7 @@ export const DataTable = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
className={"select-text"}
|
||||||
title={
|
title={
|
||||||
header.column.getNextSortingOrder() === "asc"
|
header.column.getNextSortingOrder() === "asc"
|
||||||
? "Sort ascending"
|
? "Sort ascending"
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ export * from "./sql-data-table-cell";
|
|||||||
export * from "./switch";
|
export * from "./switch";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
export * from "./tabs";
|
export * from "./tabs";
|
||||||
|
export * from "./tooltip";
|
||||||
export * from "./toggle";
|
export * from "./toggle";
|
||||||
export * from "./toggle-group";
|
export * from "./toggle-group";
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAutoId } from "@/hooks";
|
import { useAutoId } from "@/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { type InputHTMLAttributes, forwardRef } from "react";
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
const RawInput = forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, label, id, errorMessage, ...props }, ref) => {
|
(
|
||||||
|
{ className, label, id, onValueChange, onChange, errorMessage, ...props },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const finalId = useAutoId(id);
|
const finalId = useAutoId(id);
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e);
|
||||||
|
onValueChange?.(e.target.value);
|
||||||
|
},
|
||||||
|
[onValueChange, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"grid gap-1.5"}>
|
<div className={"grid gap-1.5"}>
|
||||||
{!!label && <Label htmlFor={finalId}>{label}</Label>}
|
{!!label && <Label htmlFor={finalId}>{label}</Label>}
|
||||||
<input
|
<input
|
||||||
|
onChange={handleChange}
|
||||||
id={finalId}
|
id={finalId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
@@ -31,6 +49,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Input.displayName = "Input";
|
RawInput.displayName = "Input";
|
||||||
|
|
||||||
|
const Input = memo(RawInput);
|
||||||
|
Input.displayName = "MemoizedInput";
|
||||||
|
|
||||||
export { Input };
|
export { Input };
|
||||||
|
|||||||
58
frontend/src/components/ui/tooltip.tsx
Normal file
58
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const Tooltip = ({
|
||||||
|
children,
|
||||||
|
content,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
delayDuration = 100,
|
||||||
|
disableHoverableContent,
|
||||||
|
...props
|
||||||
|
}: Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Root>,
|
||||||
|
"content"
|
||||||
|
> & {
|
||||||
|
content: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider>
|
||||||
|
<TooltipPrimitive.Root
|
||||||
|
open={open}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
disableHoverableContent={disableHoverableContent}
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||||
|
<TooltipContent {...props}>{content}</TooltipContent>
|
||||||
|
</TooltipPrimitive.Root>
|
||||||
|
</TooltipPrimitive.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
TableForeignKeys,
|
TableForeignKeys,
|
||||||
TableIndexes,
|
TableIndexes,
|
||||||
} from "@/services/db/db.types";
|
} from "@/services/db/db.types";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
class DbService {
|
class DbService {
|
||||||
login(data: LoginArgs) {
|
login(data: LoginArgs) {
|
||||||
@@ -44,12 +46,29 @@ class DbService {
|
|||||||
perPage,
|
perPage,
|
||||||
sortField,
|
sortField,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
|
whereQuery,
|
||||||
}: GetTableDataArgs) {
|
}: GetTableDataArgs) {
|
||||||
return dbInstance
|
return dbInstance
|
||||||
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
|
.get(`api/databases/${dbName}/tables/${tableName}/data`, {
|
||||||
searchParams: getValuable({ perPage, page, sortField, sortDesc }),
|
searchParams: getValuable({
|
||||||
|
perPage,
|
||||||
|
page,
|
||||||
|
sortField,
|
||||||
|
sortDesc,
|
||||||
|
whereQuery,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.json<GetTableDataResponse>();
|
.json<GetTableDataResponse>()
|
||||||
|
.catch(async (error) => {
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
const errorJson = await error.response.json();
|
||||||
|
console.log(errorJson);
|
||||||
|
toast.error(errorJson.message);
|
||||||
|
} else {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
return { count: 0, data: [] };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTableColumns({ dbName, tableName }: GetTableColumnsArgs) {
|
getTableColumns({ dbName, tableName }: GetTableColumnsArgs) {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type GetTableDataArgs = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
sortField?: string;
|
sortField?: string;
|
||||||
sortDesc?: boolean;
|
sortDesc?: boolean;
|
||||||
|
whereQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetTableDataResponse = {
|
export type GetTableDataResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user