diff --git a/api/biome.json b/api/biome.json index ec7a9f9..6f838ae 100644 --- a/api/biome.json +++ b/api/biome.json @@ -6,7 +6,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + } } }, "formatter": { diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 5823703..5ca93d9 100644 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 3360e51..250c409 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx index 2e901e7..e8a8d62 100644 --- a/frontend/src/components/db-table-view/data-table.tsx +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -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, diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c67c98f..eba1ad7 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -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"; diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx new file mode 100644 index 0000000..243106a --- /dev/null +++ b/frontend/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useUiStore } from "@/state"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const theme = useUiStore.use.theme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/frontend/src/components/ui/sql-data-table-cell.tsx b/frontend/src/components/ui/sql-data-table-cell.tsx new file mode 100644 index 0000000..0f5dfad --- /dev/null +++ b/frontend/src/components/ui/sql-data-table-cell.tsx @@ -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 = { + row: Row; + column_name: string; + isDate?: boolean; + isNumber?: boolean; +}; + +function RawSqlDataTableCell({ + isNumber, + row, + column_name, + isDate, +}: Props) { + 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 ( + +
+ {value} + {isImage && ( + {"preview"} + )} +
+
+ ); + } + return ( +
+ {finalValue} +
+ ); +} + +const SqlDataTableCell = memo(RawSqlDataTableCell); + +SqlDataTableCell.displayName = "SqlDataTableCell"; + +export { SqlDataTableCell }; diff --git a/frontend/src/components/ui/sql-data-table.tsx b/frontend/src/components/ui/sql-data-table.tsx new file mode 100644 index 0000000..bde257c --- /dev/null +++ b/frontend/src/components/ui/sql-data-table.tsx @@ -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 = { + table: TableType; + columnsLength: number; +} & ComponentPropsWithoutRef; + +function RawSqlDataTable({ + table, + columnsLength, + style, + ...rest +}: Props) { + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); + + return ( + + +
header.column.resetSize(), + onMouseDown: header.getResizeHandler(), + onTouchStart: header.getResizeHandler(), + className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`, + }} + /> + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+ ); +} + +const SqlDataTable = memo(RawSqlDataTable); + +SqlDataTable.displayName = "SqlDataTable"; + +export { SqlDataTable }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b19bf57..27c565d 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } +type Valuable = { + [K in keyof T as T[K] extends null | undefined ? never : K]: T[K]; +}; export function getValuable>(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); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c92c514..5154e6d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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"} /> + , diff --git a/frontend/src/routes/raw/index.tsx b/frontend/src/routes/raw/index.tsx index 358690b..2aad6d0 100644 --- a/frontend/src/routes/raw/index.tsx +++ b/frontend/src/routes/raw/index.tsx @@ -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("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[]>(() => { + if (!data) return [] as ColumnDef[]; + + return Object.keys(data[0]).map((key) => ({ + header: key, + accessorKey: key, + cell: ({ row }) => , + })) as ColumnDef[]; + }, [data]); + + const table = useReactTable({ + data: data ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( - +
+
+ +
+ {data &&

Result: {data.length} rows

} + +
+ {data && ( +
+ +
+ )} +
+
); } diff --git a/frontend/src/services/db/db.hooks.ts b/frontend/src/services/db/db.hooks.ts index 90aa1b0..4e2c000 100644 --- a/frontend/src/services/db/db.hooks.ts +++ b/frontend/src/services/db/db.hooks.ts @@ -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 }), + }); +}; diff --git a/frontend/src/services/db/db.service.ts b/frontend/src/services/db/db.service.ts index f2a3fc8..8e3b1a1 100644 --- a/frontend/src/services/db/db.service.ts +++ b/frontend/src/services/db/db.service.ts @@ -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(); } + + queryRawSql({ query }: QueryRawSqlArgs) { + return dbInstance + .post("api/raw", { + json: { query }, + }) + .json(); + } } export const dbService = new DbService(); diff --git a/frontend/src/services/db/db.types.ts b/frontend/src/services/db/db.types.ts index 3371b54..27fc082 100644 --- a/frontend/src/services/db/db.types.ts +++ b/frontend/src/services/db/db.types.ts @@ -69,6 +69,13 @@ export type GetTableForeignKeysArgs = { tableName: string; dbName: string; }; + +export type QueryRawSqlArgs = { + query: string; +}; + +export type QueryRawSqlResponse = Array>; + export type TableForeignKey = { conname: string; deferrable: boolean; @@ -80,4 +87,5 @@ export type TableForeignKey = { on_delete: string; on_update: string; }; + export type TableForeignKeys = TableForeignKey[];