feat: add cross-database entity lookup for searching column values across all databases

Enables searching for a specific column value (e.g. carrier_id=123) across all databases on a PostgreSQL server. The backend creates temporary connection pools per database (semaphore-limited to 5), queries information_schema for matching columns, and executes read-only SELECTs with real-time progress events. Results are grouped by database/table in a new "Entity Lookup" tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 17:28:33 +03:00
parent a2371f00df
commit d5cff8bd5e
13 changed files with 1030 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns, Users, Activity } from "lucide-react";
import { X, Table2, Code, Columns, Users, Activity, Search } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
@@ -15,6 +15,7 @@ export function TabBar() {
structure: <Columns className="h-3 w-3" />,
roles: <Users className="h-3 w-3" />,
sessions: <Activity className="h-3 w-3" />,
lookup: <Search className="h-3 w-3" />,
};
return (
@@ -41,7 +42,14 @@ export function TabBar() {
) : null;
})()}
{iconMap[tab.type]}
<span className="max-w-[120px] truncate">{tab.title}</span>
<span className="max-w-[150px] truncate">
{tab.title}
{tab.database && (
<span className="ml-1 text-[10px] text-muted-foreground">
{tab.database}
</span>
)}
</span>
<button
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
onClick={(e) => {

View File

@@ -8,7 +8,7 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections, useReconnect } from "@/hooks/use-connections";
import { toast } from "sonner";
import { Database, Plus, RefreshCw } from "lucide-react";
import { Database, Plus, RefreshCw, Search } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
@@ -16,7 +16,7 @@ export function Toolbar() {
const [listOpen, setListOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, addTab } = useAppStore();
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
const { data: connections } = useConnections();
const reconnectMutation = useReconnect();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
@@ -38,11 +38,24 @@ export function Toolbar() {
type: "query",
title: "New Query",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
sql: "",
};
addTab(tab);
};
const handleNewLookup = () => {
if (!activeConnectionId) return;
const tab: Tab = {
id: crypto.randomUUID(),
type: "lookup",
title: "Entity Lookup",
connectionId: activeConnectionId,
database: currentDatabase ?? undefined,
};
addTab(tab);
};
return (
<>
<div
@@ -91,6 +104,17 @@ export function Toolbar() {
New Query
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5"
onClick={handleNewLookup}
disabled={!activeConnectionId}
>
<Search className="h-3.5 w-3.5" />
Entity Lookup
</Button>
<div className="flex-1" />
<span className="text-xs font-semibold text-muted-foreground tracking-wide">

View File

@@ -0,0 +1,296 @@
import { useState, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Search, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { useEntityLookup } from "@/hooks/use-entity-lookup";
import { useConnections } from "@/hooks/use-connections";
import { useDatabases } from "@/hooks/use-schema";
import { LookupResultGroup } from "./LookupResultGroup";
import type { ConnectionConfig } from "@/types";
interface Props {
connectionId: string;
}
export function EntityLookupPanel({ connectionId }: Props) {
const [columnName, setColumnName] = useState("");
const [value, setValue] = useState("");
const [selectedDbs, setSelectedDbs] = useState<string[]>([]);
const [dbPickerOpen, setDbPickerOpen] = useState(false);
const { data: connections } = useConnections();
const { data: allDatabases } = useDatabases(connectionId);
const activeConn = connections?.find((c) => c.id === connectionId);
const { search, result, error, isSearching, progress } = useEntityLookup();
const handleSearch = useCallback(() => {
if (!columnName.trim() || !value.trim() || !activeConn) return;
const config: ConnectionConfig = { ...activeConn };
search({
config,
columnName: columnName.trim(),
value: value.trim(),
lookupId: crypto.randomUUID(),
databases: selectedDbs.length > 0 ? selectedDbs : undefined,
});
}, [columnName, value, activeConn, selectedDbs, search]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSearch();
}
},
[handleSearch]
);
const toggleDb = useCallback((db: string) => {
setSelectedDbs((prev) =>
prev.includes(db) ? prev.filter((d) => d !== db) : [...prev, db]
);
}, []);
const progressPercent = useMemo(() => {
if (!progress || progress.total === 0) return 0;
return Math.round((progress.completed / progress.total) * 100);
}, [progress]);
const matchedDbs = useMemo(
() => result?.databases.filter((d) => d.tables.length > 0) ?? [],
[result]
);
const errorDbs = useMemo(
() =>
result?.databases.filter(
(d) => d.error && d.tables.length === 0
) ?? [],
[result]
);
const emptyDbs = useMemo(
() =>
result?.databases.filter(
(d) => !d.error && d.tables.length === 0
) ?? [],
[result]
);
return (
<div className="flex h-full flex-col">
{/* Search form */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">
Column:
</label>
<Input
placeholder="carrier_id"
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 w-44"
disabled={isSearching}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">
Value:
</label>
<Input
placeholder="123"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-8 w-44"
disabled={isSearching}
/>
</div>
{/* Database picker */}
<Popover open={dbPickerOpen} onOpenChange={setDbPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
disabled={isSearching}
>
<ChevronsUpDown className="h-3 w-3" />
{selectedDbs.length === 0
? `All (${allDatabases?.length ?? "..."})`
: `${selectedDbs.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
<Command>
<CommandInput placeholder="Filter databases..." />
<CommandList>
<CommandEmpty>No databases found.</CommandEmpty>
<CommandGroup>
{allDatabases?.map((db) => (
<CommandItem
key={db}
value={db}
onSelect={() => toggleDb(db)}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedDbs.includes(db)
? "opacity-100"
: "opacity-0"
}`}
/>
{db}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedDbs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => setSelectedDbs([])}
>
Clear
</Button>
)}
<Button
size="sm"
className="h-8 gap-1"
onClick={handleSearch}
disabled={isSearching || !columnName.trim() || !value.trim()}
>
{isSearching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5" />
)}
Search
</Button>
</div>
{/* Selected databases badges */}
{selectedDbs.length > 0 && (
<div className="flex flex-wrap gap-1 border-b px-4 py-1.5">
{selectedDbs.map((db) => (
<Badge
key={db}
variant="secondary"
className="cursor-pointer text-xs"
onClick={() => toggleDb(db)}
>
{db} &times;
</Badge>
))}
</div>
)}
{/* Progress */}
{isSearching && progress && (
<div className="border-b px-4 py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Searching... {progress.completed}/{progress.total} databases
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="border-b px-4 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Results */}
{result && (
<div className="flex-1 overflow-hidden">
{/* Summary */}
<div className="border-b px-4 py-2 text-xs text-muted-foreground">
Found{" "}
<span className="font-medium text-foreground">
{result.total_rows_found}
</span>{" "}
row{result.total_rows_found !== 1 && "s"} in{" "}
<span className="font-medium text-foreground">
{result.total_tables_matched}
</span>{" "}
table{result.total_tables_matched !== 1 && "s"} across{" "}
<span className="font-medium text-foreground">
{result.total_databases_searched}
</span>{" "}
database{result.total_databases_searched !== 1 && "s"} in{" "}
{(result.total_time_ms / 1000).toFixed(1)}s
</div>
<ScrollArea className="h-full">
<div className="flex flex-col gap-2 p-4">
{matchedDbs.map((dbResult) => (
<LookupResultGroup
key={dbResult.database}
dbResult={dbResult}
/>
))}
{errorDbs.map((dbResult) => (
<LookupResultGroup
key={dbResult.database}
dbResult={dbResult}
/>
))}
{emptyDbs.length > 0 && (
<div className="rounded-md border px-3 py-2 text-xs text-muted-foreground">
{emptyDbs.length} database{emptyDbs.length !== 1 && "s"} with
no matches:{" "}
{emptyDbs.map((d) => d.database).join(", ")}
</div>
)}
</div>
</ScrollArea>
</div>
)}
{/* Empty state */}
{!result && !isSearching && !error && (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-sm text-muted-foreground">
<Search className="mx-auto mb-2 h-8 w-8 opacity-30" />
<p>Search for a column value across all databases</p>
<p className="mt-1 text-xs">
Enter a column name and value, then press Search
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useState } from "react";
import {
ChevronDown,
ChevronRight,
AlertCircle,
Database,
} from "lucide-react";
import { ResultsTable } from "@/components/results/ResultsTable";
import type { LookupDatabaseResult } from "@/types";
interface Props {
dbResult: LookupDatabaseResult;
}
export function LookupResultGroup({ dbResult }: Props) {
const [expanded, setExpanded] = useState(dbResult.tables.length > 0);
const [expandedTables, setExpandedTables] = useState<Set<string>>(
() => new Set(dbResult.tables.map((t) => `${t.schema}.${t.table}`))
);
const totalRows = dbResult.tables.reduce((s, t) => s + t.row_count, 0);
const hasError = !!dbResult.error;
const hasMatches = dbResult.tables.length > 0;
const toggleTable = (key: string) => {
setExpandedTables((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="border rounded-md">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent/50"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{dbResult.database}</span>
{hasMatches && (
<span className="text-xs text-muted-foreground">
{dbResult.tables.length} table{dbResult.tables.length !== 1 && "s"},{" "}
{totalRows} row{totalRows !== 1 && "s"}
</span>
)}
{hasError && (
<span className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{dbResult.error}
</span>
)}
{!hasError && !hasMatches && (
<span className="text-xs text-muted-foreground">no matches</span>
)}
<span className="ml-auto text-xs text-muted-foreground">
{dbResult.search_time_ms}ms
</span>
</button>
{expanded && hasMatches && (
<div className="border-t">
{dbResult.tables.map((table) => {
const key = `${table.schema}.${table.table}`;
const isOpen = expandedTables.has(key);
return (
<div key={key} className="border-b last:border-b-0">
<button
className="flex w-full items-center gap-2 px-5 py-1.5 text-left text-xs hover:bg-accent/50"
onClick={() => toggleTable(key)}
>
{isOpen ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
<span className="font-medium">
{table.schema}.{table.table}
</span>
<span className="text-muted-foreground">
({table.row_count} row{table.row_count !== 1 && "s"}
{table.total_count > table.row_count &&
`, ${table.total_count} total`}
)
</span>
<span className="text-muted-foreground">
[{table.column_type}]
</span>
</button>
{isOpen && table.columns.length > 0 && (
<div className="h-[200px] border-t">
<ResultsTable
columns={table.columns}
types={table.types}
rows={table.rows as unknown[][]}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { TableDataView } from "@/components/table-viewer/TableDataView";
import { TableStructure } from "@/components/table-viewer/TableStructure";
import { RoleManagerView } from "@/components/management/RoleManagerView";
import { SessionsView } from "@/components/management/SessionsView";
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
export function TabContent() {
const { tabs, activeTabId, updateTab } = useAppStore();
@@ -59,6 +60,13 @@ export function TabContent() {
connectionId={activeTab.connectionId}
/>
);
case "lookup":
return (
<EntityLookupPanel
key={activeTab.id}
connectionId={activeTab.connectionId}
/>
);
default:
return null;
}

View File

@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { entityLookup, onLookupProgress } from "@/lib/tauri";
import type {
ConnectionConfig,
EntityLookupResult,
LookupProgress,
} from "@/types";
export function useEntityLookup() {
const [progress, setProgress] = useState<LookupProgress | null>(null);
const lookupIdRef = useRef<string>("");
const mutation = useMutation({
mutationFn: ({
config,
columnName,
value,
lookupId,
databases,
}: {
config: ConnectionConfig;
columnName: string;
value: string;
lookupId: string;
databases?: string[];
}) => {
lookupIdRef.current = lookupId;
setProgress(null);
return entityLookup(config, columnName, value, lookupId, databases);
},
});
useEffect(() => {
const unlistenPromise = onLookupProgress((p) => {
if (p.lookup_id === lookupIdRef.current) {
setProgress(p);
}
});
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
const reset = useCallback(() => {
mutation.reset();
setProgress(null);
lookupIdRef.current = "";
}, [mutation]);
return {
search: mutation.mutate,
result: mutation.data as EntityLookupResult | undefined,
error: mutation.error ? String(mutation.error) : null,
isSearching: mutation.isPending,
progress,
reset,
};
}

View File

@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
ConnectionConfig,
QueryResult,
@@ -19,6 +20,10 @@ import type {
TablePrivilege,
GrantRevokeParams,
RoleMembershipParams,
AiSettings,
OllamaModel,
EntityLookupResult,
LookupProgress,
} from "@/types";
// Connections
@@ -230,3 +235,37 @@ export const cancelQuery = (connectionId: string, pid: number) =>
export const terminateBackend = (connectionId: string, pid: number) =>
invoke<boolean>("terminate_backend", { connectionId, pid });
// AI
export const getAiSettings = () =>
invoke<AiSettings>("get_ai_settings");
export const saveAiSettings = (settings: AiSettings) =>
invoke<void>("save_ai_settings", { settings });
export const listOllamaModels = (ollamaUrl: string) =>
invoke<OllamaModel[]>("list_ollama_models", { ollamaUrl });
export const generateSql = (connectionId: string, prompt: string) =>
invoke<string>("generate_sql", { connectionId, prompt });
// Entity Lookup
export const entityLookup = (
config: ConnectionConfig,
columnName: string,
value: string,
lookupId: string,
databases?: string[]
) =>
invoke<EntityLookupResult>("entity_lookup", {
config,
columnName,
value,
lookupId,
databases,
});
export const onLookupProgress = (
callback: (p: LookupProgress) => void
): Promise<UnlistenFn> =>
listen<LookupProgress>("lookup-progress", (e) => callback(e.payload));

View File

@@ -216,15 +216,67 @@ export interface SavedQuery {
created_at: string;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions";
export interface AiSettings {
ollama_url: string;
model: string;
}
export interface OllamaModel {
name: string;
}
// Entity Lookup
export interface LookupTableMatch {
schema: string;
table: string;
column_type: string;
columns: string[];
types: string[];
rows: unknown[][];
row_count: number;
total_count: number;
}
export interface LookupDatabaseResult {
database: string;
tables: LookupTableMatch[];
error: string | null;
search_time_ms: number;
}
export interface EntityLookupResult {
column_name: string;
value: string;
databases: LookupDatabaseResult[];
total_databases_searched: number;
total_tables_matched: number;
total_rows_found: number;
total_time_ms: number;
}
export interface LookupProgress {
lookup_id: string;
database: string;
status: string;
tables_found: number;
rows_found: number;
error: string | null;
completed: number;
total: number;
}
export type TabType = "query" | "table" | "structure" | "roles" | "sessions" | "lookup";
export interface Tab {
id: string;
type: TabType;
title: string;
connectionId: string;
database?: string;
schema?: string;
table?: string;
sql?: string;
roleName?: string;
lookupColumn?: string;
lookupValue?: string;
}