diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eba9d080..a97bde45 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -157,6 +157,13 @@ pub struct ConnectionParams { pub k8s_resource_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub k8s_port: Option, + /// SQL run on every new physical connection in the pool (e.g. `SET` / + /// `set_config` for session-scoped settings such as bypassing RLS). + /// Statements are separated by `;`. Runs per pooled connection so the + /// setting applies to every query regardless of which connection the + /// pool hands out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub startup_script: Option, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 70aa77b9..b63e7f11 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -840,6 +840,7 @@ mod tests { k8s_resource_type: None, k8s_resource_name: None, k8s_port: None, + startup_script: None, connection_id: Some("conn-1".to_string()), } } diff --git a/src-tauri/src/pool_manager.rs b/src-tauri/src/pool_manager.rs index 12351aaa..54d85b33 100644 --- a/src-tauri/src/pool_manager.rs +++ b/src-tauri/src/pool_manager.rs @@ -1,5 +1,5 @@ use crate::models::ConnectionParams; -use deadpool_postgres::{Manager as PgPoolManager, Pool as PgPool}; +use deadpool_postgres::{Hook as PgHook, HookError as PgHookError, Manager as PgPoolManager, Pool as PgPool}; use once_cell::sync::Lazy; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::client::{verify_server_cert_signed_by_trust_anchor, WebPkiServerVerifier}; @@ -11,7 +11,8 @@ use rustls::server::ParsedCertificate; use rustls::{DigitallySignedStruct}; use rustls::{ClientConfig, Error as TlsError, RootCertStore}; use rustls_platform_verifier::BuilderVerifierExt; -use sqlx::{sqlite::SqliteConnectOptions, MySql, Pool, Sqlite}; +use sha2::{Digest, Sha256}; +use sqlx::{sqlite::SqliteConnectOptions, ConnectOptions, Connection, Executor, MySql, Pool, Sqlite}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -51,6 +52,11 @@ static SQLITE_POOLS: Lazy> = Lazy::new(|| Arc::new(RwLock::new(H const DEFAULT_MYSQL_CONNECT_TIMEOUT_MS: u64 = 60_000; const DEFAULT_MYSQL_TIMEZONE: &str = "SYSTEM"; +/// SQLite is file-based so the preflight is effectively local, but a custom +/// VFS or a path on a stalled network mount could still hang it; bound it so a +/// broken script can never wedge pool creation indefinitely. +const SQLITE_STARTUP_SCRIPT_TIMEOUT_MS: u64 = 30_000; + fn mysql_setting_value(key: &str) -> Option { crate::config::get_cached_config() .plugins @@ -116,10 +122,23 @@ pub(crate) fn build_connection_key( ) }; - if let Some(tls_key) = tls_key { + let key = if let Some(tls_key) = tls_key { format!("{base_key}:{tls_key}") } else { base_key + }; + + // Fold the startup script into the key so editing it forces a fresh pool + // (whose new connections run the new script) instead of silently reusing + // the cached pool keyed only by connection_id. Hashed to keep the key + // bounded; only present when a script is set, so script-free connections + // keep their existing keys. + match startup_script(params) { + Some(script) => { + let digest = Sha256::digest(script.as_bytes()); + format!("{key}:startup:{digest:x}") + } + None => key, } } @@ -469,6 +488,54 @@ fn build_sqlite_connectoptions(params: &ConnectionParams) -> SqliteConnectOption SqliteConnectOptions::new().filename(params.database.to_string()) } +/// Return the connection's startup script if it is set and not blank. +/// Whitespace-only scripts are treated as absent so the per-connection +/// hook is skipped entirely rather than issuing an empty query. +fn startup_script(params: &ConnectionParams) -> Option { + params + .startup_script + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Format a startup-script execution failure so the surfaced error clearly +/// names the startup script as the cause, instead of reading like a bad host +/// or wrong credentials. +fn startup_script_error(err: impl std::fmt::Display) -> String { + format!("Startup script failed: {err}") +} + +/// Run the startup script once on a throwaway connection so a broken script +/// fails fast with a clearly attributed error. The per-connection hooks +/// (`after_connect`/`post_create`) still run it on every pooled connection; +/// this preflight exists only for early, well-labelled failures: sqlx swallows +/// `after_connect` errors and retries until the acquire timeout, which would +/// otherwise report a misleading "pool timed out". A failure to open the +/// connection is returned verbatim so genuine connectivity problems are not +/// mislabelled as startup-script errors. +async fn run_mysql_startup_script( + options: &sqlx::mysql::MySqlConnectOptions, + script: &str, +) -> Result<(), String> { + let mut conn = options.connect().await.map_err(|e| e.to_string())?; + let outcome = conn.execute(script).await; + let _ = conn.close().await; + outcome.map(|_| ()).map_err(startup_script_error) +} + +/// SQLite counterpart to [`run_mysql_startup_script`]. +async fn run_sqlite_startup_script( + options: &SqliteConnectOptions, + script: &str, +) -> Result<(), String> { + let mut conn = options.connect().await.map_err(|e| e.to_string())?; + let outcome = conn.execute(script).await; + let _ = conn.close().await; + outcome.map(|_| ()).map_err(startup_script_error) +} + pub async fn get_mysql_pool(params: &ConnectionParams) -> Result, String> { let connection_id = params.connection_id.as_deref(); get_mysql_pool_with_id(params, connection_id).await @@ -525,12 +592,25 @@ async fn get_mysql_pool_for_database_with_id( "connectTimeout", DEFAULT_MYSQL_CONNECT_TIMEOUT_MS, )); - let pool = tokio::time::timeout( - connect_timeout, - sqlx::mysql::MySqlPoolOptions::new() - .max_connections(10) - .connect_with(options), - ) + let mut pool_options = sqlx::mysql::MySqlPoolOptions::new().max_connections(10); + if let Some(script) = startup_script(params) { + tokio::time::timeout(connect_timeout, run_mysql_startup_script(&options, &script)) + .await + .map_err(|_| { + format!( + "Timed out running MySQL startup script after {} ms", + connect_timeout.as_millis() + ) + })??; + pool_options = pool_options.after_connect(move |conn, _meta| { + let script = script.clone(); + Box::pin(async move { + conn.execute(script.as_str()).await?; + Ok(()) + }) + }); + } + let pool = tokio::time::timeout(connect_timeout, pool_options.connect_with(options)) .await .map_err(|_| { format!( @@ -597,14 +677,24 @@ pub async fn get_postgres_pool_with_id( e })?; - let pool = PgPool::builder(PgPoolManager::new(cfg, tls_connector)) - .max_size(10) - .build() - .map_err(|e| { - let detail = format_error_chain(&e); - log::error!("Failed to create PostgreSQL connection pool: {}", detail); - detail - })?; + let mut builder = PgPool::builder(PgPoolManager::new(cfg, tls_connector)).max_size(10); + if let Some(script) = startup_script(params) { + builder = builder.post_create(PgHook::async_fn(move |client, _metrics| { + let script = script.clone(); + Box::pin(async move { + client + .batch_execute(&script) + .await + .map_err(|e| PgHookError::message(startup_script_error(format_error_chain(&e))))?; + Ok(()) + }) + })); + } + let pool = builder.build().map_err(|e| { + let detail = format_error_chain(&e); + log::error!("Failed to create PostgreSQL connection pool: {}", detail); + detail + })?; log::info!( "PostgreSQL connection pool created successfully for: {} (key: {})", @@ -652,14 +742,29 @@ pub async fn get_sqlite_pool_with_id( key ); let options = build_sqlite_connectoptions(params); - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .max_connections(5) // SQLite has lower concurrency needs - .connect_with(options) - .await - .map_err(|e| { - log::error!("Failed to create SQLite connection pool: {}", e); - e.to_string() - })?; + let mut pool_options = sqlx::sqlite::SqlitePoolOptions::new().max_connections(5); // SQLite has lower concurrency needs + if let Some(script) = startup_script(params) { + let timeout = Duration::from_millis(SQLITE_STARTUP_SCRIPT_TIMEOUT_MS); + tokio::time::timeout(timeout, run_sqlite_startup_script(&options, &script)) + .await + .map_err(|_| { + format!( + "Timed out running SQLite startup script after {} ms", + timeout.as_millis() + ) + })??; + pool_options = pool_options.after_connect(move |conn, _meta| { + let script = script.clone(); + Box::pin(async move { + conn.execute(script.as_str()).await?; + Ok(()) + }) + }); + } + let pool = pool_options.connect_with(options).await.map_err(|e| { + log::error!("Failed to create SQLite connection pool: {}", e); + e.to_string() + })?; log::info!( "SQLite connection pool created successfully for: {} (key: {})", diff --git a/src-tauri/src/pool_manager_tests.rs b/src-tauri/src/pool_manager_tests.rs index 6fb700f2..f14654f3 100644 --- a/src-tauri/src/pool_manager_tests.rs +++ b/src-tauri/src/pool_manager_tests.rs @@ -117,6 +117,38 @@ mod tests { ); } + #[test] + fn pool_key_changes_when_startup_script_changes() { + let none = connection_params("postgres", Some("require")); + let mut script_a = none.clone(); + script_a.startup_script = Some("SET app.bypass_rls = 'on';".to_string()); + let mut script_b = none.clone(); + script_b.startup_script = Some("SET app.bypass_rls = 'off';".to_string()); + + let key_none = build_connection_key(&none, Some("conn-1")); + let key_a = build_connection_key(&script_a, Some("conn-1")); + let key_b = build_connection_key(&script_b, Some("conn-1")); + + // A script changes the key, and different scripts differ — otherwise an + // edited startup script would silently reuse the old cached pool. + assert_ne!(key_none, key_a); + assert_ne!(key_a, key_b); + } + + #[test] + fn pool_key_ignores_blank_startup_script() { + let none = connection_params("postgres", Some("require")); + let mut blank = none.clone(); + blank.startup_script = Some(" \n\t".to_string()); + + // Whitespace-only scripts are treated as absent (no hook runs), so they + // must not fragment the pool away from the no-script connection. + assert_eq!( + build_connection_key(&none, Some("conn-1")), + build_connection_key(&blank, Some("conn-1")) + ); + } + #[test] fn mysql_options_accept_snake_case_verify_ssl_modes() { let verify_ca = mysql_params("verify_ca"); @@ -372,3 +404,94 @@ mod postgres_tls_connector_tests { let _ = std::fs::remove_file(&file_path); } } + +#[cfg(test)] +mod startup_script_tests { + use crate::models::{ConnectionParams, DatabaseSelection}; + use crate::pool_manager::{close_pool_with_id, get_sqlite_pool_with_id}; + use tempfile::NamedTempFile; + + fn sqlite_params(path: &str, startup_script: Option<&str>) -> ConnectionParams { + ConnectionParams { + driver: "sqlite".to_string(), + database: DatabaseSelection::Single(path.to_string()), + startup_script: startup_script.map(ToOwned::to_owned), + ..Default::default() + } + } + + #[tokio::test] + async fn startup_script_runs_on_each_new_connection() { + let file = NamedTempFile::new().expect("temp file"); + let path = file.path().to_str().expect("utf8 path").to_string(); + // Unique connection id keeps this pool out of other tests' cached pools. + let conn_id = format!("startup-runs-{}", ulid::Ulid::new()); + + let params = sqlite_params( + &path, + Some( + "CREATE TABLE IF NOT EXISTS startup_marker (id INTEGER); \ + INSERT INTO startup_marker (id) VALUES (1);", + ), + ); + + let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id)) + .await + .expect("pool should be created"); + + // The marker table only exists if the startup script ran on the + // physical connection the pool just handed out. + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM startup_marker") + .fetch_one(&pool) + .await + .expect("startup_marker table should exist"); + assert!(count >= 1, "expected at least one startup INSERT, got {count}"); + + close_pool_with_id(¶ms, Some(&conn_id)).await; + } + + #[tokio::test] + async fn blank_startup_script_is_skipped() { + let file = NamedTempFile::new().expect("temp file"); + let path = file.path().to_str().expect("utf8 path").to_string(); + let conn_id = format!("startup-blank-{}", ulid::Ulid::new()); + + // A whitespace-only script must be treated as absent: if it were run + // as SQL the connection would fail and `SELECT 1` below would error. + let params = sqlite_params(&path, Some(" \n ")); + + let pool = get_sqlite_pool_with_id(¶ms, Some(&conn_id)) + .await + .expect("pool should be created"); + + let (one,): (i64,) = sqlx::query_as("SELECT 1") + .fetch_one(&pool) + .await + .expect("query on pool with blank startup script should work"); + assert_eq!(one, 1); + + close_pool_with_id(¶ms, Some(&conn_id)).await; + } + + #[tokio::test] + async fn invalid_startup_script_surfaces_attributed_error() { + let file = NamedTempFile::new().expect("temp file"); + let path = file.path().to_str().expect("utf8 path").to_string(); + let conn_id = format!("startup-invalid-{}", ulid::Ulid::new()); + + let params = sqlite_params(&path, Some("THIS IS NOT VALID SQL;")); + + // A broken startup script must fail the connection with an error that + // clearly names the startup script as the cause, rather than sqlx's + // misleading "pool timed out" or a generic connection error. + let err = get_sqlite_pool_with_id(¶ms, Some(&conn_id)) + .await + .expect_err("invalid startup script should fail the connection"); + assert!( + err.contains("Startup script failed"), + "error should be attributed to the startup script, got: {err}" + ); + + close_pool_with_id(¶ms, Some(&conn_id)).await; + } +} diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 093a2d2e..f14547fc 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -15,6 +15,8 @@ import { Info, Eye, EyeOff, + ChevronLeft, + ChevronRight, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import type { ConnectionAppearance } from "../../contexts/DatabaseContext"; @@ -28,6 +30,7 @@ import { SlotAnchor } from "../ui/SlotAnchor"; import { useDrivers } from "../../hooks/useDrivers"; import { usePluginSlotRegistry } from "../../hooks/usePluginSlotRegistry"; import { Modal } from "../ui/Modal"; +import { SqlEditorWrapper } from "../ui/SqlEditorWrapper"; import type { PluginManifest } from "../../types/plugins"; import { loadSshConnections, type SshConnection } from "../../utils/ssh"; import { @@ -76,6 +79,8 @@ interface ConnectionParams { k8s_resource_type?: string; k8s_resource_name?: string; k8s_port?: number; + // SQL run on every new connection (e.g. SET / set_config) + startup_script?: string; } interface SavedConnection { @@ -186,9 +191,52 @@ export const NewConnectionModal = ({ // ── tab ── const [activeTab, setActiveTab] = useState< - "general" | "databases" | "ssh" | "ssl" | "k8s" | "appearance" + "general" | "databases" | "ssh" | "ssl" | "k8s" | "advanced" | "appearance" >("general"); + // ── Tab bar horizontal scroll affordance ── + const tabBarRef = useRef(null); + const [tabFade, setTabFade] = useState<{ left: boolean; right: boolean }>({ + left: false, + right: false, + }); + + const updateTabFade = useCallback(() => { + const el = tabBarRef.current; + if (!el) return; + const { scrollLeft, scrollWidth, clientWidth } = el; + setTabFade({ + left: scrollLeft > 1, + right: scrollLeft + clientWidth < scrollWidth - 1, + }); + }, []); + + // Recompute fades when the visible tab set changes and keep the active tab + // scrolled into view; also follow window resizes. + useEffect(() => { + updateTabFade(); + const el = tabBarRef.current; + const activeEl = el?.querySelector('[data-active="true"]'); + if (el && activeEl) { + const left = activeEl.offsetLeft; + const right = left + activeEl.offsetWidth; + if (left < el.scrollLeft) { + el.scrollTo({ left: left - 20, behavior: "smooth" }); + } else if (right > el.scrollLeft + el.clientWidth) { + el.scrollTo({ left: right - el.clientWidth + 20, behavior: "smooth" }); + } + } + window.addEventListener("resize", updateTabFade); + return () => window.removeEventListener("resize", updateTabFade); + }, [updateTabFade, driver, activeTab, selectedDatabasesState.length]); + + // Step the tab strip left/right (used by the edge arrows). + const scrollTabs = useCallback((dir: -1 | 1) => { + const el = tabBarRef.current; + if (!el) return; + el.scrollBy({ left: dir * el.clientWidth * 0.7, behavior: "smooth" }); + }, []); + // ── SSH ── const [sshConnections, setSshConnections] = useState([]); const [isSshModalOpen, setIsSshModalOpen] = useState(false); @@ -1128,6 +1176,35 @@ export const NewConnectionModal = ({ /> ); + // ── rendered Advanced tab content (per-connection startup SQL) ── + const advancedTabContent = ( +
+ +

+ {t("newConnection.startupScriptDescription", { + defaultValue: + "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.", + })} +

+
+ updateField("startup_script", value)} + onRun={() => {}} + height="100%" + options={{ + placeholder: t("newConnection.startupScriptPlaceholder", { + defaultValue: "SELECT set_config('app.bypass_rls', 'on', false);", + }), + }} + /> +
+
+ ); + // ── rendered Databases tab content (multi-db selection) ── const databasesTabContent = (
@@ -1980,7 +2057,22 @@ export const NewConnectionModal = ({ {/* Right: form area */}
{/* Tab bar */} -
+
+
{( [ { @@ -2002,6 +2094,12 @@ export const NewConnectionModal = ({ : []), ...(isNetworkDriver ? [{ id: "ssh", label: "SSH" }] : []), ...(isNetworkDriver ? [{ id: "k8s", label: "Kubernetes" }] : []), + { + id: "advanced", + label: t("newConnection.advanced", { + defaultValue: "Advanced", + }), + }, { id: "appearance", label: t("newConnection.appearance", { @@ -2009,15 +2107,16 @@ export const NewConnectionModal = ({ }), }, ] as { - id: "general" | "databases" | "ssh" | "ssl" | "k8s" | "appearance"; + id: "general" | "databases" | "ssh" | "ssl" | "k8s" | "advanced" | "appearance"; label: string; }[] ).map((tab) => ( ))}
+ {tabFade.left && ( + + )} + {tabFade.right && ( + + )} +
{/* Tab content */}
@@ -2051,7 +2171,9 @@ export const NewConnectionModal = ({ ? k8sTabContent : activeTab === "ssh" ? sshTabContent - : appearanceTabContent} + : activeTab === "advanced" + ? advancedTabContent + : appearanceTabContent}
diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index c4235b3c..66382344 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -49,6 +49,7 @@ export interface SavedConnection { ssh_connection_id?: string; k8s_enabled?: boolean; k8s_connection_id?: string; + startup_script?: string; }; group_id?: string; sort_order?: number; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 4f9e8bfd..2f3c2202 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -701,7 +701,11 @@ "selectK8sConnection": "K8s-Verbindung auswählen", "selectTypeFirst": "Zuerst Kontext/Namespace/Typ auswählen", "useK8s": "Kubernetes-Port-Forward verwenden", - "useK8sConnection": "Gespeicherte Verbindung" + "useK8sConnection": "Gespeicherte Verbindung", + "advanced": "Erweitert", + "startupScript": "Startskript", + "startupScriptDescription": "SQL, das bei jeder neuen Verbindung zu dieser Datenquelle ausgeführt wird. Verwenden Sie es für Sitzungseinstellungen wie SET / set_config (z. B. zum Umgehen von RLS). Trennen Sie Anweisungen mit Semikolons.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "SSH-Verbindungen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b5320e25..7bf3a421 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -722,7 +722,11 @@ "selectK8sConnection": "Select K8s Connection", "selectTypeFirst": "Select context/namespace/type first", "useK8s": "Use Kubernetes Port-Forward", - "useK8sConnection": "Saved Connection" + "useK8sConnection": "Saved Connection", + "advanced": "Advanced", + "startupScript": "Startup Script", + "startupScriptDescription": "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "SSH Connections", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a0e913ad..d3ff6621 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -706,7 +706,11 @@ "selectK8sConnection": "Selecciona conexión de K8s", "selectTypeFirst": "Selecciona primero contexto/namespace/tipo", "useK8s": "Usar Port-Forward de Kubernetes", - "useK8sConnection": "Conexión guardada" + "useK8sConnection": "Conexión guardada", + "advanced": "Avanzado", + "startupScript": "Script de inicio", + "startupScriptDescription": "SQL que se ejecuta en cada nueva conexión a esta fuente de datos. Úsalo para ajustes de sesión como SET / set_config (p. ej., para omitir RLS). Separa las sentencias con punto y coma.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "Conexiones SSH", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a3f727f6..b389bf69 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -701,7 +701,11 @@ "selectK8sConnection": "Sélectionner une connexion K8s", "selectTypeFirst": "Sélectionnez d'abord contexte/namespace/type", "useK8s": "Utiliser le Port-Forward Kubernetes", - "useK8sConnection": "Connexion enregistrée" + "useK8sConnection": "Connexion enregistrée", + "advanced": "Avancé", + "startupScript": "Script de démarrage", + "startupScriptDescription": "SQL exécuté à chaque nouvelle connexion à cette source de données. Utilisez-le pour des paramètres de session tels que SET / set_config (par exemple, pour contourner la RLS). Séparez les instructions par des points-virgules.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "Connexions SSH", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 62139e2a..a4863ae2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -706,7 +706,11 @@ "selectK8sConnection": "Seleziona connessione K8s", "selectTypeFirst": "Seleziona prima contesto/namespace/tipo", "useK8s": "Usa Port-Forward Kubernetes", - "useK8sConnection": "Connessione salvata" + "useK8sConnection": "Connessione salvata", + "advanced": "Avanzate", + "startupScript": "Script di avvio", + "startupScriptDescription": "SQL eseguito a ogni nuova connessione a questa origine dati. Usalo per le impostazioni di sessione come SET / set_config (ad es. per bypassare la RLS). Separa le istruzioni con punto e virgola.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "Connessioni SSH", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ff3ca732..7576109d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -715,7 +715,11 @@ "selectK8sConnection": "K8s 接続を選択", "selectTypeFirst": "先にコンテキスト/ネームスペース/タイプを選択してください", "useK8s": "Kubernetes ポートフォワードを使用", - "useK8sConnection": "保存された接続" + "useK8sConnection": "保存された接続", + "advanced": "詳細設定", + "startupScript": "起動スクリプト", + "startupScriptDescription": "このデータソースへの新規接続ごとに実行される SQL です。SET / set_config(例: RLS のバイパス)などのセッション設定に使用します。複数のステートメントはセミコロンで区切ってください。", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "SSH 接続", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c8ee3e7a..3cb1b3f7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -694,7 +694,11 @@ "selectTypeFirst": "Сначала выберите контекст/пространство имён/тип", "useK8s": "Использовать проброс портов Kubernetes", "useK8sConnection": "Сохранённое подключение", - "appearance": "Внешний вид" + "appearance": "Внешний вид", + "advanced": "Дополнительно", + "startupScript": "Сценарий запуска", + "startupScriptDescription": "SQL, выполняемый при каждом новом подключении к этому источнику данных. Используйте его для настроек сеанса, таких как SET / set_config (например, для обхода RLS). Разделяйте операторы точкой с запятой.", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "SSH-подключения", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 55a07edb..9bf1f82e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -669,7 +669,11 @@ "selectK8sConnection": "选择 K8s 连接", "selectTypeFirst": "请先选择上下文/命名空间/类型", "useK8s": "使用 Kubernetes 端口转发", - "useK8sConnection": "已保存的连接" + "useK8sConnection": "已保存的连接", + "advanced": "高级", + "startupScript": "启动脚本", + "startupScriptDescription": "每次新建到此数据源的连接时执行的 SQL。可用于会话设置,例如 SET / set_config(如绕过 RLS)。多条语句请用分号分隔。", + "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);" }, "sshConnections": { "title": "SSH 连接", diff --git a/src/utils/connections.ts b/src/utils/connections.ts index 72a267a6..92228091 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -36,6 +36,8 @@ export interface ConnectionParams { k8s_resource_type?: string; k8s_resource_name?: string; k8s_port?: number; + /** SQL run on every new connection to this data source (e.g. SET / set_config). */ + startup_script?: string; } /**