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:
367
src-tauri/src/commands/lookup.rs
Normal file
367
src-tauri/src/commands/lookup.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
use crate::commands::queries::pg_value_to_json;
|
||||||
|
use crate::error::TuskResult;
|
||||||
|
use crate::models::connection::ConnectionConfig;
|
||||||
|
use crate::models::lookup::{
|
||||||
|
EntityLookupResult, LookupDatabaseResult, LookupProgress, LookupTableMatch,
|
||||||
|
};
|
||||||
|
use crate::utils::escape_ident;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
struct TableCandidate {
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
data_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_database(
|
||||||
|
config: &ConnectionConfig,
|
||||||
|
database: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> LookupDatabaseResult {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let mut db_config = config.clone();
|
||||||
|
db_config.database = database.to_string();
|
||||||
|
let url = db_config.connection_url();
|
||||||
|
|
||||||
|
let pool = match PgPoolOptions::new()
|
||||||
|
.max_connections(2)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
return LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some(format!("Connection failed: {}", e)),
|
||||||
|
search_time_ms: start.elapsed().as_millis(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
search_database_inner(&pool, database, column_name, value),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(db_result) => {
|
||||||
|
let mut db_result = db_result;
|
||||||
|
db_result.search_time_ms = start.elapsed().as_millis();
|
||||||
|
db_result
|
||||||
|
}
|
||||||
|
Err(_) => LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some("Timeout (30s)".to_string()),
|
||||||
|
search_time_ms: start.elapsed().as_millis(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_database_inner(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
database: &str,
|
||||||
|
column_name: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> LookupDatabaseResult {
|
||||||
|
// Find tables that have this column
|
||||||
|
let candidates = match sqlx::query_as::<_, (String, String, String)>(
|
||||||
|
"SELECT table_schema, table_name, data_type \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE column_name = $1 \
|
||||||
|
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast')",
|
||||||
|
)
|
||||||
|
.bind(column_name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(schema, table, data_type)| TableCandidate {
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
data_type,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
Err(e) => {
|
||||||
|
return LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables: vec![],
|
||||||
|
error: Some(format!("Schema query failed: {}", e)),
|
||||||
|
search_time_ms: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tables = Vec::new();
|
||||||
|
|
||||||
|
for candidate in &candidates {
|
||||||
|
let qualified = format!(
|
||||||
|
"{}.{}",
|
||||||
|
escape_ident(&candidate.schema),
|
||||||
|
escape_ident(&candidate.table)
|
||||||
|
);
|
||||||
|
let col_ident = escape_ident(column_name);
|
||||||
|
|
||||||
|
// Read-only transaction: SELECT rows + COUNT
|
||||||
|
let select_sql = format!(
|
||||||
|
"SELECT * FROM {} WHERE {}::text = $1 LIMIT 50",
|
||||||
|
qualified, col_ident
|
||||||
|
);
|
||||||
|
let count_sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM {} WHERE {}::text = $1",
|
||||||
|
qualified, col_ident
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tx = match pool.begin().await {
|
||||||
|
Ok(tx) => tx,
|
||||||
|
Err(e) => {
|
||||||
|
tables.push(LookupTableMatch {
|
||||||
|
schema: candidate.schema.clone(),
|
||||||
|
table: candidate.table.clone(),
|
||||||
|
column_type: candidate.data_type.clone(),
|
||||||
|
columns: vec![],
|
||||||
|
types: vec![],
|
||||||
|
rows: vec![],
|
||||||
|
row_count: 0,
|
||||||
|
total_count: 0,
|
||||||
|
});
|
||||||
|
log::warn!(
|
||||||
|
"Failed to begin tx for {}.{}: {}",
|
||||||
|
candidate.schema,
|
||||||
|
candidate.table,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
log::warn!("Failed SET TRANSACTION READ ONLY: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows_result = sqlx::query(&select_sql)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let count_result: Result<i64, _> = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(value)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = tx.rollback().await;
|
||||||
|
|
||||||
|
match rows_result {
|
||||||
|
Ok(rows) if !rows.is_empty() => {
|
||||||
|
let mut col_names = Vec::new();
|
||||||
|
let mut col_types = Vec::new();
|
||||||
|
if let Some(first) = rows.first() {
|
||||||
|
for col in first.columns() {
|
||||||
|
col_names.push(col.name().to_string());
|
||||||
|
col_types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_rows: Vec<Vec<serde_json::Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(0..col_names.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = result_rows.len();
|
||||||
|
let total_count = count_result.unwrap_or(row_count as i64);
|
||||||
|
|
||||||
|
tables.push(LookupTableMatch {
|
||||||
|
schema: candidate.schema.clone(),
|
||||||
|
table: candidate.table.clone(),
|
||||||
|
column_type: candidate.data_type.clone(),
|
||||||
|
columns: col_names,
|
||||||
|
types: col_types,
|
||||||
|
rows: result_rows,
|
||||||
|
row_count,
|
||||||
|
total_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// No rows matched — skip
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Query failed for {}.{}: {}",
|
||||||
|
candidate.schema,
|
||||||
|
candidate.table,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LookupDatabaseResult {
|
||||||
|
database: database.to_string(),
|
||||||
|
tables,
|
||||||
|
error: None,
|
||||||
|
search_time_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn entity_lookup(
|
||||||
|
app: AppHandle,
|
||||||
|
config: ConnectionConfig,
|
||||||
|
column_name: String,
|
||||||
|
value: String,
|
||||||
|
databases: Option<Vec<String>>,
|
||||||
|
lookup_id: String,
|
||||||
|
) -> TuskResult<EntityLookupResult> {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// 1. Get list of databases
|
||||||
|
let url = config.connection_url();
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::TuskError::Database)?;
|
||||||
|
|
||||||
|
let db_names: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(crate::error::TuskError::Database)?;
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
|
||||||
|
// Filter if specific databases requested
|
||||||
|
let db_names: Vec<String> = if let Some(ref filter) = databases {
|
||||||
|
db_names
|
||||||
|
.into_iter()
|
||||||
|
.filter(|d| filter.contains(d))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
db_names
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = db_names.len();
|
||||||
|
let completed = Arc::new(AtomicUsize::new(0));
|
||||||
|
let semaphore = Arc::new(Semaphore::new(5));
|
||||||
|
|
||||||
|
// 2. Parallel search across databases
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for db_name in db_names {
|
||||||
|
let config = config.clone();
|
||||||
|
let column_name = column_name.clone();
|
||||||
|
let value = value.clone();
|
||||||
|
let lookup_id = lookup_id.clone();
|
||||||
|
let app = app.clone();
|
||||||
|
let semaphore = semaphore.clone();
|
||||||
|
let completed = completed.clone();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _permit = semaphore.acquire().await.unwrap();
|
||||||
|
|
||||||
|
// Emit "searching" progress
|
||||||
|
let _ = app.emit(
|
||||||
|
"lookup-progress",
|
||||||
|
LookupProgress {
|
||||||
|
lookup_id: lookup_id.clone(),
|
||||||
|
database: db_name.clone(),
|
||||||
|
status: "searching".to_string(),
|
||||||
|
tables_found: 0,
|
||||||
|
rows_found: 0,
|
||||||
|
error: None,
|
||||||
|
completed: completed.load(Ordering::Relaxed),
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = search_database(&config, &db_name, &column_name, &value).await;
|
||||||
|
|
||||||
|
let done = completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
|
||||||
|
let status = if result.error.is_some() {
|
||||||
|
"error"
|
||||||
|
} else {
|
||||||
|
"done"
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"lookup-progress",
|
||||||
|
LookupProgress {
|
||||||
|
lookup_id: lookup_id.clone(),
|
||||||
|
database: db_name.clone(),
|
||||||
|
status: status.to_string(),
|
||||||
|
tables_found: result.tables.len(),
|
||||||
|
rows_found: result.tables.iter().map(|t| t.row_count).sum(),
|
||||||
|
error: result.error.clone(),
|
||||||
|
completed: done,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Collect results
|
||||||
|
let mut all_results = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
match handle.await {
|
||||||
|
Ok(result) => all_results.push(result),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Join error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: databases with matches first, then by name
|
||||||
|
all_results.sort_by(|a, b| {
|
||||||
|
let a_has = !a.tables.is_empty();
|
||||||
|
let b_has = !b.tables.is_empty();
|
||||||
|
b_has.cmp(&a_has).then(a.database.cmp(&b.database))
|
||||||
|
});
|
||||||
|
|
||||||
|
let total_databases_searched = all_results.len();
|
||||||
|
let total_tables_matched: usize = all_results.iter().map(|d| d.tables.len()).sum();
|
||||||
|
let total_rows_found: usize = all_results
|
||||||
|
.iter()
|
||||||
|
.flat_map(|d| d.tables.iter())
|
||||||
|
.map(|t| t.row_count)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
Ok(EntityLookupResult {
|
||||||
|
column_name,
|
||||||
|
value,
|
||||||
|
databases: all_results,
|
||||||
|
total_databases_searched,
|
||||||
|
total_tables_matched,
|
||||||
|
total_rows_found,
|
||||||
|
total_time_ms: start.elapsed().as_millis(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
pub mod ai;
|
||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod lookup;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ pub fn run() {
|
|||||||
commands::saved_queries::list_saved_queries,
|
commands::saved_queries::list_saved_queries,
|
||||||
commands::saved_queries::save_query,
|
commands::saved_queries::save_query,
|
||||||
commands::saved_queries::delete_saved_query,
|
commands::saved_queries::delete_saved_query,
|
||||||
|
// ai
|
||||||
|
commands::ai::get_ai_settings,
|
||||||
|
commands::ai::save_ai_settings,
|
||||||
|
commands::ai::list_ollama_models,
|
||||||
|
commands::ai::generate_sql,
|
||||||
|
// lookup
|
||||||
|
commands::lookup::entity_lookup,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
44
src-tauri/src/models/lookup.rs
Normal file
44
src-tauri/src/models/lookup.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupTableMatch {
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub column_type: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<serde_json::Value>>,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub total_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupDatabaseResult {
|
||||||
|
pub database: String,
|
||||||
|
pub tables: Vec<LookupTableMatch>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub search_time_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EntityLookupResult {
|
||||||
|
pub column_name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub databases: Vec<LookupDatabaseResult>,
|
||||||
|
pub total_databases_searched: usize,
|
||||||
|
pub total_tables_matched: usize,
|
||||||
|
pub total_rows_found: usize,
|
||||||
|
pub total_time_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LookupProgress {
|
||||||
|
pub lookup_id: String,
|
||||||
|
pub database: String,
|
||||||
|
pub status: String,
|
||||||
|
pub tables_found: usize,
|
||||||
|
pub rows_found: usize,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub completed: usize,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod ai;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod lookup;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod query_result;
|
pub mod query_result;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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() {
|
export function TabBar() {
|
||||||
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
||||||
@@ -15,6 +15,7 @@ export function TabBar() {
|
|||||||
structure: <Columns className="h-3 w-3" />,
|
structure: <Columns className="h-3 w-3" />,
|
||||||
roles: <Users className="h-3 w-3" />,
|
roles: <Users className="h-3 w-3" />,
|
||||||
sessions: <Activity className="h-3 w-3" />,
|
sessions: <Activity className="h-3 w-3" />,
|
||||||
|
lookup: <Search className="h-3 w-3" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +42,14 @@ export function TabBar() {
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{iconMap[tab.type]}
|
{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
|
<button
|
||||||
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
|
className="ml-1 rounded-sm opacity-0 hover:bg-accent group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections, useReconnect } from "@/hooks/use-connections";
|
import { useConnections, useReconnect } from "@/hooks/use-connections";
|
||||||
import { toast } from "sonner";
|
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 type { ConnectionConfig, Tab } from "@/types";
|
||||||
import { getEnvironment } from "@/lib/environment";
|
import { getEnvironment } from "@/lib/environment";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export function Toolbar() {
|
|||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
|
||||||
const { activeConnectionId, addTab } = useAppStore();
|
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
||||||
const { data: connections } = useConnections();
|
const { data: connections } = useConnections();
|
||||||
const reconnectMutation = useReconnect();
|
const reconnectMutation = useReconnect();
|
||||||
const activeConn = connections?.find((c) => c.id === activeConnectionId);
|
const activeConn = connections?.find((c) => c.id === activeConnectionId);
|
||||||
@@ -38,11 +38,24 @@ export function Toolbar() {
|
|||||||
type: "query",
|
type: "query",
|
||||||
title: "New Query",
|
title: "New Query",
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
sql: "",
|
sql: "",
|
||||||
};
|
};
|
||||||
addTab(tab);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -91,6 +104,17 @@ export function Toolbar() {
|
|||||||
New Query
|
New Query
|
||||||
</Button>
|
</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" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<span className="text-xs font-semibold text-muted-foreground tracking-wide">
|
<span className="text-xs font-semibold text-muted-foreground tracking-wide">
|
||||||
|
|||||||
296
src/components/lookup/EntityLookupPanel.tsx
Normal file
296
src/components/lookup/EntityLookupPanel.tsx
Normal 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} ×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/lookup/LookupResultGroup.tsx
Normal file
116
src/components/lookup/LookupResultGroup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { TableDataView } from "@/components/table-viewer/TableDataView";
|
|||||||
import { TableStructure } from "@/components/table-viewer/TableStructure";
|
import { TableStructure } from "@/components/table-viewer/TableStructure";
|
||||||
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
||||||
import { SessionsView } from "@/components/management/SessionsView";
|
import { SessionsView } from "@/components/management/SessionsView";
|
||||||
|
import { EntityLookupPanel } from "@/components/lookup/EntityLookupPanel";
|
||||||
|
|
||||||
export function TabContent() {
|
export function TabContent() {
|
||||||
const { tabs, activeTabId, updateTab } = useAppStore();
|
const { tabs, activeTabId, updateTab } = useAppStore();
|
||||||
@@ -59,6 +60,13 @@ export function TabContent() {
|
|||||||
connectionId={activeTab.connectionId}
|
connectionId={activeTab.connectionId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "lookup":
|
||||||
|
return (
|
||||||
|
<EntityLookupPanel
|
||||||
|
key={activeTab.id}
|
||||||
|
connectionId={activeTab.connectionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/hooks/use-entity-lookup.ts
Normal file
60
src/hooks/use-entity-lookup.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type {
|
import type {
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
QueryResult,
|
QueryResult,
|
||||||
@@ -19,6 +20,10 @@ import type {
|
|||||||
TablePrivilege,
|
TablePrivilege,
|
||||||
GrantRevokeParams,
|
GrantRevokeParams,
|
||||||
RoleMembershipParams,
|
RoleMembershipParams,
|
||||||
|
AiSettings,
|
||||||
|
OllamaModel,
|
||||||
|
EntityLookupResult,
|
||||||
|
LookupProgress,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
// Connections
|
// Connections
|
||||||
@@ -230,3 +235,37 @@ export const cancelQuery = (connectionId: string, pid: number) =>
|
|||||||
|
|
||||||
export const terminateBackend = (connectionId: string, pid: number) =>
|
export const terminateBackend = (connectionId: string, pid: number) =>
|
||||||
invoke<boolean>("terminate_backend", { connectionId, pid });
|
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));
|
||||||
|
|||||||
@@ -216,15 +216,67 @@ export interface SavedQuery {
|
|||||||
created_at: string;
|
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 {
|
export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
type: TabType;
|
type: TabType;
|
||||||
title: string;
|
title: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
database?: string;
|
||||||
schema?: string;
|
schema?: string;
|
||||||
table?: string;
|
table?: string;
|
||||||
sql?: string;
|
sql?: string;
|
||||||
roleName?: string;
|
roleName?: string;
|
||||||
|
lookupColumn?: string;
|
||||||
|
lookupValue?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user