add postgres where clause for table data query.

add ui for where clause
This commit is contained in:
2024-07-14 12:50:17 +02:00
parent fd076517dc
commit bb16b2db43
10 changed files with 190 additions and 21 deletions

13
TODO.md Normal file
View 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)

View File

@@ -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>[];

View File

@@ -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();

View File

@@ -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,
}); });
}, },
) )

View File

@@ -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({
@@ -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"

View File

@@ -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";

View File

@@ -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 };

View 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 };

View File

@@ -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) {

View File

@@ -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 = {