├── public └── .gitkeep ├── screenshots ├── chat.png ├── editing.png ├── table.png ├── custom-sql.png └── introspection.png ├── env.mysql ├── postcss.config.mjs ├── env.mssql ├── env.postgresql ├── next.config.ts ├── src ├── app │ ├── sql │ │ └── page.tsx │ ├── icon.svg │ ├── page.tsx │ ├── api │ │ ├── sql │ │ │ └── route.ts │ │ ├── execute-sql │ │ │ └── route.ts │ │ └── chat │ │ │ └── route.ts │ ├── layout.tsx │ ├── [tableName] │ │ ├── page.tsx │ │ └── TablePageClient.tsx │ └── globals.css ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── tooltip.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── combobox.tsx │ │ ├── table.tsx │ │ ├── pagination.tsx │ │ ├── dialog.tsx │ │ ├── sheet.tsx │ │ ├── alert-dialog.tsx │ │ ├── command.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx │ ├── ThemeProvider.tsx │ ├── Sidebar.tsx │ ├── QueryControls.tsx │ ├── TableIntrospectionButton.tsx │ ├── TableHeader.tsx │ ├── AddRowButton.tsx │ ├── ThemeSwitcher.tsx │ ├── SortBar.tsx │ ├── JSONEditor.tsx │ ├── DeleteRowButton.tsx │ ├── TableDisplay.tsx │ ├── FilterBar.tsx │ ├── UpdateRowButton.tsx │ ├── TableRowForm.tsx │ ├── SqlQueryBlock.tsx │ ├── TableIntrospectionContent.tsx │ └── SearchableSidebar.tsx ├── hooks │ └── use-mobile.ts └── lib │ ├── types.ts │ ├── utils.ts │ ├── database │ ├── index.ts │ └── factory.ts │ └── db.ts ├── docker └── mssql │ ├── mssql-entrypoint.sh │ ├── Dockerfile.mssql │ └── mssql-init.py ├── tailwind.config.js ├── .gitignore ├── eslint.config.mjs ├── vitest.config.ts ├── components.json ├── env.example ├── tsconfig.json ├── .cursor └── rules │ ├── server-components.mdc │ ├── verify-changes.mdc │ └── project-overview.mdc ├── .dockerignore ├── docker-compose.yml ├── Dockerfile ├── .github └── workflows │ └── docker-publish.yml ├── package.json ├── README.md ├── db └── mssql-init.sql └── tests └── integration └── test-utils.ts /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshots/chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n7olkachev/db-ui/HEAD/screenshots/chat.png -------------------------------------------------------------------------------- /screenshots/editing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n7olkachev/db-ui/HEAD/screenshots/editing.png -------------------------------------------------------------------------------- /screenshots/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n7olkachev/db-ui/HEAD/screenshots/table.png -------------------------------------------------------------------------------- /screenshots/custom-sql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n7olkachev/db-ui/HEAD/screenshots/custom-sql.png -------------------------------------------------------------------------------- /screenshots/introspection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n7olkachev/db-ui/HEAD/screenshots/introspection.png -------------------------------------------------------------------------------- /env.mysql: -------------------------------------------------------------------------------- 1 | MYSQL_HOST=localhost 2 | MYSQL_PORT=3306 3 | MYSQL_DB=testdb 4 | MYSQL_USER=admin 5 | MYSQL_PASSWORD=admin -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /env.mssql: -------------------------------------------------------------------------------- 1 | MSSQL_HOST=localhost 2 | MSSQL_PORT=1433 3 | MSSQL_DB=testdb 4 | MSSQL_USER=sa 5 | MSSQL_PASSWORD=yourStrong(!)Password -------------------------------------------------------------------------------- /env.postgresql: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=localhost 2 | POSTGRES_PORT=5432 3 | POSTGRES_DB=sampledb 4 | POSTGRES_USER=admin 5 | POSTGRES_PASSWORD=admin -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | output: "standalone", 5 | /* config options here */ 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /src/app/sql/page.tsx: -------------------------------------------------------------------------------- 1 | import SQLQueryClient from "./SQLQueryClient"; 2 | 3 | // Force dynamic rendering since this page is interactive and involves database queries 4 | export const dynamic = "force-dynamic"; 5 | 6 | export default function SQLPage() { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /docker/mssql/mssql-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start SQL Server in the background 4 | /opt/mssql/bin/sqlservr & 5 | 6 | # Run the Python initialization script 7 | python3 /usr/src/app/mssql-init.py & 8 | 9 | # Keep the container running by waiting for the SQL Server process 10 | wait -------------------------------------------------------------------------------- /src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | DB 4 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | "./src/app/**/*.{js,ts,jsx,tsx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx}", 7 | "./src/lib/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [require("@tailwindcss/typography")], 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.pnp 3 | .pnp.* 4 | .yarn/* 5 | !.yarn/patches 6 | !.yarn/plugins 7 | !.yarn/releases 8 | !.yarn/versions 9 | 10 | /coverage 11 | 12 | /.next/ 13 | /out/ 14 | 15 | /build 16 | 17 | .DS_Store 18 | *.pem 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .pnpm-debug.log* 24 | 25 | .env* 26 | 27 | .vercel 28 | 29 | *.tsbuildinfo 30 | next-env.d.ts 31 | 32 | docker-compose.test.yml -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { getTables } from "@/lib/db"; 2 | import { Sidebar as ShadcnSidebar } from "@/components/ui/sidebar"; 3 | import SearchableSidebarContent from "./SearchableSidebar"; 4 | 5 | export default async function Sidebar() { 6 | const tables = await getTables(); 7 | 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: "node", 7 | include: ["tests/integration/**/*.test.{ts,js}"], 8 | testTimeout: 60000, // 60 seconds for container startup 9 | hookTimeout: 60000, 10 | teardownTimeout: 60000, 11 | globals: true, 12 | }, 13 | resolve: { 14 | alias: { 15 | "@": path.resolve(__dirname, "./src"), 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # PSQL 2 | POSTGRES_USER=your_username 3 | POSTGRES_HOST=localhost 4 | POSTGRES_DB=your_database 5 | POSTGRES_PASSWORD=your_password 6 | POSTGRES_PORT=5432 7 | 8 | # MSSQL 9 | # MSSQL_USER=your_username 10 | # MSSQL_HOST=localhost 11 | # MSSQL_DB=your_database 12 | # MSSQL_PASSWORD=your_password 13 | # MSSQL_PORT=5432 14 | 15 | # MYSQL 16 | # MYSQL_USER=your_username 17 | # MYSQL_HOST=localhost 18 | # MYSQL_DB=your_database 19 | # MYSQL_PASSWORD=your_password 20 | # MYSQL_PORT=5432 21 | 22 | GROQ_API_KEY=your_groq_api_key 23 | GROQ_MODEL=llama-3.1-70b-versatile -------------------------------------------------------------------------------- /src/components/QueryControls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "./ui/button"; 4 | 5 | interface QueryControlsProps { 6 | onClear: () => void; 7 | } 8 | 9 | export default function QueryControls({ onClear }: QueryControlsProps) { 10 | return ( 11 |
12 | 15 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /.cursor/rules/server-components.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Next.js Server Components Rules 7 | 8 | 1. ALWAYS use server components for data fetching in Next.js applications 9 | 2. NEVER use client-side state management when URL parameters can be used instead 10 | 3. ALWAYS fetch data on the server side using async/await in server components 11 | 4. NEVER use useEffect or useState for data fetching 12 | 5. ALWAYS make components async when they need to fetch data 13 | 6. ALWAYS use proper typing for component props and data structures 14 | 7. ALWAYS handle loading and error states appropriately 15 | 8. ALWAYS use proper error boundaries for server components 16 | 9. ALWAYS use proper caching strategies for server components 17 | 10. ALWAYS use proper revalidation strategies for server components 18 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log* 4 | 5 | # Next.js build outputs 6 | .next 7 | out 8 | 9 | # Environment variables 10 | .env* 11 | !.env.example 12 | 13 | # Logs 14 | *.log 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # OS generated files 26 | .DS_Store 27 | .DS_Store? 28 | ._* 29 | .Spotlight-V100 30 | .Trashes 31 | ehthumbs.db 32 | Thumbs.db 33 | 34 | # IDE files 35 | .vscode 36 | .idea 37 | *.swp 38 | *.swo 39 | 40 | # Git 41 | .git 42 | .gitignore 43 | 44 | # Docker 45 | Dockerfile* 46 | docker-compose* 47 | 48 | # Temporary files 49 | tmp 50 | temp 51 | 52 | # Test files 53 | tests 54 | test 55 | __tests__ 56 | 57 | # Documentation 58 | README.md 59 | *.md 60 | 61 | # GitHub workflows 62 | .github 63 | 64 | # Cursor IDE 65 | .cursor -------------------------------------------------------------------------------- /docker/mssql/Dockerfile.mssql: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/azure-sql-edge:latest 2 | 3 | # Install Python and dependencies (ODBC driver is already available) 4 | USER root 5 | RUN apt-get update && apt-get install -y \ 6 | python3 python3-pip unixodbc-dev \ 7 | && pip3 install pyodbc \ 8 | && apt-get clean \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | # Create app directory 12 | WORKDIR /usr/src/app 13 | 14 | # Copy initialization files 15 | COPY db/mssql-init.sql ./mssql-init.sql 16 | COPY --chmod=755 docker/mssql/mssql-entrypoint.sh ./entrypoint.sh 17 | COPY --chmod=755 docker/mssql/mssql-init.py ./mssql-init.py 18 | 19 | # Set environment variables 20 | ENV ACCEPT_EULA=Y 21 | ENV MSSQL_SA_PASSWORD=yourStrong(!)Password 22 | 23 | # Switch back to mssql user 24 | USER mssql 25 | 26 | # Expose the SQL Server port 27 | EXPOSE 1433 28 | 29 | # Use our custom entrypoint 30 | CMD ["./entrypoint.sh"] -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | // Force dynamic rendering since this page uses random content and is part of a database app 2 | export const dynamic = "force-dynamic"; 3 | 4 | const welcomeMessages = [ 5 | { 6 | title: "Welcome", 7 | tips: [], 8 | }, 9 | ]; 10 | 11 | export default function Home() { 12 | const message = 13 | welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)]; 14 | 15 | return ( 16 |
17 |
18 |
19 |

{message.title}

20 |
21 |
22 | {message.tips.map((tip, index) => ( 23 |

{tip}

24 | ))} 25 |
26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/sql/route.ts: -------------------------------------------------------------------------------- 1 | import { executeQuery } from "@/lib/db"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const { query } = await request.json(); 7 | 8 | if (!query || typeof query !== "string") { 9 | return NextResponse.json( 10 | { 11 | success: false, 12 | error: "Query is required and must be a string", 13 | rows: [], 14 | rowCount: 0, 15 | fields: [], 16 | }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | const result = await executeQuery(query); 22 | return NextResponse.json(result); 23 | } catch { 24 | return NextResponse.json( 25 | { 26 | success: false, 27 | error: "Internal server error", 28 | rows: [], 29 | rowCount: 0, 30 | fields: [], 31 | }, 32 | { status: 500 } 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | # PostgreSQL Database 4 | postgres: 5 | image: postgres:15 6 | restart: always 7 | environment: 8 | POSTGRES_USER: admin 9 | POSTGRES_PASSWORD: admin 10 | POSTGRES_DB: sampledb 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - ./db/pg-init.sql:/docker-entrypoint-initdb.d/init.sql 15 | 16 | # MySQL Database 17 | mysql: 18 | image: mysql:8.0 19 | restart: always 20 | environment: 21 | MYSQL_ROOT_PASSWORD: admin 22 | MYSQL_DATABASE: testdb 23 | MYSQL_USER: admin 24 | MYSQL_PASSWORD: admin 25 | ports: 26 | - "3306:3306" 27 | volumes: 28 | - ./db/mysql-init.sql:/docker-entrypoint-initdb.d/mysql-init.sql 29 | 30 | # MSSQL Database 31 | mssql: 32 | build: 33 | context: . 34 | dockerfile: docker/mssql/Dockerfile.mssql 35 | restart: always 36 | environment: 37 | ACCEPT_EULA: Y 38 | MSSQL_SA_PASSWORD: yourStrong(!)Password 39 | ports: 40 | - "1433:1433" 41 | volumes: 42 | - ./db/mssql-init.sql:/usr/src/app/mssql-init.sql 43 | -------------------------------------------------------------------------------- /src/components/TableIntrospectionButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "./ui/button"; 5 | import { 6 | Sheet, 7 | SheetContent, 8 | SheetHeader, 9 | SheetTitle, 10 | SheetTrigger, 11 | } from "./ui/sheet"; 12 | import { Info } from "lucide-react"; 13 | import TableIntrospectionContent from "./TableIntrospectionContent"; 14 | 15 | interface TableIntrospectionButtonProps { 16 | tableName: string; 17 | introspection: import("@/lib/types").TableIntrospection; 18 | } 19 | 20 | export default function TableIntrospectionButton({ 21 | tableName, 22 | introspection, 23 | }: TableIntrospectionButtonProps) { 24 | const [isOpen, setIsOpen] = useState(false); 25 | 26 | return ( 27 | 28 | 29 | 33 | 34 | 35 | 36 | Table Schema: {tableName} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/TableHeader.tsx: -------------------------------------------------------------------------------- 1 | import AddRowButton from "./AddRowButton"; 2 | import TableIntrospectionButton from "./TableIntrospectionButton"; 3 | 4 | interface TableHeaderProps { 5 | tableName: string; 6 | columns: string[]; 7 | columnTypes: Record; 8 | tableType?: string | null; 9 | introspection: import("@/lib/types").TableIntrospection; 10 | } 11 | 12 | export default function TableHeader({ 13 | tableName, 14 | columns, 15 | columnTypes, 16 | tableType, 17 | introspection, 18 | }: TableHeaderProps) { 19 | return ( 20 |
21 |
22 |
23 |

{tableName}

24 |
25 |
26 | 30 | {tableType === "BASE TABLE" && ( 31 | 36 | )} 37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/api/execute-sql/route.ts: -------------------------------------------------------------------------------- 1 | import { executeQuery } from "@/lib/db"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const { query, toolCallId } = await request.json(); 7 | 8 | if (!query || typeof query !== "string") { 9 | return NextResponse.json( 10 | { 11 | success: false, 12 | error: "Query is required and must be a string", 13 | rows: [], 14 | rowCount: 0, 15 | fields: [], 16 | }, 17 | { status: 400 } 18 | ); 19 | } 20 | 21 | if (!toolCallId || typeof toolCallId !== "string") { 22 | return NextResponse.json( 23 | { 24 | success: false, 25 | error: "Tool call ID is required", 26 | rows: [], 27 | rowCount: 0, 28 | fields: [], 29 | }, 30 | { status: 400 } 31 | ); 32 | } 33 | 34 | const result = await executeQuery(query); 35 | 36 | return NextResponse.json({ 37 | ...result, 38 | toolCallId, 39 | }); 40 | } catch { 41 | return NextResponse.json( 42 | { 43 | success: false, 44 | error: "Internal server error", 45 | rows: [], 46 | rowCount: 0, 47 | fields: [], 48 | toolCallId: null, 49 | }, 50 | { status: 500 } 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.cursor/rules/verify-changes.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | # Verify Changes with Linting and Type Checking 8 | 9 | ## Description 10 | 11 | Always verify your changes by running linting and type checking before declaring them complete. This ensures: 12 | 13 | 1. No TypeScript errors 14 | 2. No linting issues 15 | 3. No runtime errors from type mismatches 16 | 17 | ## Process 18 | 19 | 1. After making changes, run your linter tool or you don't have any: 20 | 21 | ```bash 22 | # Check TypeScript types 23 | npx tsc --noEmit 24 | 25 | # Run linter 26 | npm run lint 27 | ``` 28 | 29 | 2. Fix any errors before proceeding 30 | 3. Never assume changes are complete without verification 31 | 32 | ## Examples 33 | 34 | ### Good 35 | 36 | ```bash 37 | # Make changes to code 38 | # Run verification 39 | npx tsc --noEmit 40 | npm run lint 41 | # Fix any errors 42 | # Then proceed with implementation 43 | ``` 44 | 45 | ### Bad 46 | 47 | ```bash 48 | # Make changes to code 49 | # Assume everything works without verification 50 | # Continue with implementation 51 | ``` 52 | 53 | ## Rationale 54 | 55 | TypeScript and linting errors can indicate: 56 | 57 | 1. Missing dependencies 58 | 2. Incorrect imports 59 | 3. Type mismatches 60 | 4. Potential runtime errors 61 | 62 | Always verify changes to prevent these issues from reaching production. 63 | 64 | DO NOT run npm run dev yourself, only run llint scripts. 65 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface IntrospectionColumn { 2 | column_name: string; 3 | ordinal_position: number; 4 | column_default: string | null; 5 | is_nullable: "YES" | "NO"; 6 | data_type: string; 7 | udt_name: string; 8 | character_maximum_length: number | null; 9 | numeric_precision: number | null; 10 | numeric_scale: number | null; 11 | datetime_precision: number | null; 12 | is_identity: "YES" | "NO"; 13 | identity_generation: string | null; 14 | is_generated: "NEVER" | "ALWAYS" | "BY DEFAULT"; 15 | generation_expression: string | null; 16 | column_comment: string | null; 17 | } 18 | 19 | export interface IntrospectionPrimaryKey { 20 | column_name: string; 21 | ordinal_position: number; 22 | } 23 | 24 | export interface IntrospectionForeignKey { 25 | column_name: string; 26 | foreign_table_schema: string; 27 | foreign_table_name: string; 28 | foreign_column_name: string; 29 | constraint_name: string; 30 | update_rule: string; 31 | delete_rule: string; 32 | } 33 | 34 | export interface IntrospectionIndex { 35 | index_name: string; 36 | index_type: string; 37 | is_unique: boolean; 38 | is_primary: boolean; 39 | columns: string[] | string; 40 | } 41 | 42 | export interface TableIntrospection { 43 | columns: IntrospectionColumn[]; 44 | primaryKeys: IntrospectionPrimaryKey[]; 45 | foreignKeys: IntrospectionForeignKey[]; 46 | indexes: IntrospectionIndex[]; 47 | } 48 | 49 | export interface Filter { 50 | column: string; 51 | operator: string; 52 | value: string; 53 | } 54 | 55 | export interface Sort { 56 | column: string; 57 | direction: "asc" | "desc"; 58 | } 59 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { 5 | SidebarProvider, 6 | SidebarTrigger, 7 | SidebarInset, 8 | } from "@/components/ui/sidebar"; 9 | import Sidebar from "@/components/Sidebar"; 10 | import { ThemeProvider } from "@/components/ThemeProvider"; 11 | 12 | // Force dynamic rendering for the entire app since it's a database management tool 13 | export const dynamic = "force-dynamic"; 14 | 15 | const inter = Inter({ subsets: ["latin"] }); 16 | 17 | export const metadata: Metadata = { 18 | title: "Database UI", 19 | description: "A simple database UI", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: { 25 | children: React.ReactNode; 26 | }) { 27 | return ( 28 | 29 | 30 | 36 | 37 | 38 | 39 |
40 | 44 |
45 |
{children}
46 |
47 |
48 |
49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/AddRowButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { insertTableRow } 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 { Plus } from "lucide-react"; 15 | import TableRowForm from "./TableRowForm"; 16 | 17 | interface AddRowButtonProps { 18 | tableName: string; 19 | columns: string[]; 20 | columnTypes: Record; 21 | } 22 | 23 | export default function AddRowButton({ 24 | tableName, 25 | columns, 26 | columnTypes, 27 | }: AddRowButtonProps) { 28 | const [isOpen, setIsOpen] = useState(false); 29 | const router = useRouter(); 30 | 31 | const handleSubmit = async (formData: Record) => { 32 | await insertTableRow(tableName, formData); 33 | setIsOpen(false); 34 | router.refresh(); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 44 | 45 | 46 | 47 | Add New Row to {tableName} 48 | 49 | setIsOpen(false)} 55 | /> 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js 18 image as base 2 | FROM node:18-alpine AS base 3 | 4 | # Install dependencies only when needed 5 | FROM base AS deps 6 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 7 | RUN apk add --no-cache libc6-compat 8 | WORKDIR /app 9 | 10 | # Install dependencies based on the preferred package manager 11 | COPY package.json package-lock.json* ./ 12 | RUN npm ci 13 | 14 | # Rebuild the source code only when needed 15 | FROM base AS builder 16 | WORKDIR /app 17 | COPY --from=deps /app/node_modules ./node_modules 18 | COPY . . 19 | 20 | # Disable telemetry during the build 21 | ENV NEXT_TELEMETRY_DISABLED 1 22 | 23 | # Build the application 24 | RUN npm run build 25 | 26 | # Production image, copy all the files and run next 27 | FROM base AS runner 28 | WORKDIR /app 29 | 30 | ENV NODE_ENV production 31 | ENV NEXT_TELEMETRY_DISABLED 1 32 | 33 | RUN addgroup --system --gid 1001 nodejs 34 | RUN adduser --system --uid 1001 nextjs 35 | 36 | # Copy the built application 37 | COPY --from=builder /app/public ./public 38 | 39 | # Set the correct permission for prerender cache 40 | RUN mkdir .next 41 | RUN chown nextjs:nodejs .next 42 | 43 | # Automatically leverage output traces to reduce image size 44 | # https://nextjs.org/docs/advanced-features/output-file-tracing 45 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 46 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 47 | 48 | USER nextjs 49 | 50 | EXPOSE 3000 51 | 52 | ENV PORT 3000 53 | ENV HOSTNAME "0.0.0.0" 54 | 55 | # server.js is created by next build from the standalone output 56 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 57 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }: React.ComponentProps) { 11 | return 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }: React.ComponentProps) { 17 | return 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 28 | 38 | 39 | ) 40 | } 41 | 42 | function PopoverAnchor({ 43 | ...props 44 | }: React.ComponentProps) { 45 | return 46 | } 47 | 48 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 49 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )); 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )); 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 47 | 48 | export { ScrollArea, ScrollBar }; 49 | -------------------------------------------------------------------------------- /src/components/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun, Monitor } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ThemeSwitcher() { 16 | const { setTheme, theme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")} 30 | className={theme === "light" ? "bg-accent" : ""} 31 | > 32 | 33 | Light 34 | 35 | setTheme("dark")} 37 | className={theme === "dark" ? "bg-accent" : ""} 38 | > 39 | 40 | Dark 41 | 42 | setTheme("system")} 44 | className={theme === "system" ? "bg-accent" : ""} 45 | > 46 | 47 | System 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build-and-push: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | strategy: 22 | matrix: 23 | platform: 24 | - linux/amd64 25 | - linux/arm64 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Log in to Container Registry 32 | if: github.event_name != 'pull_request' 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | tags: | 45 | type=ref,event=branch 46 | type=ref,event=pr 47 | type=semver,pattern={{version}} 48 | type=semver,pattern={{major}}.{{minor}} 49 | type=semver,pattern={{major}} 50 | type=raw,value=latest,enable={{is_default_branch}} 51 | 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v3 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v5 57 | with: 58 | context: . 59 | platforms: ${{ matrix.platform }} 60 | push: ${{ github.event_name != 'pull_request' }} 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function TooltipProvider({ 9 | delayDuration = 0, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function Tooltip({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | function TooltipTrigger({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function TooltipContent({ 38 | className, 39 | sideOffset = 0, 40 | children, 41 | ...props 42 | }: React.ComponentProps) { 43 | return ( 44 | 45 | 54 | {children} 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 62 | -------------------------------------------------------------------------------- /.cursor/rules/project-overview.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | # Database Management UI Project Overview 8 | 9 | ## Description 10 | 11 | This project is a modern, user-friendly database management web interface built with Next.js, TypeScript, and shadcn/ui. It aims to provide a simpler and more elegant alternative to traditional database management tools like pgAdmin or phpmyadmin. 12 | 13 | ## Tech Stack 14 | 15 | - **Framework**: Next.js with App Router 16 | - **Language**: TypeScript 17 | - **UI Components**: shadcn/ui 18 | - **Database**: PostgreSQL, MySQL and MSSQL must be supported 19 | - **Styling**: Tailwind CSS 20 | 21 | ## Project Goals 22 | 23 | 1. **Simplicity**: Provide an intuitive interface for database operations 24 | 2. **Modern Design**: Clean, responsive UI with a focus on user experience 25 | 3. **Performance**: Efficient data handling and real-time updates 26 | 4. **Security**: Secure database connections and operations 27 | 5. **Developer Experience**: Type-safe development with TypeScript 28 | 29 | ## Key Features 30 | 31 | - Database connection management 32 | - Table browsing and editing 33 | - Query execution interface 34 | - Schema visualization 35 | - Data export/import capabilities 36 | 37 | ## Development Guidelines 38 | 39 | 1. Follow TypeScript best practices and maintain type safety 40 | 2. Use server components for data fetching operations 41 | 3. Implement responsive design for all components 42 | 4. Write clean, maintainable code with proper documentation 43 | 5. Follow shadcn/ui design patterns for consistency 44 | 45 | ## Server Components Usage 46 | 47 | Always prefer server components for data fetching operations. Use 'use server' directive and server components by default unless there's a specific reason to use client components (like interactivity or browser APIs). 48 | 49 | ### Examples 50 | 51 | #### Good 52 | 53 | ```tsx 54 | "use server"; 55 | // In a server component 56 | async function getData() { 57 | const data = await db.query("SELECT * FROM users"); 58 | return data; 59 | } 60 | ``` 61 | 62 | #### Bad 63 | 64 | ```tsx 65 | // In a client component 66 | 'use client'; 67 | useEffect(() => { 68 | fetch('/api/users').then(...); 69 | }, []); 70 | ``` -------------------------------------------------------------------------------- /src/app/[tableName]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getTableData, 3 | getTablePrimaryKeys, 4 | getTableColumns, 5 | getTableColumnTypes, 6 | getTableType, 7 | getTableIntrospection, 8 | } from "@/lib/db"; 9 | import TablePageClient from "./TablePageClient"; 10 | import { Metadata } from "next"; 11 | 12 | // Force dynamic rendering since this page fetches database data 13 | export const dynamic = "force-dynamic"; 14 | 15 | interface Props { 16 | params: Promise<{ tableName: string }>; 17 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 18 | } 19 | 20 | export async function generateMetadata({ params }: Props): Promise { 21 | const resolvedParams = await params; 22 | const tableName = decodeURIComponent(resolvedParams.tableName); 23 | return { 24 | title: `Table: ${tableName}`, 25 | }; 26 | } 27 | 28 | export default async function TablePage({ params, searchParams }: Props) { 29 | const [resolvedParams, resolvedSearchParams] = await Promise.all([ 30 | params, 31 | searchParams, 32 | ]); 33 | 34 | // Convert searchParams to URLSearchParams properly 35 | const urlSearchParams = new URLSearchParams(); 36 | Object.entries(resolvedSearchParams).forEach(([key, value]) => { 37 | if (value !== undefined) { 38 | if (Array.isArray(value)) { 39 | value.forEach((v) => urlSearchParams.append(key, v)); 40 | } else { 41 | urlSearchParams.append(key, value); 42 | } 43 | } 44 | }); 45 | 46 | const tableName = decodeURIComponent(resolvedParams.tableName); 47 | const [data, primaryKeys, columns, columnTypes, tableType, introspection] = 48 | await Promise.all([ 49 | getTableData(tableName, urlSearchParams), 50 | getTablePrimaryKeys(tableName), 51 | getTableColumns(tableName), 52 | getTableColumnTypes(tableName), 53 | getTableType(tableName), 54 | getTableIntrospection(tableName), 55 | ]); 56 | 57 | return ( 58 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-7", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean; 47 | }) { 48 | const Comp = asChild ? Slot : "button"; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | import { Filter, Sort } from "./types"; 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)); 8 | } 9 | 10 | export function parseFiltersFromSearchParams( 11 | searchParams: URLSearchParams | Record 12 | ): Filter[] { 13 | const filters: Filter[] = []; 14 | 15 | let urlSearchParams: URLSearchParams; 16 | if (searchParams instanceof URLSearchParams) { 17 | urlSearchParams = searchParams; 18 | } else { 19 | urlSearchParams = new URLSearchParams(); 20 | Object.entries(searchParams).forEach(([key, value]) => { 21 | if (value !== undefined) { 22 | if (Array.isArray(value)) { 23 | value.forEach((v) => urlSearchParams.append(key, v)); 24 | } else { 25 | urlSearchParams.append(key, value); 26 | } 27 | } 28 | }); 29 | } 30 | 31 | urlSearchParams.forEach((value, key) => { 32 | const filterMatch = key.match(/^filters\[(.+)\]$/); 33 | if (filterMatch) { 34 | const column = filterMatch[1]; 35 | // Parse operator from value (format: "operator:actual_value") 36 | const [operator, ...valueParts] = value.split(":"); 37 | const actualValue = valueParts.join(":"); 38 | if (actualValue.trim()) { 39 | filters.push({ column, operator, value: actualValue }); 40 | } 41 | } 42 | }); 43 | 44 | return filters; 45 | } 46 | 47 | export function parseSortsFromSearchParams( 48 | searchParams: URLSearchParams | Record 49 | ): Sort[] { 50 | const sorts: Sort[] = []; 51 | 52 | let urlSearchParams: URLSearchParams; 53 | if (searchParams instanceof URLSearchParams) { 54 | urlSearchParams = searchParams; 55 | } else { 56 | urlSearchParams = new URLSearchParams(); 57 | Object.entries(searchParams).forEach(([key, value]) => { 58 | if (value !== undefined) { 59 | if (Array.isArray(value)) { 60 | value.forEach((v) => urlSearchParams.append(key, v)); 61 | } else { 62 | urlSearchParams.append(key, value); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | urlSearchParams.forEach((value, key) => { 69 | const sortMatch = key.match(/^sort\[(.+)\]$/); 70 | if (sortMatch) { 71 | const column = sortMatch[1]; 72 | if (value === "asc" || value === "desc") { 73 | sorts.push({ column, direction: value }); 74 | } 75 | } 76 | }); 77 | 78 | return sorts; 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test:integration": "vitest tests/integration --run", 11 | "docker:up": "docker-compose up -d", 12 | "docker:down": "docker-compose down", 13 | "docker:logs": "docker-compose logs -f", 14 | "docker:psql": "docker-compose up postgres", 15 | "docker:mysql": "docker-compose up mysql", 16 | "docker:mssql": "docker-compose up mssql", 17 | "setup:psql": "cp env.postgresql .env", 18 | "setup:mysql": "cp env.mysql .env", 19 | "setup:mssql": "cp env.mssql .env" 20 | }, 21 | "dependencies": { 22 | "@ai-sdk/groq": "^1.2.9", 23 | "@ai-sdk/openai": "^1.3.22", 24 | "@faker-js/faker": "^9.8.0", 25 | "@monaco-editor/react": "^4.7.0", 26 | "@radix-ui/react-alert-dialog": "^1.1.14", 27 | "@radix-ui/react-dialog": "^1.1.14", 28 | "@radix-ui/react-dropdown-menu": "^2.1.15", 29 | "@radix-ui/react-popover": "^1.1.14", 30 | "@radix-ui/react-scroll-area": "^1.2.9", 31 | "@radix-ui/react-select": "^2.2.5", 32 | "@radix-ui/react-separator": "^1.1.7", 33 | "@radix-ui/react-slot": "^1.2.3", 34 | "@radix-ui/react-tooltip": "^1.2.7", 35 | "@tailwindcss/typography": "^0.5.16", 36 | "@testcontainers/mysql": "^11.0.3", 37 | "@types/mssql": "^9.1.7", 38 | "@types/pg": "^8.15.2", 39 | "@types/react-syntax-highlighter": "^15.5.13", 40 | "ai": "^4.3.16", 41 | "class-variance-authority": "^0.7.1", 42 | "clsx": "^2.1.1", 43 | "cmdk": "^1.1.1", 44 | "dotenv": "^16.5.0", 45 | "fuse.js": "^7.1.0", 46 | "global-agent": "^3.0.0", 47 | "groq-sdk": "^0.23.0", 48 | "http-proxy-agent": "^7.0.2", 49 | "lucide-react": "^0.511.0", 50 | "mssql": "^11.0.1", 51 | "mysql2": "^3.14.1", 52 | "next": "15.3.2", 53 | "next-themes": "^0.4.6", 54 | "pg": "^8.16.0", 55 | "react": "^19.0.0", 56 | "react-dom": "^19.0.0", 57 | "react-markdown": "^10.1.0", 58 | "react-syntax-highlighter": "^15.6.1", 59 | "tailwind-merge": "^3.3.0", 60 | "undici": "^7.10.0" 61 | }, 62 | "devDependencies": { 63 | "@eslint/eslintrc": "^3", 64 | "@playwright/test": "^1.53.0", 65 | "@tailwindcss/postcss": "^4", 66 | "@testcontainers/mssqlserver": "^11.0.3", 67 | "@testcontainers/postgresql": "^11.0.3", 68 | "@types/global-agent": "^3.0.0", 69 | "@types/node": "^20", 70 | "@types/react": "^19", 71 | "@types/react-dom": "^19", 72 | "@vitest/ui": "^3.2.3", 73 | "eslint": "^9", 74 | "eslint-config-next": "15.3.2", 75 | "tailwindcss": "^4", 76 | "tw-animate-css": "^1.3.0", 77 | "typescript": "^5", 78 | "vitest": "^3.2.3" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/SortBar.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 Sort { 8 | column: string; 9 | direction: "asc" | "desc"; 10 | } 11 | 12 | interface SortBarProps { 13 | columns: string[]; 14 | pendingSorts: Sort[]; 15 | setPendingSorts: (sorts: Sort[]) => void; 16 | } 17 | 18 | const directions = [ 19 | { value: "asc", label: "Ascending" }, 20 | { value: "desc", label: "Descending" }, 21 | ]; 22 | 23 | export default function SortBar({ 24 | columns, 25 | pendingSorts, 26 | setPendingSorts, 27 | }: SortBarProps) { 28 | const addSort = () => { 29 | const newSorts = [ 30 | ...pendingSorts, 31 | { column: columns[0], direction: "asc" as const }, 32 | ]; 33 | setPendingSorts(newSorts); 34 | }; 35 | 36 | const removeSort = (index: number) => { 37 | const newSorts = pendingSorts.filter((_, i) => i !== index); 38 | setPendingSorts(newSorts); 39 | }; 40 | 41 | const updateSort = (index: number, field: keyof Sort, value: string) => { 42 | const newSorts = [...pendingSorts]; 43 | newSorts[index] = { ...newSorts[index], [field]: value }; 44 | setPendingSorts(newSorts); 45 | }; 46 | 47 | // Convert columns to options for combobox 48 | const columnOptions = columns.map((column) => ({ 49 | value: column, 50 | label: column, 51 | })); 52 | 53 | return ( 54 |
55 |
56 |

Sorting

57 | 60 |
61 | 62 | {pendingSorts.map((sort, index) => ( 63 |
64 | updateSort(index, "column", value)} 68 | placeholder="Select column" 69 | searchPlaceholder="Search columns..." 70 | emptyMessage="No column found." 71 | /> 72 | 73 | updateSort(index, "direction", value)} 77 | placeholder="Select direction" 78 | searchPlaceholder="Search directions..." 79 | emptyMessage="No direction found." 80 | /> 81 | 82 | 90 |
91 | ))} 92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/ui/combobox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Check, ChevronsUpDown } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Command, 10 | CommandEmpty, 11 | CommandGroup, 12 | CommandInput, 13 | CommandItem, 14 | CommandList, 15 | } from "@/components/ui/command"; 16 | import { 17 | Popover, 18 | PopoverContent, 19 | PopoverTrigger, 20 | } from "@/components/ui/popover"; 21 | 22 | interface ComboboxOption { 23 | value: string; 24 | label: string; 25 | } 26 | 27 | interface ComboboxProps { 28 | options: ComboboxOption[]; 29 | value: string; 30 | onValueChange: (value: string) => void; 31 | placeholder?: string; 32 | searchPlaceholder?: string; 33 | emptyMessage?: string; 34 | className?: string; 35 | } 36 | 37 | export function Combobox({ 38 | options, 39 | value, 40 | onValueChange, 41 | placeholder = "Select option...", 42 | searchPlaceholder = "Search...", 43 | emptyMessage = "No option found.", 44 | className, 45 | }: ComboboxProps) { 46 | const [open, setOpen] = React.useState(false); 47 | 48 | return ( 49 | 50 | 51 | 62 | 63 | 64 | 65 | 66 | 67 | {emptyMessage} 68 | 69 | {options.map((option) => ( 70 | { 74 | onValueChange(currentValue === value ? "" : currentValue); 75 | setOpen(false); 76 | }} 77 | keywords={[option.label]} 78 | > 79 | {option.label} 80 | 86 | 87 | ))} 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/database/index.ts: -------------------------------------------------------------------------------- 1 | export type DatabaseType = "postgresql" | "mssql" | "mysql"; 2 | 3 | export interface DatabaseConfig { 4 | type: DatabaseType; 5 | host: string; 6 | port: number; 7 | database: string; 8 | username: string; 9 | password: string; 10 | } 11 | 12 | export interface TableInfo { 13 | table_name: string; 14 | schema_name: string; 15 | full_table_name: string; 16 | table_type: string; 17 | } 18 | 19 | export interface TableDataRow { 20 | [key: string]: string; 21 | } 22 | 23 | // Alias for backward compatibility 24 | export type TableRow = TableDataRow; 25 | 26 | export interface QueryResult { 27 | success: boolean; 28 | rows: TableDataRow[]; 29 | rowCount: number; 30 | fields: string[]; 31 | error?: string; 32 | } 33 | 34 | export interface TableDataResult { 35 | rows: TableDataRow[]; 36 | totalCount: number; 37 | page: number; 38 | pageSize: number; 39 | totalPages: number; 40 | error?: string; 41 | } 42 | 43 | export interface TableSchemaInfo { 44 | table_name: string; 45 | schema_name: string; 46 | table_type: string; 47 | columns: { 48 | column_name: string; 49 | data_type: string; 50 | is_nullable: string; 51 | column_default: string | null; 52 | character_maximum_length: number | null; 53 | numeric_precision: number | null; 54 | numeric_scale: number | null; 55 | }[]; 56 | } 57 | 58 | // Import existing types to match the application 59 | import { TableIntrospection, Filter, Sort } from "../types"; 60 | 61 | export interface DatabaseConnection { 62 | getTables(): Promise; 63 | getTableData( 64 | tableName: string, 65 | page: number, 66 | pageSize: number, 67 | filters?: Filter[], 68 | sorts?: Sort[] 69 | ): Promise; 70 | insertTableRow( 71 | tableName: string, 72 | data: Record 73 | ): Promise; 74 | updateTableRow( 75 | tableName: string, 76 | primaryKeyValues: Record, 77 | data: Record 78 | ): Promise; 79 | deleteTableRow( 80 | tableName: string, 81 | primaryKeyValues: Record 82 | ): Promise; 83 | getTableColumns(tableName: string): Promise; 84 | getTableColumnTypes( 85 | tableName: string 86 | ): Promise>; 87 | getTablePrimaryKeys(tableName: string): Promise; 88 | getTableType(tableName: string): Promise; 89 | executeQuery(query: string): Promise; 90 | getTableIntrospection(tableName: string): Promise; 91 | getFullDatabaseSchema(): Promise; 92 | disconnect(): Promise; 93 | } 94 | 95 | export interface DatabaseAdapter { 96 | connect(config: DatabaseConfig): Promise; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/JSONEditor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useRef } from "react"; 4 | import Editor from "@monaco-editor/react"; 5 | import type * as monaco from "monaco-editor"; 6 | import { cn } from "../lib/utils"; 7 | import { useTheme } from "next-themes"; 8 | 9 | interface JSONEditorProps { 10 | value: string; 11 | onChange: (value: string) => void; 12 | className?: string; 13 | placeholder?: string; 14 | } 15 | 16 | export default function JSONEditor({ 17 | value, 18 | onChange, 19 | className, 20 | placeholder = "Enter JSON...", 21 | }: JSONEditorProps) { 22 | const [isValid, setIsValid] = useState(true); 23 | const [error, setError] = useState(null); 24 | const editorRef = useRef(null); 25 | const { theme, resolvedTheme } = useTheme(); 26 | 27 | const handleEditorDidMount = ( 28 | editor: monaco.editor.IStandaloneCodeEditor 29 | ) => { 30 | editorRef.current = editor; 31 | }; 32 | 33 | const handleChange = (newValue: string | undefined) => { 34 | const jsonValue = newValue || ""; 35 | onChange(jsonValue); 36 | 37 | // Validate JSON 38 | if (jsonValue.trim()) { 39 | try { 40 | JSON.parse(jsonValue); 41 | setIsValid(true); 42 | setError(null); 43 | } catch (err) { 44 | setIsValid(false); 45 | setError(err instanceof Error ? err.message : "Invalid JSON"); 46 | } 47 | } else { 48 | setIsValid(true); 49 | setError(null); 50 | } 51 | }; 52 | 53 | // Determine Monaco theme based on current theme 54 | const getMonacoTheme = () => { 55 | if (theme === "system") { 56 | return resolvedTheme === "dark" ? "vs-dark" : "vs"; 57 | } 58 | return theme === "dark" ? "vs-dark" : "vs"; 59 | }; 60 | 61 | return ( 62 |
63 |
64 | 85 |
86 | {!isValid && error && ( 87 |
88 | JSON Error: {error} 89 |
90 | )} 91 | {!value && ( 92 |
{placeholder}
93 | )} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
10 | 15 | 16 | ); 17 | } 18 | 19 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 20 | return ( 21 | 26 | ); 27 | } 28 | 29 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 30 | return ( 31 | 36 | ); 37 | } 38 | 39 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 40 | return ( 41 | 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 | 41 | ); 42 | if (startPage > 2) { 43 | pages.push( 44 | 47 | ); 48 | } 49 | } 50 | 51 | for (let i = startPage; i <= endPage; i++) { 52 | pages.push( 53 | 61 | ); 62 | } 63 | 64 | if (endPage < totalPages) { 65 | if (endPage < totalPages - 1) { 66 | pages.push( 67 | 70 | ); 71 | } 72 | pages.push( 73 | 81 | ); 82 | } 83 | 84 | return pages; 85 | }; 86 | 87 | return ( 88 |
89 | 97 | {renderPageNumbers()} 98 | 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 | 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 |
85 | 86 | {error} 87 |
88 | )} 89 | 90 | 91 | Cancel 92 | 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 | 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 | 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 | 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 |
86 | {/* Readonly columns */} 87 | {readonlyColumns.map((column) => ( 88 |
89 | 100 | 106 |
107 | ))} 108 | 109 | {/* Editable columns */} 110 | {columns.map((column) => ( 111 |
112 | 120 | {isJsonField(column) ? ( 121 | handleInputChange(column, value)} 124 | placeholder={`Enter JSON for ${column}...`} 125 | /> 126 | ) : ( 127 | handleInputChange(column, e.target.value)} 131 | /> 132 | )} 133 |
134 | ))} 135 | 136 | {error && ( 137 |
138 | 139 | {error} 140 |
141 | )} 142 | 143 |
144 | 147 | {onCancel && ( 148 | 151 | )} 152 |
153 |
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 | 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 |
150 |
151 | 156 | 161 |
162 |
163 | 164 |
165 |
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 | 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 | ![Table View](screenshots/table.png) 20 | _Browse and manage your database tables with an intuitive interface featuring sorting, filtering, and pagination._ 21 | 22 | ![Schema Introspection](screenshots/introspection.png) 23 | _Automatically discover and visualize your database schema, including tables, columns, relationships, and constraints._ 24 | 25 | ![Custom SQL](screenshots/custom-sql.png) 26 | _Write and execute custom SQL queries with syntax highlighting and comprehensive result visualization._ 27 | 28 | ![Data Editing](screenshots/editing.png) 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 | ![AI Chat](screenshots/chat.png) 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 | --------------------------------------------------------------------------------