Compare commits
9 Commits
2d2dcdc4a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 652937f7f5 | |||
| 931e2b9408 | |||
| 02ea9db25d | |||
| 318210bdd8 | |||
| 11e35fcb5c | |||
| 50214fec0f | |||
| 28aa4ef8cc | |||
| ba9b58ff3a | |||
| 33b07a31da |
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-build:
|
lint-and-build:
|
||||||
@@ -13,16 +14,19 @@ jobs:
|
|||||||
image: ubuntu:22.04
|
image: ubuntu:22.04
|
||||||
env:
|
env:
|
||||||
DEBIAN_FRONTEND: noninteractive
|
DEBIAN_FRONTEND: noninteractive
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
build-essential curl wget pkg-config \
|
build-essential curl wget pkg-config git ca-certificates \
|
||||||
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
|
||||||
libssl-dev git ca-certificates
|
libssl-dev xdg-utils
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
||||||
|
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -58,4 +62,4 @@ jobs:
|
|||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
run: |
|
run: |
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
npm run tauri build
|
npm run tauri build -- --bundles deb,rpm
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -4704,7 +4704,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ authors = ["you"]
|
|||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.80.0"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "tusk_lib"
|
name = "tusk_lib"
|
||||||
@@ -21,7 +21,7 @@ tauri-plugin-shell = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net", "macros", "process", "io-util"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["serde"] }
|
uuid = { version = "1", features = ["serde"] }
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fn get_ai_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("ai_settings.json"))
|
Ok(dir.join("ai_settings.json"))
|
||||||
}
|
}
|
||||||
@@ -1037,7 +1037,7 @@ fn simplify_default(raw: &str) -> String {
|
|||||||
fn validate_select_statement(sql: &str) -> TuskResult<()> {
|
fn validate_select_statement(sql: &str) -> TuskResult<()> {
|
||||||
let sql_upper = sql.trim().to_uppercase();
|
let sql_upper = sql.trim().to_uppercase();
|
||||||
if !sql_upper.starts_with("SELECT") {
|
if !sql_upper.starts_with("SELECT") {
|
||||||
return Err(TuskError::Custom(
|
return Err(TuskError::Validation(
|
||||||
"Validation query must be a SELECT statement".to_string(),
|
"Validation query must be a SELECT statement".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1047,7 +1047,7 @@ fn validate_select_statement(sql: &str) -> TuskResult<()> {
|
|||||||
fn validate_index_ddl(ddl: &str) -> TuskResult<()> {
|
fn validate_index_ddl(ddl: &str) -> TuskResult<()> {
|
||||||
let ddl_upper = ddl.trim().to_uppercase();
|
let ddl_upper = ddl.trim().to_uppercase();
|
||||||
if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") {
|
if !ddl_upper.starts_with("CREATE INDEX") && !ddl_upper.starts_with("DROP INDEX") {
|
||||||
return Err(TuskError::Custom(
|
return Err(TuskError::Validation(
|
||||||
"Only CREATE INDEX and DROP INDEX statements are allowed".to_string(),
|
"Only CREATE INDEX and DROP INDEX statements are allowed".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub(crate) fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::Pat
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("connections.json"))
|
Ok(dir.join("connections.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub async fn get_table_data(
|
|||||||
let mut where_clause = String::new();
|
let mut where_clause = String::new();
|
||||||
if let Some(ref f) = filter {
|
if let Some(ref f) = filter {
|
||||||
if !f.trim().is_empty() {
|
if !f.trim().is_empty() {
|
||||||
|
validate_filter(f)?;
|
||||||
where_clause = format!(" WHERE {}", f);
|
where_clause = format!(" WHERE {}", f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,6 +286,75 @@ pub async fn delete_rows(
|
|||||||
Ok(total_affected)
|
Ok(total_affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rejects filter strings that contain SQL statements capable of mutating data.
|
||||||
|
/// This blocks writable CTEs and other injection attempts that could bypass
|
||||||
|
/// SET TRANSACTION READ ONLY (which PostgreSQL does not enforce inside CTEs
|
||||||
|
/// in all versions).
|
||||||
|
fn validate_filter(filter: &str) -> TuskResult<()> {
|
||||||
|
let upper = filter.to_ascii_uppercase();
|
||||||
|
// Remove string literals to avoid false positives on keywords inside quoted values
|
||||||
|
let sanitized = remove_string_literals(&upper);
|
||||||
|
|
||||||
|
const FORBIDDEN: &[&str] = &[
|
||||||
|
"INSERT ",
|
||||||
|
"UPDATE ",
|
||||||
|
"DELETE ",
|
||||||
|
"DROP ",
|
||||||
|
"ALTER ",
|
||||||
|
"TRUNCATE ",
|
||||||
|
"CREATE ",
|
||||||
|
"GRANT ",
|
||||||
|
"REVOKE ",
|
||||||
|
"COPY ",
|
||||||
|
"EXECUTE ",
|
||||||
|
"CALL ",
|
||||||
|
];
|
||||||
|
for kw in FORBIDDEN {
|
||||||
|
if sanitized.contains(kw) {
|
||||||
|
return Err(TuskError::Validation(format!(
|
||||||
|
"Filter contains forbidden SQL keyword: {}",
|
||||||
|
kw.trim()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sanitized.contains("INTO ") && sanitized.contains("SELECT ") {
|
||||||
|
return Err(TuskError::Validation(
|
||||||
|
"Filter contains forbidden SELECT INTO clause".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the contents of single-quoted string literals with spaces so that
|
||||||
|
/// keyword detection does not trigger on values like `status = 'DELETE_PENDING'`.
|
||||||
|
fn remove_string_literals(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut in_quote = false;
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\'' {
|
||||||
|
if in_quote {
|
||||||
|
// Check for escaped quote ('')
|
||||||
|
if chars.peek() == Some(&'\'') {
|
||||||
|
chars.next();
|
||||||
|
result.push(' ');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
in_quote = false;
|
||||||
|
result.push('\'');
|
||||||
|
} else {
|
||||||
|
in_quote = true;
|
||||||
|
result.push('\'');
|
||||||
|
}
|
||||||
|
} else if in_quote {
|
||||||
|
result.push(' ');
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn bind_json_value<'q>(
|
pub(crate) fn bind_json_value<'q>(
|
||||||
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||||
value: &'q Value,
|
value: &'q Value,
|
||||||
|
|||||||
@@ -739,7 +739,17 @@ async fn transfer_schema_only(
|
|||||||
docker_host: &Option<String>,
|
docker_host: &Option<String>,
|
||||||
) -> TuskResult<()> {
|
) -> TuskResult<()> {
|
||||||
let has_local = try_local_pg_dump().await;
|
let has_local = try_local_pg_dump().await;
|
||||||
transfer_schema_only_with(app, clone_id, source_url, container_name, database, pg_version, docker_host, has_local).await
|
transfer_schema_only_with(
|
||||||
|
app,
|
||||||
|
clone_id,
|
||||||
|
source_url,
|
||||||
|
container_name,
|
||||||
|
database,
|
||||||
|
pg_version,
|
||||||
|
docker_host,
|
||||||
|
has_local,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub async fn export_csv(
|
|||||||
let mut wtr = csv::Writer::from_writer(file);
|
let mut wtr = csv::Writer::from_writer(file);
|
||||||
|
|
||||||
wtr.write_record(&columns)
|
wtr.write_record(&columns)
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let record: Vec<String> = row
|
let record: Vec<String> = row
|
||||||
@@ -27,10 +27,10 @@ pub async fn export_csv(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
wtr.write_record(&record)
|
wtr.write_record(&record)
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
wtr.flush().map_err(|e| TuskError::Custom(e.to_string()))?;
|
wtr.flush().map_err(|e| TuskError::Export(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn get_history_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("query_history.json"))
|
Ok(dir.join("query_history.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,11 +110,8 @@ pub async fn drop_database(
|
|||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
// Terminate active connections to the target database
|
// Terminate active connections to the target database
|
||||||
let terminate_sql = format!(
|
sqlx::query("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1::name AND pid <> pg_backend_pid()")
|
||||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()",
|
.bind(&name)
|
||||||
name.replace('\'', "''")
|
|
||||||
);
|
|
||||||
sqlx::query(&terminate_sql)
|
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("saved_queries.json"))
|
Ok(dir.join("saved_queries.json"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ fn get_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
.map_err(|e| TuskError::Config(e.to_string()))?;
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
Ok(dir.join("app_settings.json"))
|
Ok(dir.join("app_settings.json"))
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ pub async fn save_app_settings(
|
|||||||
let connections_path = app
|
let connections_path = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?
|
.map_err(|e| TuskError::Config(e.to_string()))?
|
||||||
.join("connections.json");
|
.join("connections.json");
|
||||||
|
|
||||||
let mcp_state = state.inner().clone();
|
let mcp_state = state.inner().clone();
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ pub async fn create_snapshot(
|
|||||||
|
|
||||||
if params.include_dependencies {
|
if params.include_dependencies {
|
||||||
for fk in &fk_rows {
|
for fk in &fk_rows {
|
||||||
if target_tables.iter().any(|(s, t)| s == &fk.schema && t == &fk.table) {
|
if target_tables
|
||||||
|
.iter()
|
||||||
|
.any(|(s, t)| s == &fk.schema && t == &fk.table)
|
||||||
|
{
|
||||||
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
let parent = (fk.ref_schema.clone(), fk.ref_table.clone());
|
||||||
if !target_tables.contains(&parent) {
|
if !target_tables.contains(&parent) {
|
||||||
target_tables.push(parent);
|
target_tables.push(parent);
|
||||||
@@ -322,7 +325,7 @@ pub async fn list_snapshots(app: AppHandle) -> TuskResult<Vec<SnapshotMetadata>>
|
|||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| TuskError::Custom(e.to_string()))?
|
.map_err(|e| TuskError::Config(e.to_string()))?
|
||||||
.join("snapshots");
|
.join("snapshots");
|
||||||
|
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ pub enum TuskError {
|
|||||||
#[error("Docker error: {0}")]
|
#[error("Docker error: {0}")]
|
||||||
Docker(String),
|
Docker(String),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("Export error: {0}")]
|
||||||
|
Export(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes
|
||||||
|
const SCHEMA_CACHE_MAX_SIZE: usize = 100;
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -80,6 +81,16 @@ impl AppState {
|
|||||||
let mut cache = self.schema_cache.write().await;
|
let mut cache = self.schema_cache.write().await;
|
||||||
// Evict stale entries to prevent unbounded memory growth
|
// Evict stale entries to prevent unbounded memory growth
|
||||||
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
|
cache.retain(|_, entry| entry.cached_at.elapsed() < SCHEMA_CACHE_TTL);
|
||||||
|
// If still at capacity, remove the oldest entry
|
||||||
|
if cache.len() >= SCHEMA_CACHE_MAX_SIZE {
|
||||||
|
if let Some(oldest_key) = cache
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, e)| e.cached_at)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
{
|
||||||
|
cache.remove(&oldest_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
cache.insert(
|
cache.insert(
|
||||||
connection_id,
|
connection_id,
|
||||||
SchemaCacheEntry {
|
SchemaCacheEntry {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
pub fn escape_ident(name: &str) -> String {
|
pub fn escape_ident(name: &str) -> String {
|
||||||
format!("\"{}\"", name.replace('"', "\"\""))
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
@@ -44,27 +44,33 @@ pub fn topological_sort_tables(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kahn's algorithm
|
// Kahn's algorithm
|
||||||
let mut queue: Vec<(String, String)> = in_degree
|
let mut initial: Vec<(String, String)> = in_degree
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, °)| deg == 0)
|
.filter(|(_, °)| deg == 0)
|
||||||
.map(|(k, _)| k.clone())
|
.map(|(k, _)| k.clone())
|
||||||
.collect();
|
.collect();
|
||||||
queue.sort(); // deterministic order
|
initial.sort(); // deterministic order
|
||||||
|
let mut queue: VecDeque<(String, String)> = VecDeque::from(initial);
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
|
|
||||||
while let Some(node) = queue.pop() {
|
while let Some(node) = queue.pop_front() {
|
||||||
result.push(node.clone());
|
result.push(node.clone());
|
||||||
if let Some(neighbors) = graph.get(&node) {
|
if let Some(neighbors) = graph.get(&node) {
|
||||||
for neighbor in neighbors {
|
let mut new_ready: Vec<(String, String)> = neighbors
|
||||||
if let Some(deg) = in_degree.get_mut(neighbor) {
|
.iter()
|
||||||
|
.filter(|neighbor| {
|
||||||
|
if let Some(deg) = in_degree.get_mut(*neighbor) {
|
||||||
*deg -= 1;
|
*deg -= 1;
|
||||||
if *deg == 0 {
|
*deg == 0
|
||||||
queue.push(neighbor.clone());
|
} else {
|
||||||
queue.sort();
|
false
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
new_ready.sort();
|
||||||
|
queue.extend(new_ready);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
549
src/components/table-viewer/FilterBuilder.tsx
Normal file
549
src/components/table-viewer/FilterBuilder.tsx
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Plus, X, Check, ChevronsUpDown, Code, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ColumnInfo } from "@/types";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
type Conjunction = "AND" | "OR";
|
||||||
|
|
||||||
|
interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
column: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
valueTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterBuilderProps {
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
onFilterChange: (whereClause: string | undefined) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Operator mapping by PG type ---
|
||||||
|
|
||||||
|
type TypeCategory =
|
||||||
|
| "numeric"
|
||||||
|
| "text"
|
||||||
|
| "boolean"
|
||||||
|
| "datetime"
|
||||||
|
| "uuid"
|
||||||
|
| "json"
|
||||||
|
| "default";
|
||||||
|
|
||||||
|
const TYPE_CATEGORY_MAP: Record<string, TypeCategory> = {
|
||||||
|
int2: "numeric",
|
||||||
|
int4: "numeric",
|
||||||
|
int8: "numeric",
|
||||||
|
float4: "numeric",
|
||||||
|
float8: "numeric",
|
||||||
|
numeric: "numeric",
|
||||||
|
decimal: "numeric",
|
||||||
|
money: "numeric",
|
||||||
|
serial: "numeric",
|
||||||
|
bigserial: "numeric",
|
||||||
|
smallserial: "numeric",
|
||||||
|
varchar: "text",
|
||||||
|
text: "text",
|
||||||
|
char: "text",
|
||||||
|
bpchar: "text",
|
||||||
|
name: "text",
|
||||||
|
citext: "text",
|
||||||
|
bool: "boolean",
|
||||||
|
boolean: "boolean",
|
||||||
|
timestamp: "datetime",
|
||||||
|
timestamptz: "datetime",
|
||||||
|
date: "datetime",
|
||||||
|
time: "datetime",
|
||||||
|
timetz: "datetime",
|
||||||
|
interval: "datetime",
|
||||||
|
uuid: "uuid",
|
||||||
|
json: "json",
|
||||||
|
jsonb: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPERATORS_BY_CATEGORY: Record<TypeCategory, string[]> = {
|
||||||
|
numeric: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "IN", "BETWEEN"],
|
||||||
|
text: ["=", "!=", "LIKE", "ILIKE", "IS NULL", "IS NOT NULL", "IN", "~"],
|
||||||
|
boolean: ["=", "IS NULL", "IS NOT NULL"],
|
||||||
|
datetime: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "BETWEEN"],
|
||||||
|
uuid: ["=", "!=", "IS NULL", "IS NOT NULL", "IN"],
|
||||||
|
json: ["IS NULL", "IS NOT NULL"],
|
||||||
|
default: ["=", "!=", "IS NULL", "IS NOT NULL"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function getTypeCategory(dataType: string): TypeCategory {
|
||||||
|
return TYPE_CATEGORY_MAP[dataType] ?? "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperatorsForColumn(dataType: string): string[] {
|
||||||
|
return OPERATORS_BY_CATEGORY[getTypeCategory(dataType)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteIdentifier(name: string): string {
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLiteral(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeConditions(
|
||||||
|
conditions: FilterCondition[],
|
||||||
|
conjunction: Conjunction,
|
||||||
|
columns: ColumnInfo[],
|
||||||
|
): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const c of conditions) {
|
||||||
|
if (!c.column) continue;
|
||||||
|
|
||||||
|
const col = quoteIdentifier(c.column);
|
||||||
|
const colInfo = columns.find((ci) => ci.name === c.column);
|
||||||
|
const isBool = colInfo && getTypeCategory(colInfo.data_type) === "boolean";
|
||||||
|
|
||||||
|
if (c.operator === "IS NULL") {
|
||||||
|
parts.push(`${col} IS NULL`);
|
||||||
|
} else if (c.operator === "IS NOT NULL") {
|
||||||
|
parts.push(`${col} IS NOT NULL`);
|
||||||
|
} else if (c.operator === "IN") {
|
||||||
|
const items = c.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((v) => escapeLiteral(v));
|
||||||
|
if (items.length > 0) {
|
||||||
|
parts.push(`${col} IN (${items.join(", ")})`);
|
||||||
|
}
|
||||||
|
} else if (c.operator === "BETWEEN") {
|
||||||
|
if (c.value && c.valueTo) {
|
||||||
|
parts.push(
|
||||||
|
`${col} BETWEEN ${escapeLiteral(c.value)} AND ${escapeLiteral(c.valueTo)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (isBool) {
|
||||||
|
if (c.value === "true" || c.value === "false") {
|
||||||
|
parts.push(`${col} ${c.operator} ${c.value}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c.value !== "") {
|
||||||
|
parts.push(`${col} ${c.operator} ${escapeLiteral(c.value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return undefined;
|
||||||
|
return parts.join(` ${conjunction} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCondition(): FilterCondition {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
column: "",
|
||||||
|
operator: "=",
|
||||||
|
value: "",
|
||||||
|
valueTo: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-components ---
|
||||||
|
|
||||||
|
function ColumnCombobox({
|
||||||
|
columns,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
value: string;
|
||||||
|
onSelect: (column: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = columns.find((c) => c.name === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-6 w-[130px] justify-between px-2 text-xs font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selected ? selected.name : "Column..."}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[220px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search column..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No column found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={col.name}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-3 w-3",
|
||||||
|
value === col.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{col.name}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
{col.data_type}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterConditionRow({
|
||||||
|
condition,
|
||||||
|
columns,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
condition: FilterCondition;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
onChange: (updated: FilterCondition) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const colInfo = columns.find((c) => c.name === condition.column);
|
||||||
|
const dataType = colInfo?.data_type ?? "";
|
||||||
|
const operators = getOperatorsForColumn(dataType);
|
||||||
|
const category = getTypeCategory(dataType);
|
||||||
|
const needsNoValue =
|
||||||
|
condition.operator === "IS NULL" || condition.operator === "IS NOT NULL";
|
||||||
|
const isBetween = condition.operator === "BETWEEN";
|
||||||
|
const isBoolEq = category === "boolean" && condition.operator === "=";
|
||||||
|
|
||||||
|
const handleColumnChange = (columnName: string) => {
|
||||||
|
const newColInfo = columns.find((c) => c.name === columnName);
|
||||||
|
const newType = newColInfo?.data_type ?? "";
|
||||||
|
const newOps = getOperatorsForColumn(newType);
|
||||||
|
const newOperator = newOps.includes(condition.operator)
|
||||||
|
? condition.operator
|
||||||
|
: newOps[0];
|
||||||
|
onChange({
|
||||||
|
...condition,
|
||||||
|
column: columnName,
|
||||||
|
operator: newOperator,
|
||||||
|
value: "",
|
||||||
|
valueTo: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ColumnCombobox
|
||||||
|
columns={columns}
|
||||||
|
value={condition.column}
|
||||||
|
onSelect={handleColumnChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={condition.operator}
|
||||||
|
onValueChange={(op) =>
|
||||||
|
onChange({ ...condition, operator: op, value: "", valueTo: "" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[100px] px-2 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<SelectItem key={op} value={op} className="text-xs">
|
||||||
|
{op}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{!needsNoValue && (
|
||||||
|
isBoolEq ? (
|
||||||
|
<Select
|
||||||
|
value={condition.value}
|
||||||
|
onValueChange={(v) => onChange({ ...condition, value: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[80px] px-2 text-xs">
|
||||||
|
<SelectValue placeholder="value" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true" className="text-xs">
|
||||||
|
true
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="false" className="text-xs">
|
||||||
|
false
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : isBetween ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
className="h-6 w-[80px] text-xs"
|
||||||
|
placeholder="from"
|
||||||
|
value={condition.value}
|
||||||
|
onChange={(e) => onChange({ ...condition, value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
className="h-6 w-[80px] text-xs"
|
||||||
|
placeholder="to"
|
||||||
|
value={condition.valueTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...condition, valueTo: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-6 w-[120px] text-xs"
|
||||||
|
placeholder={
|
||||||
|
condition.operator === "IN" ? "val1, val2, ..." : "value"
|
||||||
|
}
|
||||||
|
value={condition.value}
|
||||||
|
onChange={(e) => onChange({ ...condition, value: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main component ---
|
||||||
|
|
||||||
|
type FilterMode = "visual" | "sql";
|
||||||
|
|
||||||
|
export function FilterBuilder({ columns, onFilterChange, children }: FilterBuilderProps) {
|
||||||
|
const [mode, setMode] = useState<FilterMode>("visual");
|
||||||
|
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
|
const [conjunction, setConjunction] = useState<Conjunction>("AND");
|
||||||
|
const [rawFilter, setRawFilter] = useState("");
|
||||||
|
|
||||||
|
const handleAdd = useCallback(() => {
|
||||||
|
setConditions((prev) => [...prev, createCondition()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemove = useCallback((id: string) => {
|
||||||
|
setConditions((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = useCallback((updated: FilterCondition) => {
|
||||||
|
setConditions((prev) =>
|
||||||
|
prev.map((c) => (c.id === updated.id ? updated : c)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyVisual = useCallback(() => {
|
||||||
|
const clause = serializeConditions(conditions, conjunction, columns);
|
||||||
|
onFilterChange(clause);
|
||||||
|
}, [conditions, conjunction, columns, onFilterChange]);
|
||||||
|
|
||||||
|
const handleApplyRaw = useCallback(() => {
|
||||||
|
let clause = rawFilter.trim();
|
||||||
|
if (clause.toLowerCase().startsWith("where ")) {
|
||||||
|
clause = clause.slice(6).trim();
|
||||||
|
}
|
||||||
|
onFilterChange(clause || undefined);
|
||||||
|
}, [rawFilter, onFilterChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setConditions([]);
|
||||||
|
setRawFilter("");
|
||||||
|
onFilterChange(undefined);
|
||||||
|
}, [onFilterChange]);
|
||||||
|
|
||||||
|
const hasActiveFilter =
|
||||||
|
mode === "visual" ? conditions.length > 0 : rawFilter.trim() !== "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toolbar row */}
|
||||||
|
<div className="flex items-center gap-2 border-b px-2 py-1">
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className="flex items-center rounded-md border text-xs">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
|
||||||
|
mode === "visual"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setMode("visual")}
|
||||||
|
title="Visual filter builder"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3 w-3" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
|
||||||
|
mode === "sql"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setMode("sql")}
|
||||||
|
title="Raw SQL WHERE clause"
|
||||||
|
>
|
||||||
|
<Code className="h-3 w-3" />
|
||||||
|
SQL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual mode controls */}
|
||||||
|
{mode === "visual" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{conditions.length === 0 ? "Add filter" : "Add"}
|
||||||
|
</Button>
|
||||||
|
{conditions.length > 0 && (
|
||||||
|
<>
|
||||||
|
{conditions.length >= 2 && (
|
||||||
|
<div className="flex items-center rounded-md border text-[10px]">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 font-medium",
|
||||||
|
conjunction === "AND"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setConjunction("AND")}
|
||||||
|
>
|
||||||
|
AND
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 font-medium",
|
||||||
|
conjunction === "OR"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => setConjunction("OR")}
|
||||||
|
>
|
||||||
|
OR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={handleApplyVisual}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SQL mode controls */}
|
||||||
|
{mode === "sql" && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. id > 10 AND status = 'active'"
|
||||||
|
className="h-6 max-w-md flex-1 text-xs"
|
||||||
|
value={rawFilter}
|
||||||
|
onChange={(e) => setRawFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleApplyRaw()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={handleApplyRaw}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs text-muted-foreground"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual mode: condition rows below the toolbar */}
|
||||||
|
{mode === "visual" && conditions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 border-b bg-muted/30 px-2 py-1.5">
|
||||||
|
{conditions.map((c) => (
|
||||||
|
<FilterConditionRow
|
||||||
|
key={c.id}
|
||||||
|
condition={c}
|
||||||
|
columns={columns}
|
||||||
|
onChange={handleChange}
|
||||||
|
onRemove={() => handleRemove(c.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import { ResultsTable } from "@/components/results/ResultsTable";
|
|||||||
import { ResultsJsonView } from "@/components/results/ResultsJsonView";
|
import { ResultsJsonView } from "@/components/results/ResultsJsonView";
|
||||||
import { PaginationControls } from "./PaginationControls";
|
import { PaginationControls } from "./PaginationControls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { updateRow as updateRowApi } from "@/lib/tauri";
|
import { updateRow as updateRowApi } from "@/lib/tauri";
|
||||||
import { getTableColumns } from "@/lib/tauri";
|
import { getTableColumns } from "@/lib/tauri";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
|
import { Save, RotateCcw, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
|
||||||
|
import { FilterBuilder } from "./FilterBuilder";
|
||||||
import { InsertRowDialog } from "./InsertRowDialog";
|
import { InsertRowDialog } from "./InsertRowDialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -35,7 +35,6 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||||
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
||||||
const [filter, setFilter] = useState("");
|
|
||||||
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
||||||
const [pendingChanges, setPendingChanges] = useState<
|
const [pendingChanges, setPendingChanges] = useState<
|
||||||
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
||||||
@@ -184,14 +183,24 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApplyFilter = () => {
|
|
||||||
setAppliedFilter(filter || undefined);
|
|
||||||
setPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 border-b px-2 py-1">
|
<FilterBuilder
|
||||||
|
columns={columnsInfo ?? data?.columns.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
data_type: data.types?.[i] ?? "text",
|
||||||
|
is_nullable: true,
|
||||||
|
column_default: null,
|
||||||
|
ordinal_position: i,
|
||||||
|
character_maximum_length: null,
|
||||||
|
is_primary_key: false,
|
||||||
|
comment: null,
|
||||||
|
})) ?? []}
|
||||||
|
onFilterChange={(clause) => {
|
||||||
|
setAppliedFilter(clause);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
||||||
<Lock className="h-3 w-3" />
|
<Lock className="h-3 w-3" />
|
||||||
@@ -206,22 +215,6 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
No PK — using ctid
|
No PK — using ctid
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="WHERE clause (e.g. id > 10)"
|
|
||||||
className="h-6 flex-1 text-xs"
|
|
||||||
value={filter}
|
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
onClick={handleApplyFilter}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
{data && data.columns.length > 0 && (
|
{data && data.columns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -310,7 +303,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FilterBuilder>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export function useReconnect() {
|
|||||||
setPgVersion(version);
|
setPgVersion(version);
|
||||||
setDbFlavor(id, flavor);
|
setDbFlavor(id, flavor);
|
||||||
setCurrentDatabase(database);
|
setCurrentDatabase(database);
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries({ queryKey: ["databases"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["schemas"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["completion-schema"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user