tr]:last:border-b-0",
45 | className
46 | )}
47 | {...props}
48 | />
49 | );
50 | }
51 |
52 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
53 | return (
54 |
62 | );
63 | }
64 |
65 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
66 | return (
67 | [role=checkbox]]:translate-y-[2px]",
71 | className
72 | )}
73 | {...props}
74 | />
75 | );
76 | }
77 |
78 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
79 | return (
80 | [role=checkbox]]:translate-y-[2px]",
84 | className
85 | )}
86 | {...props}
87 | />
88 | );
89 | }
90 |
91 | function TableCaption({
92 | className,
93 | ...props
94 | }: React.ComponentProps<"caption">) {
95 | return (
96 |
101 | );
102 | }
103 |
104 | export {
105 | Table,
106 | TableHeader,
107 | TableBody,
108 | TableFooter,
109 | TableHead,
110 | TableRow,
111 | TableCell,
112 | TableCaption,
113 | };
114 |
--------------------------------------------------------------------------------
/src/app/api/chat/route.ts:
--------------------------------------------------------------------------------
1 | import { createGroq } from "@ai-sdk/groq";
2 | import { streamText, tool } from "ai";
3 | import { getFullDatabaseSchema, formatSchemaForAI } from "@/lib/db";
4 | import { z } from "zod";
5 | // import { ProxyAgent } from "undici";
6 |
7 | // Allow streaming responses up to 30 seconds
8 | export const maxDuration = 30;
9 |
10 | export async function POST(req: Request) {
11 | const { messages } = await req.json();
12 |
13 | try {
14 | // Get the database schema
15 | const schemas = await getFullDatabaseSchema();
16 | const schemaText = await formatSchemaForAI(schemas);
17 |
18 | // Configure Groq
19 | const groq = createGroq({
20 | apiKey: process.env.GROQ_API_KEY,
21 | });
22 |
23 | const systemPrompt = `You are a helpful database assistant. You have access to a PostgreSQL database with the following schema:
24 |
25 | ${schemaText}
26 |
27 | You should ONLY answer questions related to this database and its data. You can help users:
28 | 1. Understand the database structure
29 | 2. Suggest SQL queries to retrieve specific data
30 | 3. Explain relationships between tables
31 | 4. Provide insights about data patterns
32 | 5. Help with database optimization suggestions
33 |
34 | When a user asks a question that requires querying the database, use the execute_sql tool to provide the SQL query.
35 | The query will be reviewed by the user before execution for safety.
36 |
37 | When you receive tool results from executed queries, analyze and explain the data in a helpful manner.
38 |
39 | Rules:
40 | - Always provide SQL queries when appropriate using the execute_sql tool
41 | - Only answer database-related questions
42 | - If asked about topics unrelated to the database, politely redirect the conversation back to database topics
43 | - Be helpful and provide clear explanations
44 | - When suggesting queries, explain what they do
45 | - Consider the table types (TABLE vs VIEW) when making suggestions
46 | - Only suggest SELECT queries for safety, avoid INSERT, UPDATE, DELETE operations unless specifically requested
47 | - When you receive query results, provide meaningful insights about the data`;
48 |
49 | const result = streamText({
50 | model: groq(process.env.GROQ_MODEL || "llama-3.1-8b-instant"),
51 | system: systemPrompt,
52 | messages,
53 | maxSteps: 3,
54 | tools: {
55 | execute_sql: tool({
56 | description:
57 | "Execute a SQL query to retrieve data from the database. The query will be reviewed before execution.",
58 | parameters: z.object({
59 | query: z.string().describe("The SQL query to execute"),
60 | description: z
61 | .string()
62 | .describe("A brief description of what this query does"),
63 | }),
64 | }),
65 | },
66 | });
67 |
68 | return result.toDataStreamResponse();
69 | } catch (error) {
70 | console.error("Error in chat API:", error);
71 | return new Response(
72 | JSON.stringify({ error: "Failed to process chat request" }),
73 | { status: 500, headers: { "Content-Type": "application/json" } }
74 | );
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
5 | import { cn } from "@/lib/utils";
6 | import { Button } from "./button";
7 |
8 | interface PaginationProps {
9 | currentPage: number;
10 | totalPages: number;
11 | onPageChange: (page: number) => void;
12 | className?: string;
13 | }
14 |
15 | export function Pagination({
16 | currentPage,
17 | totalPages,
18 | onPageChange,
19 | className,
20 | }: PaginationProps) {
21 | const renderPageNumbers = () => {
22 | const pages = [];
23 | const maxVisiblePages = 5;
24 | let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
25 | const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
26 |
27 | if (endPage - startPage + 1 < maxVisiblePages) {
28 | startPage = Math.max(1, endPage - maxVisiblePages + 1);
29 | }
30 |
31 | if (startPage > 1) {
32 | pages.push(
33 | onPageChange(1)}
38 | >
39 | 1
40 |
41 | );
42 | if (startPage > 2) {
43 | pages.push(
44 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | for (let i = startPage; i <= endPage; i++) {
52 | pages.push(
53 | onPageChange(i)}
58 | >
59 | {i}
60 |
61 | );
62 | }
63 |
64 | if (endPage < totalPages) {
65 | if (endPage < totalPages - 1) {
66 | pages.push(
67 |
68 |
69 |
70 | );
71 | }
72 | pages.push(
73 | onPageChange(totalPages)}
78 | >
79 | {totalPages}
80 |
81 | );
82 | }
83 |
84 | return pages;
85 | };
86 |
87 | return (
88 |
89 | onPageChange(currentPage - 1)}
93 | disabled={currentPage === 1}
94 | >
95 |
96 |
97 | {renderPageNumbers()}
98 | onPageChange(currentPage + 1)}
102 | disabled={currentPage === totalPages}
103 | >
104 |
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/src/lib/database/factory.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import {
4 | DatabaseAdapter,
5 | DatabaseConnection,
6 | DatabaseConfig,
7 | DatabaseType,
8 | } from "./index";
9 | import { PostgreSQLAdapter } from "./postgresql-adapter";
10 | import { MSSQLAdapter } from "./mssql-adapter";
11 | import { MySQLAdapter } from "./mysql-adapter";
12 |
13 | class DatabaseFactory {
14 | private static instance: DatabaseFactory;
15 | private adapters: Map = new Map();
16 |
17 | private constructor() {
18 | this.adapters.set("postgresql", new PostgreSQLAdapter());
19 | this.adapters.set("mssql", new MSSQLAdapter());
20 | this.adapters.set("mysql", new MySQLAdapter());
21 | }
22 |
23 | public static getInstance(): DatabaseFactory {
24 | if (!DatabaseFactory.instance) {
25 | DatabaseFactory.instance = new DatabaseFactory();
26 | }
27 | return DatabaseFactory.instance;
28 | }
29 |
30 | public async createConnection(
31 | config: DatabaseConfig
32 | ): Promise {
33 | const adapter = this.adapters.get(config.type);
34 | if (!adapter) {
35 | throw new Error(`Unsupported database type: ${config.type}`);
36 | }
37 | return adapter.connect(config);
38 | }
39 | }
40 |
41 | // Configuration builder
42 | function createDatabaseConfig(): DatabaseConfig {
43 | const dbType = process.env.POSTGRES_HOST
44 | ? "postgresql"
45 | : process.env.MSSQL_HOST
46 | ? "mssql"
47 | : process.env.MYSQL_HOST
48 | ? "mysql"
49 | : "postgresql";
50 |
51 | switch (dbType) {
52 | case "postgresql":
53 | return {
54 | type: "postgresql",
55 | host: process.env.POSTGRES_HOST || "localhost",
56 | port: parseInt(process.env.POSTGRES_PORT || "5432"),
57 | database: process.env.POSTGRES_DB || "",
58 | username: process.env.POSTGRES_USER || "",
59 | password: process.env.POSTGRES_PASSWORD || "",
60 | };
61 | case "mssql":
62 | return {
63 | type: "mssql",
64 | host: process.env.MSSQL_HOST || "localhost",
65 | port: parseInt(process.env.MSSQL_PORT || "1433"),
66 | database: process.env.MSSQL_DB || "",
67 | username: process.env.MSSQL_USER || "",
68 | password: process.env.MSSQL_PASSWORD || "",
69 | };
70 | case "mysql":
71 | return {
72 | type: "mysql",
73 | host: process.env.MYSQL_HOST || "localhost",
74 | port: parseInt(process.env.MYSQL_PORT || "3306"),
75 | database: process.env.MYSQL_DB || "",
76 | username: process.env.MYSQL_USER || "",
77 | password: process.env.MYSQL_PASSWORD || "",
78 | };
79 | default:
80 | throw new Error(`Unsupported database type: ${dbType}`);
81 | }
82 | }
83 |
84 | // Global database connection instance
85 | let dbConnection: DatabaseConnection | null = null;
86 |
87 | export async function getDatabaseConnection(): Promise {
88 | if (!dbConnection) {
89 | const config = createDatabaseConfig();
90 | const factory = DatabaseFactory.getInstance();
91 | dbConnection = await factory.createConnection(config);
92 | }
93 | return dbConnection;
94 | }
95 |
96 | export async function closeDatabaseConnection(): Promise {
97 | if (dbConnection) {
98 | await dbConnection.disconnect();
99 | dbConnection = null;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/DeleteRowButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { deleteTableRow } from "@/lib/db";
6 | import { Button } from "./ui/button";
7 | import { Trash2, AlertCircle } from "lucide-react";
8 | import {
9 | AlertDialog,
10 | AlertDialogCancel,
11 | AlertDialogContent,
12 | AlertDialogDescription,
13 | AlertDialogFooter,
14 | AlertDialogHeader,
15 | AlertDialogTitle,
16 | AlertDialogTrigger,
17 | } from "./ui/alert-dialog";
18 |
19 | interface DeleteRowButtonProps {
20 | tableName: string;
21 | primaryKeyValues: Record;
22 | }
23 |
24 | export default function DeleteRowButton({
25 | tableName,
26 | primaryKeyValues,
27 | }: DeleteRowButtonProps) {
28 | const [isOpen, setIsOpen] = useState(false);
29 | const [isLoading, setIsLoading] = useState(false);
30 | const [error, setError] = useState(null);
31 | const router = useRouter();
32 |
33 | const handleDelete = async (e: React.MouseEvent) => {
34 | e.preventDefault(); // Prevent the default dialog close behavior
35 | setIsLoading(true);
36 | setError(null);
37 | try {
38 | await deleteTableRow(tableName, primaryKeyValues);
39 | setIsOpen(false); // Only close on success
40 | router.refresh();
41 | } catch (error) {
42 | console.error("Error deleting row:", error);
43 | setError(
44 | error instanceof Error
45 | ? error.message
46 | : "Failed to delete row. Please try again."
47 | );
48 | // Dialog stays open on error so user can see the error message
49 | } finally {
50 | setIsLoading(false);
51 | }
52 | };
53 |
54 | const handleOpenChange = (open: boolean) => {
55 | setIsOpen(open);
56 | if (!open) {
57 | // Reset error state when dialog is closed
58 | setError(null);
59 | }
60 | };
61 |
62 | return (
63 |
64 |
65 |
70 |
71 | Delete row
72 |
73 |
74 |
75 |
76 | Are you sure?
77 |
78 | This action cannot be undone. This will permanently delete the row
79 | from the database.
80 |
81 |
82 |
83 | {error && (
84 |
88 | )}
89 |
90 |
91 | Cancel
92 |
97 | {isLoading ? "Deleting..." : "Delete"}
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/TableDisplay.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Table,
3 | TableBody,
4 | TableCell,
5 | TableHead,
6 | TableHeader,
7 | TableRow,
8 | } from "./ui/table";
9 | import DeleteRowButton from "./DeleteRowButton";
10 | import UpdateRowButton from "./UpdateRowButton";
11 | import { cn } from "../lib/utils";
12 | import { TableIntrospection } from "@/lib/types";
13 |
14 | interface TableDisplayProps {
15 | tableName: string;
16 | initialData: Record[];
17 | primaryKeys: string[];
18 | columnTypes: Record;
19 | introspection?: TableIntrospection;
20 | }
21 |
22 | export default function TableDisplay({
23 | tableName,
24 | initialData,
25 | primaryKeys,
26 | columnTypes,
27 | introspection,
28 | }: TableDisplayProps) {
29 | const columns = initialData.length > 0 ? Object.keys(initialData[0]) : [];
30 | const hasPrimaryKey = primaryKeys.length > 0;
31 |
32 | return (
33 |
34 |
35 |
36 |
37 | {hasPrimaryKey && (
38 |
39 | )}
40 | {columns.map((column, columnIndex) => (
41 |
48 | {column}
49 |
50 | ))}
51 |
52 |
53 |
54 | {initialData.map((row, rowIndex) => (
55 |
56 | {hasPrimaryKey && (
57 |
58 |
59 |
67 | [key, row[key]])
72 | .filter(
73 | ([, value]) =>
74 | typeof value === "string" ||
75 | typeof value === "number"
76 | )
77 | )}
78 | />
79 |
80 |
81 | )}
82 | {columns.map((column, columnIndex) => (
83 |
90 | {String(row[column])}
91 |
92 | ))}
93 |
94 | ))}
95 |
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/components/FilterBar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "./ui/button";
4 | import { Combobox } from "./ui/combobox";
5 | import { X } from "lucide-react";
6 |
7 | interface Filter {
8 | column: string;
9 | operator: string;
10 | value: string;
11 | }
12 |
13 | interface FilterBarProps {
14 | columns: string[];
15 | pendingFilters: Filter[];
16 | setPendingFilters: (filters: Filter[]) => void;
17 | }
18 |
19 | const operators = [
20 | { value: "=", label: "Equals" },
21 | { value: "!=", label: "Not Equals" },
22 | { value: ">", label: "Greater Than" },
23 | { value: "<", label: "Less Than" },
24 | { value: ">=", label: "Greater Than or Equal" },
25 | { value: "<=", label: "Less Than or Equal" },
26 | { value: "LIKE", label: "Contains" },
27 | { value: "NOT LIKE", label: "Does Not Contain" },
28 | ];
29 |
30 | export default function FilterBar({
31 | columns,
32 | pendingFilters,
33 | setPendingFilters,
34 | }: FilterBarProps) {
35 | const addFilter = () => {
36 | const newFilters = [
37 | ...pendingFilters,
38 | { column: columns[0], operator: "=", value: "" },
39 | ];
40 | setPendingFilters(newFilters);
41 | };
42 |
43 | const removeFilter = (index: number) => {
44 | const newFilters = pendingFilters.filter((_, i) => i !== index);
45 | setPendingFilters(newFilters);
46 | };
47 |
48 | const updateFilter = (index: number, field: keyof Filter, value: string) => {
49 | const newFilters = [...pendingFilters];
50 | newFilters[index] = { ...newFilters[index], [field]: value };
51 | setPendingFilters(newFilters);
52 | };
53 |
54 | // Convert columns to options for combobox
55 | const columnOptions = columns.map((column) => ({
56 | value: column,
57 | label: column,
58 | }));
59 |
60 | return (
61 |
62 |
63 |
Filters
64 |
65 | Add Filter
66 |
67 |
68 |
69 | {pendingFilters.map((filter, index) => (
70 |
71 | updateFilter(index, "column", value)}
75 | placeholder="Select column"
76 | searchPlaceholder="Search columns..."
77 | emptyMessage="No column found."
78 | />
79 |
80 | updateFilter(index, "operator", value)}
84 | placeholder="Select operator"
85 | searchPlaceholder="Search operators..."
86 | emptyMessage="No operator found."
87 | />
88 |
89 | updateFilter(index, "value", e.target.value)}
93 | className="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"
94 | placeholder="Value"
95 | />
96 |
97 | removeFilter(index)}
102 | >
103 |
104 |
105 |
106 | ))}
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/UpdateRowButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { updateTableRow } from "@/lib/db";
6 | import { Button } from "./ui/button";
7 | import {
8 | Sheet,
9 | SheetContent,
10 | SheetHeader,
11 | SheetTitle,
12 | SheetTrigger,
13 | } from "./ui/sheet";
14 | import { Pencil } from "lucide-react";
15 | import TableRowForm from "./TableRowForm";
16 | import { IntrospectionColumn } from "@/lib/types";
17 |
18 | interface UpdateRowButtonProps {
19 | tableName: string;
20 | columns: string[];
21 | columnTypes: Record;
22 | rowData: Record;
23 | primaryKeys: string[];
24 | introspection?: {
25 | columns: IntrospectionColumn[];
26 | };
27 | }
28 |
29 | export default function UpdateRowButton({
30 | tableName,
31 | columns,
32 | columnTypes,
33 | rowData,
34 | primaryKeys,
35 | introspection,
36 | }: UpdateRowButtonProps) {
37 | const [isOpen, setIsOpen] = useState(false);
38 | const router = useRouter();
39 |
40 | // Filter out read-only columns (identity columns, primary keys, and auto-managed timestamps)
41 | const getEditableColumns = (): string[] => {
42 | if (!introspection) {
43 | // Fallback: exclude primary keys and identity columns
44 | return columns.filter((col) => !primaryKeys.includes(col));
45 | }
46 |
47 | return columns.filter((columnName) => {
48 | const columnInfo = introspection.columns.find(
49 | (col) => col.column_name === columnName
50 | );
51 |
52 | // Exclude identity columns (auto-increment/serial columns)
53 | if (columnInfo?.is_identity === "YES") {
54 | return false;
55 | }
56 |
57 | return true;
58 | });
59 | };
60 |
61 | // Get columns that should be shown as readonly (primary keys and identity columns)
62 | const getReadonlyColumns = (): string[] => {
63 | if (!introspection) {
64 | return primaryKeys;
65 | }
66 |
67 | return columns.filter((columnName) => {
68 | const columnInfo = introspection.columns.find(
69 | (col) => col.column_name === columnName
70 | );
71 |
72 | // Include identity columns and primary keys as readonly
73 | return (
74 | columnInfo?.is_identity === "YES" || primaryKeys.includes(columnName)
75 | );
76 | });
77 | };
78 |
79 | const editableColumns = getEditableColumns();
80 | const readonlyColumns = getReadonlyColumns();
81 |
82 | const handleSubmit = async (formData: Record) => {
83 | // Build primary key values object from rowData
84 | const primaryKeyValues: Record = {};
85 | primaryKeys.forEach((key) => {
86 | const value = rowData[key];
87 | if (value !== undefined) {
88 | primaryKeyValues[key] =
89 | typeof value === "string" ? value : String(value);
90 | }
91 | });
92 |
93 | // Only send data for editable columns
94 | const filteredFormData: Record = {};
95 | editableColumns.forEach((column) => {
96 | if (formData[column] !== undefined) {
97 | filteredFormData[column] = formData[column];
98 | }
99 | });
100 |
101 | await updateTableRow(tableName, primaryKeyValues, filteredFormData);
102 | setIsOpen(false);
103 | router.refresh();
104 | };
105 |
106 | return (
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | Update row in {tableName}
116 |
117 | [
122 | column,
123 | String(rowData[column] || ""),
124 | ])
125 | )}
126 | readonlyColumns={readonlyColumns}
127 | readonlyData={Object.fromEntries(
128 | readonlyColumns.map((column) => [
129 | column,
130 | String(rowData[column] || ""),
131 | ])
132 | )}
133 | onSubmit={handleSubmit}
134 | submitButtonText="Update Row"
135 | onCancel={() => setIsOpen(false)}
136 | />
137 |
138 |
139 | );
140 | }
141 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | )
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 | return (
95 |
103 | )
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | )
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | )
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | }
144 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --color-sidebar-ring: var(--sidebar-ring);
12 | --color-sidebar-border: var(--sidebar-border);
13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14 | --color-sidebar-accent: var(--sidebar-accent);
15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16 | --color-sidebar-primary: var(--sidebar-primary);
17 | --color-sidebar-foreground: var(--sidebar-foreground);
18 | --color-sidebar: var(--sidebar);
19 | --color-chart-5: var(--chart-5);
20 | --color-chart-4: var(--chart-4);
21 | --color-chart-3: var(--chart-3);
22 | --color-chart-2: var(--chart-2);
23 | --color-chart-1: var(--chart-1);
24 | --color-ring: var(--ring);
25 | --color-input: var(--input);
26 | --color-border: var(--border);
27 | --color-destructive: var(--destructive);
28 | --color-accent-foreground: var(--accent-foreground);
29 | --color-accent: var(--accent);
30 | --color-muted-foreground: var(--muted-foreground);
31 | --color-muted: var(--muted);
32 | --color-secondary-foreground: var(--secondary-foreground);
33 | --color-secondary: var(--secondary);
34 | --color-primary-foreground: var(--primary-foreground);
35 | --color-primary: var(--primary);
36 | --color-popover-foreground: var(--popover-foreground);
37 | --color-popover: var(--popover);
38 | --color-card-foreground: var(--card-foreground);
39 | --color-card: var(--card);
40 | --radius-sm: calc(var(--radius) - 4px);
41 | --radius-md: calc(var(--radius) - 2px);
42 | --radius-lg: var(--radius);
43 | --radius-xl: calc(var(--radius) + 4px);
44 | }
45 |
46 | :root {
47 | --radius: 0.625rem;
48 | --background: oklch(1 0 0);
49 | --foreground: oklch(0.145 0 0);
50 | --card: oklch(1 0 0);
51 | --card-foreground: oklch(0.145 0 0);
52 | --popover: oklch(1 0 0);
53 | --popover-foreground: oklch(0.145 0 0);
54 | --primary: oklch(0.205 0 0);
55 | --primary-foreground: oklch(0.985 0 0);
56 | --secondary: oklch(0.97 0 0);
57 | --secondary-foreground: oklch(0.205 0 0);
58 | --muted: oklch(0.97 0 0);
59 | --muted-foreground: oklch(0.556 0 0);
60 | --accent: oklch(0.97 0 0);
61 | --accent-foreground: oklch(0.205 0 0);
62 | --destructive: oklch(0.577 0.245 27.325);
63 | --border: oklch(0.922 0 0);
64 | --input: oklch(0.922 0 0);
65 | --ring: oklch(0.708 0 0);
66 | --chart-1: oklch(0.646 0.222 41.116);
67 | --chart-2: oklch(0.6 0.118 184.704);
68 | --chart-3: oklch(0.398 0.07 227.392);
69 | --chart-4: oklch(0.828 0.189 84.429);
70 | --chart-5: oklch(0.769 0.188 70.08);
71 | --sidebar: oklch(0.985 0 0);
72 | --sidebar-foreground: oklch(0.145 0 0);
73 | --sidebar-primary: oklch(0.205 0 0);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.97 0 0);
76 | --sidebar-accent-foreground: oklch(0.205 0 0);
77 | --sidebar-border: oklch(0.922 0 0);
78 | --sidebar-ring: oklch(0.708 0 0);
79 | }
80 |
81 | .dark {
82 | --background: oklch(0.145 0 0);
83 | --foreground: oklch(0.985 0 0);
84 | --card: oklch(0.205 0 0);
85 | --card-foreground: oklch(0.985 0 0);
86 | --popover: oklch(0.205 0 0);
87 | --popover-foreground: oklch(0.985 0 0);
88 | --primary: oklch(0.922 0 0);
89 | --primary-foreground: oklch(0.205 0 0);
90 | --secondary: oklch(0.269 0 0);
91 | --secondary-foreground: oklch(0.985 0 0);
92 | --muted: oklch(0.269 0 0);
93 | --muted-foreground: oklch(0.708 0 0);
94 | --accent: oklch(0.269 0 0);
95 | --accent-foreground: oklch(0.985 0 0);
96 | --destructive: oklch(0.704 0.191 22.216);
97 | --border: oklch(1 0 0 / 10%);
98 | --input: oklch(1 0 0 / 15%);
99 | --ring: oklch(0.556 0 0);
100 | --chart-1: oklch(0.488 0.243 264.376);
101 | --chart-2: oklch(0.696 0.17 162.48);
102 | --chart-3: oklch(0.769 0.188 70.08);
103 | --chart-4: oklch(0.627 0.265 303.9);
104 | --chart-5: oklch(0.645 0.246 16.439);
105 | --sidebar: oklch(0.205 0 0);
106 | --sidebar-foreground: oklch(0.985 0 0);
107 | --sidebar-primary: oklch(0.488 0.243 264.376);
108 | --sidebar-primary-foreground: oklch(0.985 0 0);
109 | --sidebar-accent: oklch(0.269 0 0);
110 | --sidebar-accent-foreground: oklch(0.985 0 0);
111 | --sidebar-border: oklch(1 0 0 / 10%);
112 | --sidebar-ring: oklch(0.556 0 0);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SheetPrimitive from "@radix-ui/react-dialog";
5 | import { XIcon } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return ;
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left";
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | );
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | );
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | );
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | );
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | };
140 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { buttonVariants } from "./button";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/src/lib/db.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { getDatabaseConnection } from "./database/factory";
4 | import { TableIntrospection } from "./types";
5 | import {
6 | parseFiltersFromSearchParams,
7 | parseSortsFromSearchParams,
8 | } from "./utils";
9 |
10 | export async function getTables() {
11 | const db = await getDatabaseConnection();
12 | return db.getTables();
13 | }
14 |
15 | export async function getTableData(
16 | tableName: string,
17 | searchParams?: URLSearchParams | Record
18 | ) {
19 | const db = await getDatabaseConnection();
20 |
21 | // Convert searchParams to page and pageSize for the abstraction layer
22 | let page = 1;
23 | let pageSize = 10;
24 |
25 | if (searchParams) {
26 | let urlSearchParams: URLSearchParams;
27 | if (searchParams instanceof URLSearchParams) {
28 | urlSearchParams = searchParams;
29 | } else {
30 | urlSearchParams = new URLSearchParams();
31 | Object.entries(searchParams).forEach(([key, value]) => {
32 | if (value !== undefined) {
33 | if (Array.isArray(value)) {
34 | value.forEach((v) => urlSearchParams.append(key, v));
35 | } else {
36 | urlSearchParams.append(key, value);
37 | }
38 | }
39 | });
40 | }
41 |
42 | page = parseInt(urlSearchParams.get("page") || "1");
43 | pageSize = parseInt(urlSearchParams.get("pageSize") || "10");
44 | }
45 |
46 | // Parse filters and sorts from searchParams
47 | const filters = searchParams
48 | ? parseFiltersFromSearchParams(searchParams)
49 | : [];
50 | const sorts = searchParams ? parseSortsFromSearchParams(searchParams) : [];
51 |
52 | const result = await db.getTableData(
53 | tableName,
54 | page,
55 | pageSize,
56 | filters,
57 | sorts
58 | );
59 |
60 | // Convert back to the expected format
61 | return {
62 | rows: result.rows,
63 | totalCount: result.totalCount,
64 | page: result.page,
65 | pageSize: result.pageSize,
66 | totalPages: result.totalPages,
67 | };
68 | }
69 |
70 | export async function insertTableRow(
71 | tableName: string,
72 | data: Record
73 | ) {
74 | const db = await getDatabaseConnection();
75 | return db.insertTableRow(tableName, data);
76 | }
77 |
78 | export async function getTablePrimaryKeys(tableName: string) {
79 | const db = await getDatabaseConnection();
80 | return db.getTablePrimaryKeys(tableName);
81 | }
82 |
83 | export async function deleteTableRow(
84 | tableName: string,
85 | primaryKeyValues: Record
86 | ) {
87 | const db = await getDatabaseConnection();
88 | return db.deleteTableRow(tableName, primaryKeyValues);
89 | }
90 |
91 | export async function updateTableRow(
92 | tableName: string,
93 | primaryKeyValues: Record,
94 | data: Record
95 | ) {
96 | const db = await getDatabaseConnection();
97 | return db.updateTableRow(tableName, primaryKeyValues, data);
98 | }
99 |
100 | export async function getTableColumns(tableName: string) {
101 | const db = await getDatabaseConnection();
102 | return db.getTableColumns(tableName);
103 | }
104 |
105 | export async function getTableColumnTypes(tableName: string) {
106 | const db = await getDatabaseConnection();
107 | return db.getTableColumnTypes(tableName);
108 | }
109 |
110 | export async function getTableType(tableName: string) {
111 | const db = await getDatabaseConnection();
112 | return db.getTableType(tableName);
113 | }
114 |
115 | export async function executeQuery(sqlQuery: string) {
116 | const db = await getDatabaseConnection();
117 | return db.executeQuery(sqlQuery);
118 | }
119 |
120 | export async function getTableIntrospection(
121 | tableName: string
122 | ): Promise {
123 | const db = await getDatabaseConnection();
124 | return db.getTableIntrospection(tableName);
125 | }
126 |
127 | export async function getFullDatabaseSchema() {
128 | const db = await getDatabaseConnection();
129 | return db.getFullDatabaseSchema();
130 | }
131 |
132 | export async function formatSchemaForAI(
133 | schemas: Awaited>
134 | ): Promise {
135 | let schemaText = "Database Schema Information:\n\n";
136 |
137 | for (const table of schemas) {
138 | const tableType = table.table_type === "VIEW" ? "VIEW" : "TABLE";
139 | schemaText += `${tableType}: ${table.schema_name}.${table.table_name}\n`;
140 | schemaText += "Columns:\n";
141 |
142 | for (const column of table.columns) {
143 | const nullable = column.is_nullable === "YES" ? "NULL" : "NOT NULL";
144 | const defaultVal = column.column_default
145 | ? ` DEFAULT ${column.column_default}`
146 | : "";
147 | schemaText += ` - ${column.column_name}: ${column.data_type}${defaultVal} (${nullable})\n`;
148 | }
149 |
150 | schemaText += "\n";
151 | }
152 |
153 | return schemaText;
154 | }
155 |
--------------------------------------------------------------------------------
/src/components/TableRowForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "./ui/button";
3 | import { Input } from "./ui/input";
4 | import { AlertCircle } from "lucide-react";
5 | import JSONEditor from "./JSONEditor";
6 |
7 | interface TableRowFormProps {
8 | columns: string[];
9 | columnTypes?: Record;
10 | initialData?: Record;
11 | readonlyColumns?: string[];
12 | readonlyData?: Record;
13 | onSubmit: (data: Record) => Promise;
14 | submitButtonText: string;
15 | onCancel?: () => void;
16 | }
17 |
18 | export default function TableRowForm({
19 | columns,
20 | columnTypes = {},
21 | initialData = {},
22 | readonlyColumns = [],
23 | readonlyData = {},
24 | onSubmit,
25 | submitButtonText,
26 | onCancel,
27 | }: TableRowFormProps) {
28 | const [formData, setFormData] = useState>(initialData);
29 | const [isLoading, setIsLoading] = useState(false);
30 | const [error, setError] = useState(null);
31 |
32 | const handleSubmit = async (e: React.FormEvent) => {
33 | e.preventDefault();
34 | setIsLoading(true);
35 | setError(null);
36 |
37 | try {
38 | await onSubmit(formData);
39 | } catch (error) {
40 | console.error("Error submitting form:", error);
41 | setError(
42 | error instanceof Error
43 | ? error.message
44 | : "Failed to submit. Please try again."
45 | );
46 | } finally {
47 | setIsLoading(false);
48 | }
49 | };
50 |
51 | const handleInputChange = (column: string, value: string) => {
52 | setFormData((prev) => ({
53 | ...prev,
54 | [column]: value,
55 | }));
56 | };
57 |
58 | const isJsonField = (column: string): boolean => {
59 | const columnType = columnTypes[column];
60 | if (!columnType) return false;
61 |
62 | return (
63 | columnType.dataType === "json" ||
64 | columnType.udtName === "json" ||
65 | columnType.udtName === "jsonb"
66 | );
67 | };
68 |
69 | const formatInitialJsonValue = (value: string): string => {
70 | if (!value) return "";
71 | try {
72 | // If it's already valid JSON, format it nicely
73 | const parsed = JSON.parse(value);
74 | return JSON.stringify(parsed, null, 2);
75 | } catch {
76 | // If it's not valid JSON, return as-is
77 | return value;
78 | }
79 | };
80 |
81 | return (
82 |
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Command as CommandPrimitive } from "cmdk"
5 | import { SearchIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog"
15 |
16 | function Command({
17 | className,
18 | ...props
19 | }: React.ComponentProps) {
20 | return (
21 |
29 | )
30 | }
31 |
32 | function CommandDialog({
33 | title = "Command Palette",
34 | description = "Search for a command to run...",
35 | children,
36 | className,
37 | showCloseButton = true,
38 | ...props
39 | }: React.ComponentProps & {
40 | title?: string
41 | description?: string
42 | className?: string
43 | showCloseButton?: boolean
44 | }) {
45 | return (
46 |
47 |
48 | {title}
49 | {description}
50 |
51 |
55 |
56 | {children}
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | function CommandInput({
64 | className,
65 | ...props
66 | }: React.ComponentProps) {
67 | return (
68 |
72 |
73 |
81 |
82 | )
83 | }
84 |
85 | function CommandList({
86 | className,
87 | ...props
88 | }: React.ComponentProps) {
89 | return (
90 |
98 | )
99 | }
100 |
101 | function CommandEmpty({
102 | ...props
103 | }: React.ComponentProps) {
104 | return (
105 |
110 | )
111 | }
112 |
113 | function CommandGroup({
114 | className,
115 | ...props
116 | }: React.ComponentProps) {
117 | return (
118 |
126 | )
127 | }
128 |
129 | function CommandSeparator({
130 | className,
131 | ...props
132 | }: React.ComponentProps) {
133 | return (
134 |
139 | )
140 | }
141 |
142 | function CommandItem({
143 | className,
144 | ...props
145 | }: React.ComponentProps) {
146 | return (
147 |
155 | )
156 | }
157 |
158 | function CommandShortcut({
159 | className,
160 | ...props
161 | }: React.ComponentProps<"span">) {
162 | return (
163 |
171 | )
172 | }
173 |
174 | export {
175 | Command,
176 | CommandDialog,
177 | CommandInput,
178 | CommandList,
179 | CommandEmpty,
180 | CommandGroup,
181 | CommandItem,
182 | CommandShortcut,
183 | CommandSeparator,
184 | }
185 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | ...props
58 | }: React.ComponentProps) {
59 | return (
60 |
61 |
72 |
73 |
80 | {children}
81 |
82 |
83 |
84 |
85 | )
86 | }
87 |
88 | function SelectLabel({
89 | className,
90 | ...props
91 | }: React.ComponentProps) {
92 | return (
93 |
98 | )
99 | }
100 |
101 | function SelectItem({
102 | className,
103 | children,
104 | ...props
105 | }: React.ComponentProps) {
106 | return (
107 |
115 |
116 |
117 |
118 |
119 |
120 | {children}
121 |
122 | )
123 | }
124 |
125 | function SelectSeparator({
126 | className,
127 | ...props
128 | }: React.ComponentProps) {
129 | return (
130 |
135 | )
136 | }
137 |
138 | function SelectScrollUpButton({
139 | className,
140 | ...props
141 | }: React.ComponentProps) {
142 | return (
143 |
151 |
152 |
153 | )
154 | }
155 |
156 | function SelectScrollDownButton({
157 | className,
158 | ...props
159 | }: React.ComponentProps) {
160 | return (
161 |
169 |
170 |
171 | )
172 | }
173 |
174 | export {
175 | Select,
176 | SelectContent,
177 | SelectGroup,
178 | SelectItem,
179 | SelectLabel,
180 | SelectScrollDownButton,
181 | SelectScrollUpButton,
182 | SelectSeparator,
183 | SelectTrigger,
184 | SelectValue,
185 | }
186 |
--------------------------------------------------------------------------------
/src/components/SqlQueryBlock.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6 | import { Play, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
7 | import {
8 | Table,
9 | TableBody,
10 | TableCell,
11 | TableHead,
12 | TableHeader,
13 | TableRow,
14 | } from "@/components/ui/table";
15 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
16 | import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
17 |
18 | interface QueryResult {
19 | success: boolean;
20 | rows: Record[];
21 | rowCount: number;
22 | fields: string[];
23 | error?: string;
24 | }
25 |
26 | interface SqlQueryBlockProps {
27 | toolCall: {
28 | toolCallId: string;
29 | toolName: string;
30 | args: {
31 | query: string;
32 | description: string;
33 | };
34 | };
35 | onExecute: (toolCallId: string, result: QueryResult) => void;
36 | }
37 |
38 | export function SqlQueryBlock({ toolCall, onExecute }: SqlQueryBlockProps) {
39 | const [isExecuting, setIsExecuting] = useState(false);
40 | const [result, setResult] = useState(null);
41 | const [hasExecuted, setHasExecuted] = useState(false);
42 |
43 | const executeQuery = async () => {
44 | setIsExecuting(true);
45 | try {
46 | const response = await fetch("/api/execute-sql", {
47 | method: "POST",
48 | headers: {
49 | "Content-Type": "application/json",
50 | },
51 | body: JSON.stringify({
52 | query: toolCall.args.query,
53 | toolCallId: toolCall.toolCallId,
54 | }),
55 | });
56 |
57 | const data = await response.json();
58 | const queryResult: QueryResult = {
59 | success: data.success,
60 | rows: data.rows,
61 | rowCount: data.rowCount,
62 | fields: data.fields,
63 | error: data.error,
64 | };
65 |
66 | setResult(queryResult);
67 | setHasExecuted(true);
68 |
69 | // Notify parent component with the results
70 | onExecute(toolCall.toolCallId, queryResult);
71 | } catch {
72 | const errorResult: QueryResult = {
73 | success: false,
74 | error: "Failed to execute query",
75 | rows: [],
76 | rowCount: 0,
77 | fields: [],
78 | };
79 | setResult(errorResult);
80 | setHasExecuted(true);
81 | onExecute(toolCall.toolCallId, errorResult);
82 | } finally {
83 | setIsExecuting(false);
84 | }
85 | };
86 |
87 | return (
88 |
89 |
90 |
91 | SQL Query
92 | {hasExecuted && result?.success && (
93 |
94 | )}
95 | {hasExecuted && !result?.success && (
96 |
97 | )}
98 |
99 | {toolCall.args.description && (
100 |
101 | {toolCall.args.description}
102 |
103 | )}
104 |
105 |
106 | {/* SQL Query Display */}
107 |
108 | }
111 | customStyle={{
112 | margin: 0,
113 | fontSize: "14px",
114 | }}
115 | >
116 | {toolCall.args.query}
117 |
118 |
119 |
120 | {/* Execute Button */}
121 | {!hasExecuted && (
122 |
127 | {isExecuting ? (
128 | <>
129 |
130 | Executing...
131 | >
132 | ) : (
133 | <>
134 |
135 | Execute Query
136 | >
137 | )}
138 |
139 | )}
140 |
141 | {/* Results Display */}
142 | {result && (
143 |
144 | {/* Status */}
145 |
146 | {result.success ? (
147 |
148 |
149 |
150 | Query executed successfully. {result.rowCount} row(s)
151 | returned.
152 |
153 |
154 | ) : (
155 |
156 |
157 |
Query failed: {result.error}
158 |
159 | )}
160 |
161 |
162 | {/* Results Table */}
163 | {result.success && result.rows.length > 0 && (
164 |
165 |
166 |
167 |
168 |
169 | {result.fields.map((field) => (
170 | {field}
171 | ))}
172 |
173 |
174 |
175 | {result.rows.slice(0, 50).map((row, index) => (
176 |
177 | {result.fields.map((field) => (
178 |
179 | {row[field] === null ? (
180 |
181 | null
182 |
183 | ) : (
184 | String(row[field])
185 | )}
186 |
187 | ))}
188 |
189 | ))}
190 |
191 |
192 |
193 | {result.rows.length > 50 && (
194 |
195 | Showing first 50 rows of {result.rows.length} total rows
196 |
197 | )}
198 |
199 | )}
200 |
201 | {/* No Results */}
202 | {result.success && result.rows.length === 0 && (
203 |
204 | No results returned
205 |
206 | )}
207 |
208 | )}
209 |
210 |
211 | );
212 | }
213 |
--------------------------------------------------------------------------------
/docker/mssql/mssql-init.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import pyodbc
4 | import time
5 | import os
6 | import sys
7 |
8 | def wait_for_sql_server():
9 | """Wait for SQL Server to be ready for connections"""
10 | max_attempts = 60
11 | attempt = 1
12 |
13 | while attempt <= max_attempts:
14 | try:
15 | # Test connection
16 | conn_str = (
17 | f"DRIVER={{ODBC Driver 17 for SQL Server}};"
18 | f"SERVER=localhost;"
19 | f"UID=sa;"
20 | f"PWD={os.environ.get('MSSQL_SA_PASSWORD', 'yourStrong(!)Password')}"
21 | )
22 |
23 | conn = pyodbc.connect(conn_str, timeout=5)
24 | cursor = conn.cursor()
25 | cursor.execute("SELECT 1")
26 | cursor.fetchone()
27 | cursor.close()
28 | conn.close()
29 |
30 | print("SQL Server is ready!")
31 | return True
32 |
33 | except Exception as e:
34 | print(f"SQL Server not ready yet... (attempt {attempt}/{max_attempts}): {str(e)}")
35 | attempt += 1
36 | time.sleep(2)
37 |
38 | print("Failed to connect to SQL Server after maximum attempts")
39 | return False
40 |
41 | def execute_sql_file(file_path):
42 | """Execute SQL commands from a file"""
43 | try:
44 | # Read the SQL file
45 | with open(file_path, 'r') as file:
46 | sql_content = file.read()
47 |
48 | # Connect to SQL Server (master database first)
49 | conn_str = (
50 | f"DRIVER={{ODBC Driver 17 for SQL Server}};"
51 | f"SERVER=localhost;"
52 | f"UID=sa;"
53 | f"PWD={os.environ.get('MSSQL_SA_PASSWORD', 'yourStrong(!)Password')};"
54 | f"DATABASE=master"
55 | )
56 |
57 | conn = pyodbc.connect(conn_str)
58 | conn.autocommit = True # Enable autocommit for CREATE DATABASE
59 | cursor = conn.cursor()
60 |
61 | # First, create the database
62 | try:
63 | cursor.execute("CREATE DATABASE testdb")
64 | print("Database 'testdb' created successfully")
65 | except Exception as e:
66 | print(f"Database creation info: {str(e)}")
67 |
68 | cursor.close()
69 | conn.close()
70 |
71 | # Now connect to the testdb database
72 | conn_str_testdb = (
73 | f"DRIVER={{ODBC Driver 17 for SQL Server}};"
74 | f"SERVER=localhost;"
75 | f"UID=sa;"
76 | f"PWD={os.environ.get('MSSQL_SA_PASSWORD', 'yourStrong(!)Password')};"
77 | f"DATABASE=testdb"
78 | )
79 |
80 | conn = pyodbc.connect(conn_str_testdb)
81 | cursor = conn.cursor()
82 |
83 | # Process the SQL content - handle the USE statement and parse properly
84 | lines = sql_content.split('\n')
85 | processed_lines = []
86 |
87 | for line in lines:
88 | # Skip CREATE DATABASE and USE statements since we handle them differently
89 | if (not line.strip().startswith('CREATE DATABASE') and
90 | not line.strip().startswith('USE ') and
91 | line.strip()):
92 | processed_lines.append(line)
93 |
94 | # Join lines back and split by 'GO' statements properly
95 | sql_content = '\n'.join(processed_lines)
96 |
97 | # Split by GO statements (SQL Server batch separator)
98 | batches = []
99 | current_batch = []
100 |
101 | for line in sql_content.split('\n'):
102 | line = line.strip()
103 | if line.upper() == 'GO':
104 | if current_batch:
105 | batches.append('\n'.join(current_batch))
106 | current_batch = []
107 | elif line and not line.startswith('--'): # Skip comments
108 | current_batch.append(line)
109 |
110 | # Add the last batch if there's content
111 | if current_batch:
112 | batches.append('\n'.join(current_batch))
113 |
114 | # If no GO statements found, we need to manually split for SQL Server batch requirements
115 | if len(batches) <= 1 and batches:
116 | # Split the content more intelligently for SQL Server batching rules
117 | single_batch = batches[0] if batches else sql_content
118 | batches = []
119 | current_batch = []
120 | lines = single_batch.split('\n')
121 |
122 | i = 0
123 | while i < len(lines):
124 | line = lines[i].strip()
125 |
126 | # Check if this is a statement that needs its own batch
127 | if (line.startswith('CREATE TRIGGER') or
128 | line.startswith('CREATE PROCEDURE') or
129 | line.startswith('CREATE FUNCTION')):
130 |
131 | # Close current batch if it has content
132 | if current_batch:
133 | batches.append('\n'.join(current_batch))
134 | current_batch = []
135 |
136 | # Start new batch for this statement
137 | # Find all lines until the next major statement
138 | trigger_batch = [line]
139 | i += 1
140 | while i < len(lines):
141 | next_line = lines[i].strip()
142 | if (next_line.startswith('CREATE ') and
143 | not next_line.startswith('CREATE INDEX')):
144 | break
145 | trigger_batch.append(lines[i])
146 | i += 1
147 |
148 | batches.append('\n'.join(trigger_batch))
149 | i -= 1 # Step back one since we'll increment at the end
150 | else:
151 | current_batch.append(lines[i])
152 |
153 | i += 1
154 |
155 | # Add final batch if it has content
156 | if current_batch:
157 | batches.append('\n'.join(current_batch))
158 |
159 | # Execute each batch
160 | for i, batch in enumerate(batches):
161 | if batch.strip():
162 | try:
163 | print(f"Executing batch {i+1}/{len(batches)}")
164 | cursor.execute(batch)
165 | conn.commit()
166 | print(f"Batch {i+1} executed successfully")
167 | except Exception as e:
168 | print(f"Error executing batch {i+1}: {str(e)}")
169 | print(f"Batch content (first 300 chars): {batch[:300]}...")
170 | # Continue with next batch
171 |
172 | cursor.close()
173 | conn.close()
174 |
175 | print("Database initialization completed successfully!")
176 | return True
177 |
178 | except Exception as e:
179 | print(f"Database initialization failed: {str(e)}")
180 | return False
181 |
182 | if __name__ == "__main__":
183 | print("Starting MSSQL database initialization...")
184 |
185 | # Wait for SQL Server to be ready
186 | if not wait_for_sql_server():
187 | sys.exit(1)
188 |
189 | # Execute initialization script
190 | sql_file = "/usr/src/app/mssql-init.sql"
191 | if os.path.exists(sql_file):
192 | if execute_sql_file(sql_file):
193 | print("Initialization completed successfully!")
194 | else:
195 | print("Initialization failed!")
196 | sys.exit(1)
197 | else:
198 | print(f"SQL file not found: {sql_file}")
199 | sys.exit(1)
--------------------------------------------------------------------------------
/src/app/[tableName]/TablePageClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import TableDisplay from "@/components/TableDisplay";
4 | import TableHeader from "@/components/TableHeader";
5 | import FilterBar from "@/components/FilterBar";
6 | import SortBar from "@/components/SortBar";
7 | import QueryControls from "@/components/QueryControls";
8 | import { useRouter, useSearchParams } from "next/navigation";
9 | import { useState } from "react";
10 | import { Pagination } from "@/components/ui/pagination";
11 | import {
12 | Select,
13 | SelectContent,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from "@/components/ui/select";
18 |
19 | interface TablePageClientProps {
20 | tableName: string;
21 | initialData: {
22 | rows: Record[];
23 | totalCount: number;
24 | page: number;
25 | pageSize: number;
26 | totalPages: number;
27 | error?: string;
28 | };
29 | initialPrimaryKeys: string[];
30 | initialColumns: string[];
31 | initialColumnTypes: Record;
32 | tableType: string | null;
33 | introspection: import("@/lib/types").TableIntrospection;
34 | }
35 |
36 | interface Filter {
37 | column: string;
38 | operator: string;
39 | value: string;
40 | }
41 |
42 | interface Sort {
43 | column: string;
44 | direction: "asc" | "desc";
45 | }
46 |
47 | export default function TablePageClient({
48 | tableName,
49 | initialData,
50 | initialPrimaryKeys,
51 | initialColumns,
52 | initialColumnTypes,
53 | tableType,
54 | introspection,
55 | }: TablePageClientProps) {
56 | const router = useRouter();
57 | const searchParams = useSearchParams();
58 |
59 | // Parse initial filters from URL
60 | const getInitialFilters = (): Filter[] => {
61 | const filters: Filter[] = [];
62 | searchParams.forEach((value, key) => {
63 | const filterMatch = key.match(/^filters\[(.+)\]$/);
64 | if (filterMatch) {
65 | const column = filterMatch[1];
66 | // Parse operator from value (format: "operator:actual_value")
67 | const [operator, ...valueParts] = value.split(":");
68 | const actualValue = valueParts.join(":");
69 | filters.push({ column, operator, value: actualValue });
70 | }
71 | });
72 | return filters;
73 | };
74 |
75 | // Parse initial sorts from URL
76 | const getInitialSorts = (): Sort[] => {
77 | const sorts: Sort[] = [];
78 | searchParams.forEach((value, key) => {
79 | const sortMatch = key.match(/^sort\[(.+)\]$/);
80 | if (sortMatch) {
81 | const column = sortMatch[1];
82 | if (value === "asc" || value === "desc") {
83 | sorts.push({ column, direction: value });
84 | }
85 | }
86 | });
87 | return sorts;
88 | };
89 |
90 | const [pendingFilters, setPendingFilters] = useState(
91 | getInitialFilters()
92 | );
93 | const [pendingSorts, setPendingSorts] = useState(getInitialSorts());
94 |
95 | const handleSubmit = (e: React.FormEvent) => {
96 | e.preventDefault();
97 | const params = new URLSearchParams();
98 |
99 | // Add filter params with bracket notation, but only if they have a value
100 | pendingFilters.forEach((filter) => {
101 | if (filter.value.trim()) {
102 | params.set(
103 | `filters[${filter.column}]`,
104 | `${filter.operator}:${filter.value}`
105 | );
106 | }
107 | });
108 |
109 | // Add sort params with bracket notation
110 | pendingSorts.forEach((sort) => {
111 | params.set(`sort[${sort.column}]`, sort.direction);
112 | });
113 |
114 | // Preserve pagination params
115 | params.set("page", searchParams.get("page") || "1");
116 | params.set("pageSize", searchParams.get("pageSize") || "10");
117 |
118 | router.push(`/${tableName}?${params.toString()}`);
119 | };
120 |
121 | const handleClear = () => {
122 | setPendingFilters([]);
123 | setPendingSorts([]);
124 | router.push(`/${tableName}`);
125 | };
126 |
127 | const handlePageChange = (page: number) => {
128 | const params = new URLSearchParams(searchParams.toString());
129 | params.set("page", page.toString());
130 | router.push(`/${tableName}?${params.toString()}`);
131 | };
132 |
133 | const handlePageSizeChange = (pageSize: string) => {
134 | const params = new URLSearchParams(searchParams.toString());
135 | params.set("pageSize", pageSize);
136 | params.set("page", "1"); // Reset to first page when changing page size
137 | router.push(`/${tableName}?${params.toString()}`);
138 | };
139 |
140 | return (
141 |
142 |
149 |
166 |
167 | {/* Display error message if there's a database error */}
168 | {initialData.error && (
169 |
170 |
171 |
172 |
Query Error
173 |
174 |
{initialData.error}
175 |
176 | Try adjusting your filters. For example, make sure you're
177 | using the correct data type for each column (numbers for
178 | numeric columns, text for text columns).
179 |
180 |
181 |
182 |
183 |
184 | )}
185 |
186 |
193 |
194 |
195 | Rows per page
196 |
200 |
201 |
202 |
203 |
204 | 10
205 | 20
206 | 50
207 | 100
208 |
209 |
210 |
211 |
212 |
213 | {initialData.totalCount} total rows
214 |
215 |
220 |
221 |
222 |
223 | );
224 | }
225 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Database Management UI
2 |
3 | This is a modern, user-friendly self-hosted DB management web ui built with Next.js, TypeScript, and shadcn/ui. It provides a simpler and more elegant alternative to traditional database management tools like pgAdmin, phpMyAdmin, and SQL Server Management Studio.
4 |
5 | ## Supported Databases
6 |
7 | - **PostgreSQL** - Full support for PostgreSQL databases
8 | - **MySQL** - Complete MySQL database management
9 | - **Microsoft SQL Server** - MSSQL database operations and management
10 |
11 | ## Features & Screenshots
12 |
13 | ### Database Operations
14 |
15 | - **Full CRUD Operations**: Create, read, update, and delete records from your database tables. Has UI for filters/sorts with pagination.
16 | - **Schema Introspection**: Automatically discover and visualize your database schema, including tables, columns, relationships, and constraints.
17 | - **Custom SQL Queries**: Write and execute custom SQL queries with syntax highlighting and result visualization + exports to CSV.
18 |
19 | 
20 | _Browse and manage your database tables with an intuitive interface featuring sorting, filtering, and pagination._
21 |
22 | 
23 | _Automatically discover and visualize your database schema, including tables, columns, relationships, and constraints._
24 |
25 | 
26 | _Write and execute custom SQL queries with syntax highlighting and comprehensive result visualization._
27 |
28 | 
29 | _Edit database records directly in the interface with validation and real-time updates._
30 |
31 | ### Table Search
32 |
33 | Quickly find and navigate to any table in your database with intelligent search capabilities.
34 |
35 | - **Fuzzy Search**: The sidebar includes a fuzzy search bar that helps you quickly find tables
36 | - **Keyboard Shortcuts**:
37 | - Press `Cmd+K` (Mac) or `Ctrl+K` (Windows/Linux) to focus the search bar
38 | - Press `Enter` to navigate to the first matching table
39 | - Press `Escape` to clear the search and unfocus
40 | - **Real-time Filtering**: Tables are filtered as you type with intelligent fuzzy matching
41 |
42 | ### Chat with DB
43 |
44 | Get intelligent assistance for your database operations with an AI-powered chat interface.
45 |
46 | - **AI-Powered Database Assistant**: Interactive chat interface with an AI that knows your database schema
47 | - **Schema-Aware Responses**: The AI has access to your complete database structure and can help with:
48 | - Understanding table relationships
49 | - Writing SQL queries
50 | - Database optimization suggestions
51 | - Data analysis insights
52 | - **Real-time Streaming**: Responses are streamed in real-time for a smooth chat experience
53 |
54 | 
55 | _Interactive chat with an AI assistant that understands your database schema and can help with queries and optimization._
56 |
57 | ## Getting Started
58 |
59 | ## Docker Deployment
60 |
61 | This application can also be deployed using Docker for consistent and portable deployments.
62 |
63 | ### Using Published Docker Image
64 |
65 | The application is automatically published to GitHub Container Registry. To use the published image:
66 |
67 | ```bash
68 | docker pull ghcr.io/n7olkachev/db-ui:latest
69 | ```
70 |
71 | ### Quick Start with Docker Run
72 |
73 | **For PostgreSQL**:
74 |
75 | ```bash
76 | docker run -d \
77 | --name db-ui-app \
78 | -p 3000:3000 \
79 | -e POSTGRES_HOST=your_database_host \
80 | -e POSTGRES_USER=your_username \
81 | -e POSTGRES_PASSWORD=your_password \
82 | -e POSTGRES_DB=your_database \
83 | -e POSTGRES_PORT=5432 \
84 | -e GROQ_API_KEY=your_groq_api_key \
85 | -e GROQ_MODEL=llama-3.1-70b-versatile \
86 | ghcr.io/n7olkachev/db-ui:latest
87 | ```
88 |
89 | **For MySQL**:
90 |
91 | ```bash
92 | docker run -d \
93 | --name db-ui-app \
94 | -p 3000:3000 \
95 | -e MYSQL_HOST=your_database_host \
96 | -e MYSQL_USER=your_username \
97 | -e MYSQL_PASSWORD=your_password \
98 | -e MYSQL_DB=your_database \
99 | -e MYSQL_PORT=3306 \
100 | -e GROQ_API_KEY=your_groq_api_key \
101 | -e GROQ_MODEL=llama-3.1-70b-versatile \
102 | ghcr.io/n7olkachev/db-ui:latest
103 | ```
104 |
105 | **For Microsoft SQL Server**:
106 |
107 | ```bash
108 | docker run -d \
109 | --name db-ui-app \
110 | -p 3000:3000 \
111 | -e MSSQL_HOST=your_database_host \
112 | -e MSSQL_USER=your_username \
113 | -e MSSQL_PASSWORD=your_password \
114 | -e MSSQL_DB=your_database \
115 | -e MSSQL_PORT=1433 \
116 | -e GROQ_API_KEY=your_groq_api_key \
117 | -e GROQ_MODEL=llama-3.1-70b-versatile \
118 | ghcr.io/n7olkachev/db-ui:latest
119 | ```
120 |
121 | Replace the database connection details and `your_groq_api_key` with your actual values.
122 |
123 | ### Building and Running from Source
124 |
125 | 1. **Build the Docker Image**:
126 |
127 | ```bash
128 | docker build -t db-ui .
129 | ```
130 |
131 | 2. **Run the Built Image**:
132 |
133 | For PostgreSQL:
134 |
135 | ```bash
136 | docker run -d \
137 | --name db-ui-app \
138 | -p 3000:3000 \
139 | -e POSTGRES_HOST=your_database_host \
140 | -e POSTGRES_USER=your_username \
141 | -e POSTGRES_PASSWORD=your_password \
142 | -e POSTGRES_DB=your_database \
143 | -e POSTGRES_PORT=5432 \
144 | -e GROQ_API_KEY=your_groq_api_key \
145 | -e GROQ_MODEL=llama-3.1-70b-versatile \
146 | db-ui
147 | ```
148 |
149 | For MySQL:
150 |
151 | ```bash
152 | docker run -d \
153 | --name db-ui-app \
154 | -p 3000:3000 \
155 | -e MYSQL_HOST=your_database_host \
156 | -e MYSQL_USER=your_username \
157 | -e MYSQL_PASSWORD=your_password \
158 | -e MYSQL_DB=your_database \
159 | -e MYSQL_PORT=3306 \
160 | -e GROQ_API_KEY=your_groq_api_key \
161 | -e GROQ_MODEL=llama-3.1-70b-versatile \
162 | db-ui
163 | ```
164 |
165 | For Microsoft SQL Server:
166 |
167 | ```bash
168 | docker run -d \
169 | --name db-ui-app \
170 | -p 3000:3000 \
171 | -e MSSQL_HOST=your_database_host \
172 | -e MSSQL_USER=your_username \
173 | -e MSSQL_PASSWORD=your_password \
174 | -e MSSQL_DB=your_database \
175 | -e MSSQL_PORT=1433 \
176 | -e GROQ_API_KEY=your_groq_api_key \
177 | -e GROQ_MODEL=llama-3.1-70b-versatile \
178 | db-ui
179 | ```
180 |
181 | ### Running from source
182 |
183 | - Node.js 18+
184 | - npm, yarn, pnpm
185 | - One of the supported databases:
186 | - PostgreSQL database (local or remote)
187 | - MySQL database (local or remote)
188 | - Microsoft SQL Server database (local or remote)
189 |
190 | ### Building for Production
191 |
192 | 1. **Build the Application**:
193 |
194 | ```bash
195 | npm run build
196 | # or
197 | yarn build
198 | # or
199 | pnpm build
200 | ```
201 |
202 | 2. **Start the Production Server**:
203 | ```bash
204 | npm start
205 | # or
206 | yarn start
207 | # or
208 | pnpm start
209 | ```
210 |
211 | The application will be available at [http://localhost:3000](http://localhost:3000).
212 |
213 | ### Environment Setup
214 |
215 | Choose the appropriate environment template for your database and copy it to `.env`:
216 |
217 | **For PostgreSQL:**
218 |
219 | ```bash
220 | cp env.postgresql .env
221 | ```
222 |
223 | Example PostgreSQL configuration:
224 |
225 | ```env
226 | POSTGRES_HOST=localhost
227 | POSTGRES_PORT=5432
228 | POSTGRES_DB=sampledb
229 | POSTGRES_USER=admin
230 | POSTGRES_PASSWORD=admin
231 | ```
232 |
233 | **For MySQL:**
234 |
235 | ```bash
236 | cp env.mysql .env
237 | ```
238 |
239 | Example MySQL configuration:
240 |
241 | ```env
242 | MYSQL_HOST=localhost
243 | MYSQL_PORT=3306
244 | MYSQL_DB=testdb
245 | MYSQL_USER=admin
246 | MYSQL_PASSWORD=admin
247 | ```
248 |
249 | **For Microsoft SQL Server:**
250 |
251 | ```bash
252 | cp env.mssql .env
253 | ```
254 |
255 | Example MSSQL configuration:
256 |
257 | ```env
258 | MSSQL_HOST=localhost
259 | MSSQL_PORT=1433
260 | MSSQL_DB=testdb
261 | MSSQL_USER=sa
262 | MSSQL_PASSWORD=yourStrong(!)Password
263 | ```
264 |
265 | Then edit `.env` with your actual database connection details.
266 |
267 | ### Installation and Development
268 |
269 | 1. **Install Dependencies**:
270 |
271 | ```bash
272 | npm install
273 | # or
274 | yarn install
275 | # or
276 | pnpm install
277 | ```
278 |
279 | 2. **Run the Development Server**:
280 |
281 | ```bash
282 | npm run dev
283 | # or
284 | yarn dev
285 | # or
286 | pnpm dev
287 | ```
288 |
289 | 3. **Access the Application**:
290 | Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
291 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function DropdownMenu({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DropdownMenuPortal({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function DropdownMenuTrigger({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
31 | )
32 | }
33 |
34 | function DropdownMenuContent({
35 | className,
36 | sideOffset = 4,
37 | ...props
38 | }: React.ComponentProps) {
39 | return (
40 |
41 |
50 |
51 | )
52 | }
53 |
54 | function DropdownMenuGroup({
55 | ...props
56 | }: React.ComponentProps) {
57 | return (
58 |
59 | )
60 | }
61 |
62 | function DropdownMenuItem({
63 | className,
64 | inset,
65 | variant = "default",
66 | ...props
67 | }: React.ComponentProps & {
68 | inset?: boolean
69 | variant?: "default" | "destructive"
70 | }) {
71 | return (
72 |
82 | )
83 | }
84 |
85 | function DropdownMenuCheckboxItem({
86 | className,
87 | children,
88 | checked,
89 | ...props
90 | }: React.ComponentProps) {
91 | return (
92 |
101 |
102 |
103 |
104 |
105 |
106 | {children}
107 |
108 | )
109 | }
110 |
111 | function DropdownMenuRadioGroup({
112 | ...props
113 | }: React.ComponentProps) {
114 | return (
115 |
119 | )
120 | }
121 |
122 | function DropdownMenuRadioItem({
123 | className,
124 | children,
125 | ...props
126 | }: React.ComponentProps) {
127 | return (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | )
144 | }
145 |
146 | function DropdownMenuLabel({
147 | className,
148 | inset,
149 | ...props
150 | }: React.ComponentProps & {
151 | inset?: boolean
152 | }) {
153 | return (
154 |
163 | )
164 | }
165 |
166 | function DropdownMenuSeparator({
167 | className,
168 | ...props
169 | }: React.ComponentProps) {
170 | return (
171 |
176 | )
177 | }
178 |
179 | function DropdownMenuShortcut({
180 | className,
181 | ...props
182 | }: React.ComponentProps<"span">) {
183 | return (
184 |
192 | )
193 | }
194 |
195 | function DropdownMenuSub({
196 | ...props
197 | }: React.ComponentProps) {
198 | return
199 | }
200 |
201 | function DropdownMenuSubTrigger({
202 | className,
203 | inset,
204 | children,
205 | ...props
206 | }: React.ComponentProps & {
207 | inset?: boolean
208 | }) {
209 | return (
210 |
219 | {children}
220 |
221 |
222 | )
223 | }
224 |
225 | function DropdownMenuSubContent({
226 | className,
227 | ...props
228 | }: React.ComponentProps) {
229 | return (
230 |
238 | )
239 | }
240 |
241 | export {
242 | DropdownMenu,
243 | DropdownMenuPortal,
244 | DropdownMenuTrigger,
245 | DropdownMenuContent,
246 | DropdownMenuGroup,
247 | DropdownMenuLabel,
248 | DropdownMenuItem,
249 | DropdownMenuCheckboxItem,
250 | DropdownMenuRadioGroup,
251 | DropdownMenuRadioItem,
252 | DropdownMenuSeparator,
253 | DropdownMenuShortcut,
254 | DropdownMenuSub,
255 | DropdownMenuSubTrigger,
256 | DropdownMenuSubContent,
257 | }
258 |
--------------------------------------------------------------------------------
/db/mssql-init.sql:
--------------------------------------------------------------------------------
1 | -- MSSQL Database Initialization Script
2 | -- Note: Database is created automatically by the container
3 | CREATE DATABASE testdb;
4 |
5 | USE testdb;
6 |
7 | -- Create users table
8 | CREATE TABLE users (
9 | id INT IDENTITY(1, 1) PRIMARY KEY,
10 | name NVARCHAR(255) NOT NULL,
11 | email NVARCHAR(255) UNIQUE NOT NULL,
12 | age INT,
13 | is_active BIT DEFAULT 1,
14 | preferences NVARCHAR(MAX),
15 | -- JSON data stored as NVARCHAR
16 | created_at DATETIME2 DEFAULT GETDATE(),
17 | updated_at DATETIME2 DEFAULT GETDATE()
18 | );
19 |
20 | -- Create posts table
21 | CREATE TABLE posts (
22 | id INT IDENTITY(1, 1) PRIMARY KEY,
23 | title NVARCHAR(255) NOT NULL,
24 | content NTEXT,
25 | user_id INT,
26 | published BIT DEFAULT 0,
27 | metadata NVARCHAR(MAX),
28 | -- JSON data for post metadata
29 | created_at DATETIME2 DEFAULT GETDATE(),
30 | updated_at DATETIME2 DEFAULT GETDATE(),
31 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
32 | );
33 |
34 | -- Create categories table
35 | CREATE TABLE categories (
36 | id INT IDENTITY(1, 1) PRIMARY KEY,
37 | name NVARCHAR(255) NOT NULL UNIQUE,
38 | description NTEXT,
39 | created_at DATETIME2 DEFAULT GETDATE()
40 | );
41 |
42 | -- Create post_categories junction table
43 | CREATE TABLE post_categories (
44 | post_id INT,
45 | category_id INT,
46 | PRIMARY KEY (post_id, category_id),
47 | FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
48 | FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
49 | );
50 |
51 | -- Insert sample users
52 | INSERT INTO
53 | users (name, email, age, is_active, preferences)
54 | VALUES
55 | (
56 | 'Johny Nick Doe',
57 | 'john.doe@example.com',
58 | 30,
59 | 1,
60 | '{"theme": "dark", "language": "en", "notifications": {"email": true, "push": false}, "dashboard": {"widgets": ["recent_posts", "analytics"]}}'
61 | ),
62 | (
63 | 'Jane Smith',
64 | 'jane.smith@example.com',
65 | 25,
66 | 1,
67 | '{"theme": "light", "language": "en", "notifications": {"email": false, "push": true}, "dashboard": {"widgets": ["todo_list", "calendar"]}}'
68 | ),
69 | (
70 | 'Bob Johnson',
71 | 'bob.johnson@example.com',
72 | 35,
73 | 0,
74 | '{"theme": "auto", "language": "es", "notifications": {"email": true, "push": true}, "privacy": {"profile_visible": false}}'
75 | ),
76 | (
77 | 'Alice Brown',
78 | 'alice.brown@example.com',
79 | 28,
80 | 1,
81 | '{"theme": "light", "language": "fr", "notifications": {"email": true, "push": false}, "timezone": "Europe/Paris"}'
82 | ),
83 | (
84 | 'Charlie Wilson',
85 | 'charlie.wilson@example.com',
86 | 42,
87 | 1,
88 | '{"theme": "dark", "language": "en", "notifications": {"email": false, "push": false}, "advanced": {"debug_mode": true, "api_access": ["read", "write"]}}'
89 | );
90 |
91 | -- Insert sample categories
92 | INSERT INTO
93 | categories (name, description)
94 | VALUES
95 | (
96 | 'Technology',
97 | 'Posts about technology and programming'
98 | ),
99 | (
100 | 'Lifestyle',
101 | 'Posts about lifestyle and personal experiences'
102 | ),
103 | (
104 | 'Business',
105 | 'Posts about business and entrepreneurship'
106 | ),
107 | ('Travel', 'Posts about travel and adventures'),
108 | ('Food', 'Posts about cooking and restaurants');
109 |
110 | -- Insert sample posts
111 | INSERT INTO
112 | posts (title, content, user_id, published, metadata)
113 | VALUES
114 | (
115 | 'Getting Started with MSSQL',
116 | 'A comprehensive guide to SQL Server database management...',
117 | 1,
118 | 1,
119 | '{"tags": ["database", "mssql", "tutorial"], "reading_time": 15, "difficulty": "beginner", "seo": {"meta_description": "Learn SQL Server basics", "keywords": ["sql server", "database", "tutorial"]}}'
120 | ),
121 | (
122 | 'My Journey as a Developer',
123 | 'Sharing my experiences in software development...',
124 | 2,
125 | 1,
126 | '{"tags": ["personal", "career", "development"], "reading_time": 8, "difficulty": "beginner", "featured": true, "comments_enabled": true}'
127 | ),
128 | (
129 | 'Building Scalable Applications',
130 | 'Best practices for building applications that scale...',
131 | 1,
132 | 0,
133 | '{"tags": ["architecture", "scalability", "performance"], "reading_time": 22, "difficulty": "advanced", "series": {"name": "Architecture Series", "part": 1}}'
134 | ),
135 | (
136 | 'Traveling Through Europe',
137 | 'Amazing experiences from my European adventure...',
138 | 4,
139 | 1,
140 | '{"tags": ["travel", "europe", "adventure"], "reading_time": 12, "difficulty": "beginner", "location": {"countries": ["France", "Italy", "Spain"]}, "photos": 15}'
141 | ),
142 | (
143 | 'The Future of Remote Work',
144 | 'How remote work is changing the business landscape...',
145 | 5,
146 | 1,
147 | '{"tags": ["business", "remote work", "future"], "reading_time": 18, "difficulty": "intermediate", "trending": true, "sources": ["https://example.com/study1", "https://example.com/study2"]}'
148 | ),
149 | (
150 | 'Cooking Italian Cuisine',
151 | 'Traditional recipes from my grandmother...',
152 | 3,
153 | 0,
154 | '{"tags": ["cooking", "italian", "recipes"], "reading_time": 25, "difficulty": "intermediate", "ingredients_count": 12, "prep_time": 45}'
155 | ),
156 | (
157 | 'Database Design Patterns',
158 | 'Common patterns for designing efficient databases...',
159 | 1,
160 | 1,
161 | '{"tags": ["database", "design patterns", "architecture"], "reading_time": 30, "difficulty": "advanced", "code_examples": 8, "diagrams": 5}'
162 | ),
163 | (
164 | 'Work-Life Balance Tips',
165 | 'How to maintain a healthy work-life balance...',
166 | 2,
167 | 1,
168 | '{"tags": ["lifestyle", "productivity", "wellness"], "reading_time": 10, "difficulty": "beginner", "tips_count": 12, "actionable": true}'
169 | );
170 |
171 | -- Insert sample post-category relationships
172 | INSERT INTO
173 | post_categories (post_id, category_id)
174 | VALUES
175 | (1, 1),
176 | -- Getting Started with MSSQL -> Technology
177 | (2, 2),
178 | -- My Journey as a Developer -> Lifestyle
179 | (3, 1),
180 | -- Building Scalable Applications -> Technology
181 | (3, 3),
182 | -- Building Scalable Applications -> Business
183 | (4, 4),
184 | -- Traveling Through Europe -> Travel
185 | (5, 3),
186 | -- The Future of Remote Work -> Business
187 | (6, 5),
188 | -- Cooking Italian Cuisine -> Food
189 | (7, 1),
190 | -- Database Design Patterns -> Technology
191 | (8, 2);
192 |
193 | -- Work-Life Balance Tips -> Lifestyle
194 | -- Create indexes for better performance
195 | CREATE INDEX idx_users_email ON users(email);
196 |
197 | CREATE INDEX idx_posts_user_id ON posts(user_id);
198 |
199 | CREATE INDEX idx_posts_published ON posts(published);
200 |
201 | CREATE INDEX idx_posts_created_at ON posts(created_at);
202 |
203 | -- Create trigger for updated_at on users table
204 | CREATE TRIGGER tr_users_updated_at ON users
205 | AFTER
206 | UPDATE
207 | AS BEGIN
208 | UPDATE
209 | users
210 | SET
211 | updated_at = GETDATE()
212 | FROM
213 | users u
214 | INNER JOIN inserted i ON u.id = i.id;
215 |
216 | END;
217 |
218 | -- Create trigger for updated_at on posts table
219 | CREATE TRIGGER tr_posts_updated_at ON posts
220 | AFTER
221 | UPDATE
222 | AS BEGIN
223 | UPDATE
224 | posts
225 | SET
226 | updated_at = GETDATE()
227 | FROM
228 | posts p
229 | INNER JOIN inserted i ON p.id = i.id;
230 |
231 | END;
232 |
233 | -- JSON validation constraints (SQL Server 2016+)
234 | -- Add constraint to ensure preferences column contains valid JSON
235 | ALTER TABLE
236 | users
237 | ADD
238 | CONSTRAINT CK_users_preferences_json CHECK (
239 | preferences IS NULL
240 | OR ISJSON(preferences) = 1
241 | );
242 |
243 | -- Add constraint to ensure metadata column contains valid JSON
244 | ALTER TABLE
245 | posts
246 | ADD
247 | CONSTRAINT CK_posts_metadata_json CHECK (
248 | metadata IS NULL
249 | OR ISJSON(metadata) = 1
250 | );
251 |
252 | -- Example JSON queries that work with MSSQL
253 | -- These demonstrate how to query JSON data in MSSQL
254 | -- Query users with dark theme preference
255 | -- SELECT * FROM users WHERE JSON_VALUE(preferences, '$.theme') = 'dark';
256 | -- Query users with email notifications enabled
257 | -- SELECT * FROM users WHERE JSON_VALUE(preferences, '$.notifications.email') = 'true';
258 | -- Query posts with specific tags
259 | -- SELECT * FROM posts WHERE JSON_VALUE(metadata, '$.tags[0]') = 'database';
260 | -- Extract reading time from all posts
261 | -- SELECT title, JSON_VALUE(metadata, '$.reading_time') as reading_time FROM posts;
262 | -- Query posts with difficulty level
263 | -- SELECT * FROM posts WHERE JSON_VALUE(metadata, '$.difficulty') = 'advanced';
264 | -- Update JSON data (example - commented out)
265 | -- UPDATE users SET preferences = JSON_MODIFY(preferences, '$.theme', 'light') WHERE id = 1;
--------------------------------------------------------------------------------
/src/components/TableIntrospectionContent.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Table,
5 | TableBody,
6 | TableCell,
7 | TableHead,
8 | TableHeader,
9 | TableRow,
10 | } from "./ui/table";
11 | import { Badge } from "./ui/badge";
12 | import { Key, Link, Database } from "lucide-react";
13 |
14 | import { TableIntrospection, IntrospectionColumn } from "@/lib/types";
15 |
16 | interface TableIntrospectionContentProps {
17 | data: TableIntrospection;
18 | }
19 |
20 | export default function TableIntrospectionContent({
21 | data: introspection,
22 | }: TableIntrospectionContentProps) {
23 | const formatDataType = (column: IntrospectionColumn) => {
24 | let type = column.data_type;
25 |
26 | if (column.character_maximum_length) {
27 | type += `(${column.character_maximum_length})`;
28 | } else if (column.numeric_precision && column.numeric_scale !== null) {
29 | type += `(${column.numeric_precision},${column.numeric_scale})`;
30 | } else if (column.numeric_precision) {
31 | type += `(${column.numeric_precision})`;
32 | }
33 |
34 | return type;
35 | };
36 |
37 | const isPrimaryKeyColumn = (columnName: string) => {
38 | return introspection.primaryKeys.some(
39 | (pk) => pk.column_name === columnName
40 | );
41 | };
42 |
43 | const getForeignKeyInfo = (columnName: string) => {
44 | return introspection.foreignKeys.find(
45 | (fk) => fk.column_name === columnName
46 | );
47 | };
48 |
49 | return (
50 |
51 | {/* Columns Section */}
52 |
53 |
54 |
55 |
Columns
56 |
57 |
58 |
59 |
60 |
61 | Name
62 | Type
63 | Nullable
64 | Default
65 | Constraints
66 |
67 |
68 |
69 | {introspection.columns.map((column) => {
70 | const isPrimaryKey = isPrimaryKeyColumn(column.column_name);
71 | const foreignKey = getForeignKeyInfo(column.column_name);
72 |
73 | return (
74 |
75 |
76 | {column.column_name}
77 |
78 |
79 |
80 | {formatDataType(column)}
81 |
82 |
83 |
84 |
89 | {column.is_nullable === "YES" ? "NULL" : "NOT NULL"}
90 |
91 |
92 |
93 | {column.column_default ? (
94 |
95 | {column.column_default}
96 |
97 | ) : (
98 | —
99 | )}
100 |
101 |
102 |
103 | {isPrimaryKey && (
104 |
105 |
106 | PK
107 |
108 | )}
109 | {foreignKey && (
110 |
111 |
112 | FK → {foreignKey.foreign_table_name}
113 |
114 | )}
115 | {column.is_identity === "YES" && (
116 |
117 | Identity
118 |
119 | )}
120 |
121 |
122 |
123 | );
124 | })}
125 |
126 |
127 |
128 |
129 |
130 | {/* Primary Keys Section */}
131 | {introspection.primaryKeys.length > 0 && (
132 |
133 |
134 |
135 |
Primary Key
136 |
137 |
138 |
139 |
140 |
141 | Column
142 | Position
143 |
144 |
145 |
146 | {introspection.primaryKeys.map((pk) => (
147 |
148 |
149 | {pk.column_name}
150 |
151 | {pk.ordinal_position}
152 |
153 | ))}
154 |
155 |
156 |
157 |
158 | )}
159 |
160 | {/* Foreign Keys Section */}
161 | {introspection.foreignKeys.length > 0 && (
162 |
163 |
164 |
165 |
Foreign Keys
166 |
167 |
168 |
169 |
170 |
171 | Column
172 | References
173 | On Update
174 | On Delete
175 |
176 |
177 |
178 | {introspection.foreignKeys.map((fk) => (
179 |
180 |
181 | {fk.column_name}
182 |
183 |
184 |
185 | {fk.foreign_table_schema}.{fk.foreign_table_name}.
186 | {fk.foreign_column_name}
187 |
188 |
189 |
190 |
191 | {fk.update_rule}
192 |
193 |
194 |
195 |
196 | {fk.delete_rule}
197 |
198 |
199 |
200 | ))}
201 |
202 |
203 |
204 |
205 | )}
206 |
207 | {/* Indexes Section */}
208 | {introspection.indexes.length > 0 && (
209 |
210 |
211 |
212 |
Indexes
213 |
214 |
215 |
216 |
217 |
218 | Name
219 | Type
220 | Columns
221 | Properties
222 |
223 |
224 |
225 | {introspection.indexes.map((index) => (
226 |
227 |
228 | {index.index_name}
229 |
230 |
231 |
232 | {index.index_type}
233 |
234 |
235 |
236 | {Array.isArray(index.columns)
237 | ? index.columns.join(", ")
238 | : index.columns}
239 |
240 |
241 |
242 | {index.is_unique && (
243 |
244 | Unique
245 |
246 | )}
247 | {index.is_primary && (
248 |
249 | Primary
250 |
251 | )}
252 |
253 |
254 |
255 | ))}
256 |
257 |
258 |
259 |
260 | )}
261 |
262 | );
263 | }
264 |
--------------------------------------------------------------------------------
/tests/integration/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { expect } from "vitest";
2 | import { PostgreSqlContainer } from "@testcontainers/postgresql";
3 | import { MSSQLServerContainer } from "@testcontainers/mssqlserver";
4 | import { MySqlContainer } from "@testcontainers/mysql";
5 | import { DatabaseConfig, DatabaseConnection } from "@/lib/database";
6 | import { PostgreSQLAdapter } from "@/lib/database/postgresql-adapter";
7 | import { MSSQLAdapter } from "@/lib/database/mssql-adapter";
8 | import { MySQLAdapter } from "@/lib/database/mysql-adapter";
9 |
10 | export interface TestDatabase {
11 | connection: DatabaseConnection;
12 | config: DatabaseConfig;
13 | cleanup: () => Promise;
14 | }
15 |
16 | export async function setupPostgreSQLContainer(): Promise {
17 | const container = await new PostgreSqlContainer("postgres:15")
18 | .withDatabase("testdb")
19 | .withUsername("testuser")
20 | .withPassword("testpass")
21 | .start();
22 |
23 | const config: DatabaseConfig = {
24 | type: "postgresql",
25 | host: container.getHost(),
26 | port: container.getPort(),
27 | database: container.getDatabase(),
28 | username: container.getUsername(),
29 | password: container.getPassword(),
30 | };
31 |
32 | const adapter = new PostgreSQLAdapter();
33 | const connection = await adapter.connect(config);
34 |
35 | return {
36 | connection,
37 | config,
38 | cleanup: async () => {
39 | await connection.disconnect();
40 | await container.stop();
41 | },
42 | };
43 | }
44 |
45 | export async function setupMSSQLContainer(): Promise {
46 | const container = await new MSSQLServerContainer(
47 | "mcr.microsoft.com/azure-sql-edge:latest"
48 | )
49 | .acceptLicense()
50 | .start();
51 | // throw new Error(123);
52 |
53 | const config: DatabaseConfig = {
54 | type: "mssql",
55 | host: container.getHost(),
56 | port: container.getPort(),
57 | database: container.getDatabase(),
58 | username: container.getUsername(),
59 | password: container.getPassword(),
60 | };
61 |
62 | const adapter = new MSSQLAdapter();
63 | const connection = await adapter.connect(config);
64 |
65 | return {
66 | connection,
67 | config,
68 | cleanup: async () => {
69 | await connection.disconnect();
70 | await container.stop();
71 | },
72 | };
73 | }
74 |
75 | export async function setupMySQLContainer(): Promise {
76 | const container = await new MySqlContainer("mysql:8.0")
77 | .withDatabase("testdb")
78 | .withUsername("testuser")
79 | .withUserPassword("testpass")
80 | .withRootPassword("rootpass")
81 | .start();
82 |
83 | const config: DatabaseConfig = {
84 | type: "mysql",
85 | host: container.getHost(),
86 | port: container.getPort(),
87 | database: container.getDatabase(),
88 | username: container.getUsername(),
89 | password: container.getUserPassword(),
90 | };
91 |
92 | const adapter = new MySQLAdapter();
93 | const connection = await adapter.connect(config);
94 |
95 | return {
96 | connection,
97 | config,
98 | cleanup: async () => {
99 | await connection.disconnect();
100 | await container.stop();
101 | },
102 | };
103 | }
104 |
105 | export async function createTestTable(
106 | connection: DatabaseConnection,
107 | dbType: "postgresql" | "mssql" | "mysql"
108 | ): Promise {
109 | let createTableSQL: string;
110 |
111 | if (dbType === "postgresql") {
112 | createTableSQL = `
113 | CREATE TABLE test_users (
114 | id SERIAL PRIMARY KEY,
115 | name VARCHAR(255) NOT NULL,
116 | email VARCHAR(255) UNIQUE,
117 | age INTEGER,
118 | is_active BOOLEAN DEFAULT TRUE,
119 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
120 | );
121 |
122 | CREATE TABLE test_posts (
123 | id SERIAL PRIMARY KEY,
124 | title VARCHAR(255) NOT NULL,
125 | content TEXT,
126 | user_id INTEGER REFERENCES test_users(id),
127 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
128 | );
129 | `;
130 | } else if (dbType === "mssql") {
131 | createTableSQL = `
132 | CREATE TABLE test_users (
133 | id INT IDENTITY(1,1) PRIMARY KEY,
134 | name NVARCHAR(255) NOT NULL,
135 | email NVARCHAR(255) UNIQUE,
136 | age INT,
137 | is_active BIT DEFAULT 1,
138 | created_at DATETIME2 DEFAULT CURRENT_TIMESTAMP
139 | );
140 |
141 | CREATE TABLE test_posts (
142 | id INT IDENTITY(1,1) PRIMARY KEY,
143 | title NVARCHAR(255) NOT NULL,
144 | content NTEXT,
145 | user_id INT,
146 | created_at DATETIME2 DEFAULT CURRENT_TIMESTAMP,
147 | updated_at DATETIME2 DEFAULT CURRENT_TIMESTAMP,
148 | FOREIGN KEY (user_id) REFERENCES test_users(id)
149 | );
150 |
151 |
152 | `;
153 | } else {
154 | // MySQL - execute statements separately
155 | const createUsersSQL = `
156 | CREATE TABLE test_users (
157 | id INT AUTO_INCREMENT PRIMARY KEY,
158 | name VARCHAR(255) NOT NULL,
159 | email VARCHAR(255) UNIQUE,
160 | age INT,
161 | is_active BOOLEAN DEFAULT TRUE,
162 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
163 | )
164 | `;
165 |
166 | const createPostsSQL = `
167 | CREATE TABLE test_posts (
168 | id INT AUTO_INCREMENT PRIMARY KEY,
169 | title VARCHAR(255) NOT NULL,
170 | content TEXT,
171 | user_id INT,
172 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
173 | FOREIGN KEY (user_id) REFERENCES test_users(id)
174 | )
175 | `;
176 |
177 | await connection.executeQuery(createUsersSQL);
178 | await connection.executeQuery(createPostsSQL);
179 | return;
180 | }
181 |
182 | await connection.executeQuery(createTableSQL);
183 |
184 | // Create triggers for MSSQL test_posts table
185 | if (dbType === "mssql") {
186 | const triggerSQL = `
187 | CREATE TRIGGER tr_test_posts_updated_at ON test_posts
188 | AFTER UPDATE AS BEGIN
189 | UPDATE test_posts
190 | SET updated_at = GETDATE()
191 | FROM test_posts p
192 | INNER JOIN inserted i ON p.id = i.id;
193 | END;
194 | `;
195 | await connection.executeQuery(triggerSQL);
196 | }
197 | }
198 |
199 | export async function insertTestData(
200 | connection: DatabaseConnection
201 | ): Promise {
202 | // Insert test users
203 | await connection.insertTableRow("test_users", {
204 | name: "Alice Johnson",
205 | email: "alice@test.com",
206 | age: 30,
207 | is_active: true,
208 | });
209 |
210 | await connection.insertTableRow("test_users", {
211 | name: "Bob Smith",
212 | email: "bob@test.com",
213 | age: 25,
214 | is_active: false,
215 | });
216 |
217 | // Insert test posts
218 | await connection.insertTableRow("test_posts", {
219 | title: "First Post",
220 | content: "This is the first test post",
221 | user_id: 1,
222 | });
223 |
224 | await connection.insertTableRow("test_posts", {
225 | title: "Second Post",
226 | content: "This is the second test post",
227 | user_id: 2,
228 | });
229 | }
230 |
231 | export function expectTableRow(
232 | row: Record,
233 | expected: Record
234 | ): void {
235 | for (const [key, value] of Object.entries(expected)) {
236 | if (value === null) {
237 | expect(row[key] === "" || row[key] === null).toBe(true);
238 | } else if (typeof value === "boolean") {
239 | // Handle boolean conversion differences between databases
240 | expect(
241 | row[key] === "true" || row[key] === "1" || row[key] === "TRUE"
242 | ).toBe(value);
243 | } else {
244 | expect(row[key]).toBe(String(value));
245 | }
246 | }
247 | }
248 |
249 | export async function createJsonTestTable(
250 | connection: DatabaseConnection,
251 | dbType: "postgresql" | "mssql" | "mysql"
252 | ): Promise {
253 | let createTableSQL: string;
254 |
255 | if (dbType === "postgresql") {
256 | createTableSQL = `
257 | CREATE TABLE test_json (
258 | id SERIAL PRIMARY KEY,
259 | name VARCHAR(255) NOT NULL,
260 | profile JSONB,
261 | settings JSON,
262 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
263 | );
264 | `;
265 | } else if (dbType === "mssql") {
266 | createTableSQL = `
267 | CREATE TABLE test_json (
268 | id INT IDENTITY(1,1) PRIMARY KEY,
269 | name NVARCHAR(255) NOT NULL,
270 | profile NVARCHAR(MAX) CHECK (ISJSON(profile) = 1),
271 | settings NVARCHAR(MAX) CHECK (ISJSON(settings) = 1),
272 | created_at DATETIME2 DEFAULT CURRENT_TIMESTAMP
273 | );
274 | `;
275 | } else {
276 | // MySQL
277 | createTableSQL = `
278 | CREATE TABLE test_json (
279 | id INT AUTO_INCREMENT PRIMARY KEY,
280 | name VARCHAR(255) NOT NULL,
281 | profile JSON,
282 | settings JSON,
283 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
284 | )
285 | `;
286 | }
287 |
288 | await connection.executeQuery(createTableSQL);
289 | }
290 |
291 | export async function insertJsonTestData(
292 | connection: DatabaseConnection
293 | ): Promise {
294 | const sampleProfile = {
295 | bio: "Software developer with 5 years experience",
296 | avatar: "https://example.com/avatar1.jpg",
297 | location: "New York",
298 | skills: ["JavaScript", "Python", "React"],
299 | social: {
300 | twitter: "@johndoe",
301 | github: "johndoe",
302 | linkedin: "john-doe",
303 | },
304 | };
305 |
306 | const sampleSettings = {
307 | theme: "dark",
308 | language: "en",
309 | notifications: {
310 | email: true,
311 | push: false,
312 | sms: true,
313 | },
314 | privacy: {
315 | showEmail: false,
316 | showProfile: true,
317 | },
318 | features: ["beta", "experimental"],
319 | };
320 |
321 | // Insert test records with JSON data
322 | await connection.insertTableRow("test_json", {
323 | name: "John Doe",
324 | profile: JSON.stringify(sampleProfile),
325 | settings: JSON.stringify(sampleSettings),
326 | });
327 |
328 | await connection.insertTableRow("test_json", {
329 | name: "Jane Smith",
330 | profile: JSON.stringify({
331 | bio: "UX Designer passionate about user experience",
332 | avatar: "https://example.com/avatar2.jpg",
333 | location: "San Francisco",
334 | skills: ["Figma", "Sketch", "Adobe XD"],
335 | social: {
336 | twitter: "@janesmith",
337 | github: "janesmith",
338 | },
339 | }),
340 | settings: JSON.stringify({
341 | theme: "light",
342 | language: "es",
343 | notifications: {
344 | email: false,
345 | push: true,
346 | sms: false,
347 | },
348 | privacy: {
349 | showEmail: true,
350 | showProfile: true,
351 | },
352 | }),
353 | });
354 |
355 | // Insert a record with empty JSON profile
356 | await connection.insertTableRow("test_json", {
357 | name: "Bob Wilson",
358 | settings: JSON.stringify({
359 | theme: "auto",
360 | language: "en",
361 | }),
362 | });
363 | }
364 |
--------------------------------------------------------------------------------
/src/components/SearchableSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useRef, useEffect, useMemo } from "react";
4 | import { useRouter, usePathname } from "next/navigation";
5 | import { Search, Table, Code, Eye, MessageSquare } from "lucide-react";
6 | import { ThemeSwitcher } from "./ThemeSwitcher";
7 | import Fuse from "fuse.js";
8 | import {
9 | SidebarContent,
10 | SidebarGroup,
11 | SidebarGroupContent,
12 | SidebarMenu,
13 | SidebarMenuButton,
14 | SidebarMenuItem,
15 | SidebarFooter,
16 | } from "@/components/ui/sidebar";
17 | import Link from "next/link";
18 |
19 | interface TableInfo {
20 | table_name: string;
21 | schema_name: string;
22 | full_table_name: string;
23 | table_type: string;
24 | }
25 |
26 | interface SearchableSidebarContentProps {
27 | tables: TableInfo[];
28 | }
29 |
30 | export default function SearchableSidebarContent({
31 | tables,
32 | }: SearchableSidebarContentProps) {
33 | const [searchQuery, setSearchQuery] = useState("");
34 | const [filteredTables, setFilteredTables] = useState(tables);
35 | const [selectedIndex, setSelectedIndex] = useState(0);
36 | const [isInputFocused, setIsInputFocused] = useState(false);
37 | const searchInputRef = useRef(null);
38 | const router = useRouter();
39 | const pathname = usePathname();
40 |
41 | // Initialize Fuse.js for fuzzy search using useMemo
42 | const fuse = useMemo(
43 | () =>
44 | new Fuse(tables, {
45 | keys: ["table_name", "schema_name", "full_table_name"],
46 | threshold: 0.3, // Lower threshold means more strict matching
47 | includeScore: true,
48 | }),
49 | [tables]
50 | );
51 |
52 | // Group tables by schema for display
53 | const groupedTables = useMemo(() => {
54 | const grouped: Record = {};
55 | filteredTables.forEach((table) => {
56 | if (!grouped[table.schema_name]) {
57 | grouped[table.schema_name] = [];
58 | }
59 | grouped[table.schema_name].push(table);
60 | });
61 | return grouped;
62 | }, [filteredTables]);
63 |
64 | // Get the currently selected table
65 | const selectedTable = useMemo(() => {
66 | return filteredTables.length > 0 &&
67 | selectedIndex >= 0 &&
68 | selectedIndex < filteredTables.length
69 | ? filteredTables[selectedIndex]
70 | : null;
71 | }, [filteredTables, selectedIndex]);
72 |
73 | // Clear search when route changes
74 | useEffect(() => {
75 | setSearchQuery("");
76 | setSelectedIndex(0);
77 | }, [pathname]);
78 |
79 | // Handle search input changes
80 | useEffect(() => {
81 | if (searchQuery.trim() === "") {
82 | setFilteredTables(tables);
83 | } else {
84 | const results = fuse.search(searchQuery);
85 | setFilteredTables(results.map((result) => result.item));
86 | }
87 | // Reset selection when search changes
88 | setSelectedIndex(0);
89 | }, [searchQuery, tables, fuse]);
90 |
91 | // Handle keyboard shortcuts
92 | useEffect(() => {
93 | const handleKeyDown = (event: KeyboardEvent) => {
94 | // Cmd+K or Ctrl+K to focus search - this should work globally
95 | if ((event.metaKey || event.ctrlKey) && event.key === "k") {
96 | event.preventDefault();
97 | searchInputRef.current?.focus();
98 | return;
99 | }
100 |
101 | // Only handle other keyboard shortcuts when search input is focused
102 | if (!isInputFocused) {
103 | return;
104 | }
105 |
106 | // Arrow key navigation
107 | if (event.key === "ArrowDown") {
108 | event.preventDefault();
109 | setSelectedIndex((prevIndex) => {
110 | const maxIndex = filteredTables.length - 1;
111 | return prevIndex < maxIndex ? prevIndex + 1 : 0; // Wrap to beginning
112 | });
113 | return;
114 | }
115 |
116 | if (event.key === "ArrowUp") {
117 | event.preventDefault();
118 | setSelectedIndex((prevIndex) => {
119 | const maxIndex = filteredTables.length - 1;
120 | return prevIndex > 0 ? prevIndex - 1 : maxIndex; // Wrap to end
121 | });
122 | return;
123 | }
124 |
125 | // Enter key to navigate to selected result
126 | if (event.key === "Enter") {
127 | event.preventDefault();
128 | if (selectedTable) {
129 | router.push(`/${encodeURIComponent(selectedTable.full_table_name)}`);
130 | }
131 | searchInputRef.current?.blur();
132 | return;
133 | }
134 |
135 | // Escape to clear search and unfocus
136 | if (event.key === "Escape") {
137 | setSearchQuery("");
138 | setSelectedIndex(0);
139 | searchInputRef.current?.blur();
140 | return;
141 | }
142 | };
143 |
144 | document.addEventListener("keydown", handleKeyDown);
145 | return () => document.removeEventListener("keydown", handleKeyDown);
146 | }, [filteredTables, selectedTable, router, isInputFocused]);
147 |
148 | return (
149 | <>
150 |
151 |
152 |
153 | {/* Search Input with Theme Switcher */}
154 |
155 |
156 |
157 | setSearchQuery(e.target.value)}
163 | onFocus={() => setIsInputFocused(true)}
164 | onBlur={() => setIsInputFocused(false)}
165 | data-testid="search-input"
166 | className="w-full pl-8 pr-2 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
167 | />
168 |
169 |
170 |
171 |
172 | {/* Search hint when focused and has results */}
173 | {isInputFocused && filteredTables.length > 0 && (
174 |
175 | {selectedTable ? (
176 | <>
177 | Press Enter to open "{selectedTable.table_name}" •
178 | Use ↑↓ arrows to navigate
179 | >
180 | ) : (
181 | "Use ↑↓ arrows to navigate, Enter to select"
182 | )}
183 |
184 | )}
185 |
186 | {/* Tables List */}
187 |
188 | {Object.keys(groupedTables).length === 0 &&
189 | searchQuery.trim() !== "" ? (
190 |
191 | No tables or views found
192 |
193 | ) : (
194 | Object.entries(groupedTables).map(
195 | ([schemaName, schemaTables]) => (
196 |
197 |
201 | {schemaName}
202 |
203 | {schemaTables.map((table) => {
204 | // Get the index of this table in the filtered results
205 | const tableIndex = filteredTables.findIndex(
206 | (t) => t.full_table_name === table.full_table_name
207 | );
208 | // Only highlight when input is focused or there's an active search
209 | const isSelected =
210 | (isInputFocused || searchQuery.trim() !== "") &&
211 | tableIndex === selectedIndex;
212 |
213 | return (
214 |
215 |
216 |
227 | {table.table_type === "VIEW" ? (
228 |
229 | ) : (
230 |
231 | )}
232 | {table.table_name}
233 |
234 |
235 | {table.table_type === "VIEW"
236 | ? "view"
237 | : "table"}
238 |
239 | {isSelected && (
240 |
241 | ↵
242 |
243 | )}
244 |
245 |
246 |
247 |
248 | );
249 | })}
250 |
251 | )
252 | )
253 | )}
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
267 |
268 | Run SQL
269 |
270 |
271 |
272 |
273 |
274 |
278 |
279 | Chat with DB
280 |
281 |
282 |
283 |
284 |
285 | >
286 | );
287 | }
288 |
--------------------------------------------------------------------------------