diff --git a/src-tauri/src/commands/lookup.rs b/src-tauri/src/commands/lookup.rs new file mode 100644 index 0000000..e9a0d66 --- /dev/null +++ b/src-tauri/src/commands/lookup.rs @@ -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::>(), + 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 = 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> = 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>, + lookup_id: String, +) -> TuskResult { + 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 = 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 = 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(), + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8792a2c..28cecbe 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,7 +1,9 @@ +pub mod ai; pub mod connections; pub mod data; pub mod export; pub mod history; +pub mod lookup; pub mod management; pub mod queries; pub mod saved_queries; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d533f4..1458de1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -88,6 +88,13 @@ pub fn run() { commands::saved_queries::list_saved_queries, commands::saved_queries::save_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!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/lookup.rs b/src-tauri/src/models/lookup.rs new file mode 100644 index 0000000..73c02ef --- /dev/null +++ b/src-tauri/src/models/lookup.rs @@ -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, + pub types: Vec, + pub rows: Vec>, + pub row_count: usize, + pub total_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LookupDatabaseResult { + pub database: String, + pub tables: Vec, + pub error: Option, + pub search_time_ms: u128, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityLookupResult { + pub column_name: String, + pub value: String, + pub databases: Vec, + 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, + pub completed: usize, + pub total: usize, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 950eb4c..98a9071 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,7 @@ +pub mod ai; pub mod connection; pub mod history; +pub mod lookup; pub mod management; pub mod query_result; pub mod saved_queries; diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index e3880ee..db606e2 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -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: , roles: , sessions: , + lookup: , }; return ( @@ -41,7 +42,14 @@ export function TabBar() { ) : null; })()} {iconMap[tab.type]} - {tab.title} + + {tab.title} + {tab.database && ( + + {tab.database} + + )} + +
diff --git a/src/components/lookup/EntityLookupPanel.tsx b/src/components/lookup/EntityLookupPanel.tsx new file mode 100644 index 0000000..c83dfa2 --- /dev/null +++ b/src/components/lookup/EntityLookupPanel.tsx @@ -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([]); + 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 ( +
+ {/* Search form */} +
+
+ + setColumnName(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 w-44" + disabled={isSearching} + /> +
+
+ + setValue(e.target.value)} + onKeyDown={handleKeyDown} + className="h-8 w-44" + disabled={isSearching} + /> +
+ + {/* Database picker */} + + + + + + + + + No databases found. + + {allDatabases?.map((db) => ( + toggleDb(db)} + > + + {db} + + ))} + + + + + + + {selectedDbs.length > 0 && ( + + )} + + +
+ + {/* Selected databases badges */} + {selectedDbs.length > 0 && ( +
+ {selectedDbs.map((db) => ( + toggleDb(db)} + > + {db} × + + ))} +
+ )} + + {/* Progress */} + {isSearching && progress && ( +
+
+ + Searching... {progress.completed}/{progress.total} databases +
+
+
+
+
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Results */} + {result && ( +
+ {/* Summary */} +
+ Found{" "} + + {result.total_rows_found} + {" "} + row{result.total_rows_found !== 1 && "s"} in{" "} + + {result.total_tables_matched} + {" "} + table{result.total_tables_matched !== 1 && "s"} across{" "} + + {result.total_databases_searched} + {" "} + database{result.total_databases_searched !== 1 && "s"} in{" "} + {(result.total_time_ms / 1000).toFixed(1)}s +
+ + +
+ {matchedDbs.map((dbResult) => ( + + ))} + {errorDbs.map((dbResult) => ( + + ))} + {emptyDbs.length > 0 && ( +
+ {emptyDbs.length} database{emptyDbs.length !== 1 && "s"} with + no matches:{" "} + {emptyDbs.map((d) => d.database).join(", ")} +
+ )} +
+
+
+ )} + + {/* Empty state */} + {!result && !isSearching && !error && ( +
+
+ +

Search for a column value across all databases

+

+ Enter a column name and value, then press Search +

+
+
+ )} +
+ ); +} diff --git a/src/components/lookup/LookupResultGroup.tsx b/src/components/lookup/LookupResultGroup.tsx new file mode 100644 index 0000000..d95cfb5 --- /dev/null +++ b/src/components/lookup/LookupResultGroup.tsx @@ -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>( + () => 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 ( +
+ + + {expanded && hasMatches && ( +
+ {dbResult.tables.map((table) => { + const key = `${table.schema}.${table.table}`; + const isOpen = expandedTables.has(key); + + return ( +
+ + + {isOpen && table.columns.length > 0 && ( +
+ +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/workspace/TabContent.tsx b/src/components/workspace/TabContent.tsx index e2f46de..eb3a2a3 100644 --- a/src/components/workspace/TabContent.tsx +++ b/src/components/workspace/TabContent.tsx @@ -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 ( + + ); default: return null; } diff --git a/src/hooks/use-entity-lookup.ts b/src/hooks/use-entity-lookup.ts new file mode 100644 index 0000000..b118b9e --- /dev/null +++ b/src/hooks/use-entity-lookup.ts @@ -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(null); + const lookupIdRef = useRef(""); + + 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, + }; +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index edb2d56..10316d2 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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("terminate_backend", { connectionId, pid }); + +// AI +export const getAiSettings = () => + invoke("get_ai_settings"); + +export const saveAiSettings = (settings: AiSettings) => + invoke("save_ai_settings", { settings }); + +export const listOllamaModels = (ollamaUrl: string) => + invoke("list_ollama_models", { ollamaUrl }); + +export const generateSql = (connectionId: string, prompt: string) => + invoke("generate_sql", { connectionId, prompt }); + +// Entity Lookup +export const entityLookup = ( + config: ConnectionConfig, + columnName: string, + value: string, + lookupId: string, + databases?: string[] +) => + invoke("entity_lookup", { + config, + columnName, + value, + lookupId, + databases, + }); + +export const onLookupProgress = ( + callback: (p: LookupProgress) => void +): Promise => + listen("lookup-progress", (e) => callback(e.payload)); diff --git a/src/types/index.ts b/src/types/index.ts index 479c417..6ef3b7c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }