Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ jobs:
--ignore=tests/test_ssh.py \
--ignore=tests/test_clickhouse.py

test-databricks:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v5
- name: Install Databricks including OAuth SDK
run: uv sync --group test --no-dev --extra databricks
- name: Verify SDK and adapter regressions
run: |
uv run --no-sync python -c "from databricks.sdk.core import Config, oauth_service_principal"
uv run --no-sync pytest tests/unit/test_databricks_adapter.py tests/unit/test_databricks_regressions.py tests/unit/test_provider_credential_aliases.py tests/ui/test_provider_token_credentials.py -v --timeout=60

test-sqlite:
runs-on: ubuntu-latest
strategy:
Expand Down
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,21 @@ sqlit should provide fun and a feeling of mastery and satisfaction for those who
**Example:**
<e> = explorer pane, <q> = query pane, <r> = results pane.
Rationale: E;Q;R satisfies both intuitiveness (each binding is the first letter of the pane), harmony (proximity: qwerty speaks for itself)

### Cloud credential regression test

`tests/integration/test_cloud_provider_credentials.py` is an opt-in test against an owned cloud
database. It requires a working OS keyring and creates a temporary schema and connection, then
removes them. It verifies CLI creation via stdin, separate-process queries, credential redaction,
metadata, row limits and rename. Load the test token from your secret manager into the process
environment; do not commit it or pass it as a command-line argument.

Set `SQLIT_LIVE_PROVIDER=databricks`, `SQLIT_LIVE_HOST`, `SQLIT_LIVE_HTTP_PATH`,
`SQLIT_LIVE_TOKEN`, and optionally `SQLIT_LIVE_CATALOG` (default `workspace`), then run:

```bash
uv run --no-sync pytest tests/integration/test_cloud_provider_credentials.py -v --timeout=240
```

The ordinary CI lane runs without cloud credentials. A configured live run fails on missing
configuration or an unavailable keyring; it does not silently skip those checks.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
---

### Connect
Supports all major databases: SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, FirebirdSQL, Oracle, DuckDB, CockroachDB, ClickHouse, Snowflake, Supabase, CloudFlare D1, Turso, Athena, BigQuery, Spanner, RedShift, IBM Db2, SAP HANA, Teradata, Trino, Presto, Apache Flight SQL, Apache Impala, SurrealDB and osquery.
Supports all major databases: SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, FirebirdSQL, Oracle, DuckDB, CockroachDB, ClickHouse, Snowflake, Databricks, Supabase, CloudFlare D1, Turso, Athena, BigQuery, Spanner, RedShift, IBM Db2, SAP HANA, Teradata, Trino, Presto, Apache Flight SQL, Apache Impala, SurrealDB and osquery.

![Database Providers](docs/demos/demo-providers.gif)

Expand Down Expand Up @@ -245,6 +245,15 @@ Edit the `keymap.json` file in your sqlit config dir. See [`config/keymap.templa

## FAQ

### Databricks authentication

The Databricks extra includes the SQL connector and the SDK required for service-principal OAuth.
PATs and client secrets are kept in the OS credential store, including connections created with
`connections add --url-stdin`. A PAT URL has this shape:
`databricks://token:TOKEN@HOST/CATALOG?http_path=/sql/1.0/warehouses/WAREHOUSE_ID`.
Use stdin to keep the URL out of shell history and process arguments. Browser OAuth does not ask
for a database password. Unity Catalog and the legacy Hive metastore use their respective metadata APIs.

### How are sensitive credentials stored?

Connection details are stored in `connections.json` inside the config directory, but passwords are stored in your OS keyring when available (macOS Keychain, Windows Credential Locker, Linux Secret Service).
Expand Down Expand Up @@ -287,6 +296,7 @@ Most of the time you can just run `sqlit` and connect. If a Python driver is mis
| Turso | `libsql` | `pipx inject sqlit-tui libsql` | `python -m pip install libsql` |
| Cloudflare D1 | `requests` | `pipx inject sqlit-tui requests` | `python -m pip install requests` |
| Snowflake | `snowflake-connector-python` | `pipx inject sqlit-tui snowflake-connector-python` | `python -m pip install snowflake-connector-python` |
| Databricks | `databricks-sql-connector` | `pipx inject sqlit-tui databricks-sql-connector databricks-sdk` | `python -m pip install databricks-sql-connector databricks-sdk` |
| Firebird | `firebirdsql` | `pipx inject sqlit-tui firebirdsql` | `python -m pip install firebirdsql` |
| Athena | `pyathena` | `pipx inject sqlit-tui pyathena` | `python -m pip install pyathena` |
| BigQuery | `google-cloud-bigquery` | `pipx inject sqlit-tui google-cloud-bigquery` | `python -m pip install google-cloud-bigquery` |
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ all = [
"impyla>=0.18.0",
"osquery>=3.0.0",
"surrealdb>=1.0.0",
"databricks-sql-connector>=3.0.0",
"databricks-sdk>=0.18.0",
]
postgres = ["psycopg2-binary>=2.9.0"]
cockroachdb = ["psycopg2-binary>=2.9.0"]
Expand Down Expand Up @@ -90,6 +92,7 @@ flight = ["adbc-driver-flightsql>=1.0.0"]
impala = ["impyla>=0.18.0"]
osquery = ["osquery>=3.0.0"]
surrealdb = ["surrealdb>=1.0.0"]
databricks = ["databricks-sql-connector>=3.0.0", "databricks-sdk>=0.18.0"]
ssh = [
"sshtunnel>=0.4.0",
"paramiko>=2.0.0,<4.0.0",
Expand Down Expand Up @@ -256,6 +259,10 @@ module = [
"impala.dbapi",
"osquery",
"surrealdb",
"databricks",
"databricks.sql",
"databricks.sdk",
"databricks.sdk.core",
"google.cloud",
"google.cloud.bigquery",
"google.cloud.bigquery.dbapi",
Expand Down
3 changes: 3 additions & 0 deletions sqlit/domains/connections/app/persist_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def build_persist_connections(
"""
persist_connections = copy.deepcopy(connections)
for conn in persist_connections:
from sqlit.domains.connections.domain.credential_aliases import normalize_credential_options

normalize_credential_options(conn)
endpoint = conn.tcp_endpoint
if endpoint and endpoint.password is None and not endpoint.password_command:
stored = credentials_service.get_password(conn.name)
Expand Down
15 changes: 1 addition & 14 deletions sqlit/domains/connections/cli/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,8 @@ def _needs_ssh_prompt(config: ConnectionConfig) -> bool:

def _needs_db_prompt(config: ConnectionConfig) -> bool:
"""Check if DB password is still missing (ignoring password_command)."""
from sqlit.domains.connections.providers.metadata import is_file_based, requires_auth

if is_file_based(config.db_type):
return False
if not requires_auth(config.db_type):
return False
auth_type = config.get_option("auth_type")
if auth_type in ("ad_default", "ad_integrated", "windows"):
return False
if config.db_type == "trino":
auth_method = str(config.options.get("trino_auth_method", config.extra_options.get("trino_auth_method", "basic"))).lower()
if auth_method in {"none", "kerberos", "gssapi"}:
return False
endpoint = config.tcp_endpoint
return bool(endpoint and endpoint.password is None)
return bool(uses_db_password(config) and endpoint and endpoint.password is None)


def prompt_for_password(config: ConnectionConfig) -> ConnectionConfig:
Expand Down
31 changes: 28 additions & 3 deletions sqlit/domains/connections/domain/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class DatabaseType(str, Enum):
CLICKHOUSE = "clickhouse"
COCKROACHDB = "cockroachdb"
D1 = "d1"
DATABRICKS = "databricks"
DUCKDB = "duckdb"
DB2 = "db2"
FIREBIRD = "firebird"
Expand Down Expand Up @@ -53,6 +54,7 @@ class DatabaseType(str, Enum):
DatabaseType.HANA,
DatabaseType.TERADATA,
DatabaseType.SNOWFLAKE,
DatabaseType.DATABRICKS,
DatabaseType.BIGQUERY,
DatabaseType.SPANNER,
DatabaseType.TRINO,
Expand Down Expand Up @@ -144,6 +146,11 @@ class ConnectionConfig:
extra_options: dict[str, str] = field(default_factory=dict)
options: dict[str, Any] = field(default_factory=dict)

def __post_init__(self) -> None:
from sqlit.domains.connections.domain.credential_aliases import normalize_credential_options

normalize_credential_options(self)

@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
payload = dict(data)
Expand Down Expand Up @@ -262,10 +269,21 @@ def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig:
)

def get_option(self, name: str, default: Any | None = None) -> Any:
from sqlit.domains.connections.domain.credential_aliases import credential_option

if name == credential_option(self) and self.tcp_endpoint is not None:
fallback = self.tcp_endpoint.password
return self.options.get(name, fallback if fallback is not None else default)
return self.options.get(name, default)

def set_option(self, name: str, value: Any) -> None:
from sqlit.domains.connections.domain.credential_aliases import credential_option, normalize_credential_options

previous_credential = credential_option(self)
self.options[name] = value
if previous_credential != credential_option(self) and self.tcp_endpoint is not None:
self.tcp_endpoint.password = None
normalize_credential_options(self)

def get_field_value(self, name: str, default: Any = "") -> Any:
values = self.to_form_values()
Expand Down Expand Up @@ -317,17 +335,24 @@ def to_form_values(self) -> dict[str, Any]:
values["ssh_enabled"] = "disabled"

values.update(self.options)
from sqlit.domains.connections.domain.credential_aliases import credential_option

alias = credential_option(self)
if alias:
values[alias] = self.get_option(alias)
return values

def to_dict(self, *, include_passwords: bool = True) -> dict[str, Any]:
from sqlit.domains.connections.domain.credential_aliases import public_connection_url, without_secret_options

data: dict[str, Any] = {
"name": self.name,
"db_type": self.db_type,
"source": self.source,
"connection_url": self.connection_url,
"connection_url": self.connection_url if include_passwords else public_connection_url(self),
"folder_path": self.folder_path,
"extra_options": dict(self.extra_options),
"options": dict(self.options),
"extra_options": dict(self.extra_options) if include_passwords else without_secret_options(self, self.extra_options),
"options": dict(self.options) if include_passwords else without_secret_options(self, self.options),
}

if isinstance(self.endpoint, FileEndpoint):
Expand Down
73 changes: 73 additions & 0 deletions sqlit/domains/connections/domain/credential_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Map mutually exclusive provider secrets onto the existing keyring credential.

A Databricks/Exasol connection uses one authentication secret at a time. Keeping
it in endpoint.password reuses the credential store's save/load/rename semantics;
provider-specific field names remain available to forms and CLI configuration.
"""
from __future__ import annotations

from typing import TYPE_CHECKING
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

if TYPE_CHECKING:
from typing import Any

from sqlit.domains.connections.domain.config import ConnectionConfig

_CREDENTIAL_MODES = {
'databricks': ('auth_type', 'pat', {'pat': 'access_token', 'oauth-m2m': 'client_secret'}),
'exasol': ('authenticator', 'password', {'access_token': 'access_token', 'refresh_token': 'refresh_token'}),
}


def credential_option(config: ConnectionConfig) -> str | None:
definition = _CREDENTIAL_MODES.get(config.db_type)
if definition is None:
return None
selector, default, aliases = definition
mode = config.options.get(selector, config.extra_options.get(selector, default))
return aliases.get(mode)


def secret_option_names(config: ConnectionConfig) -> frozenset[str]:
definition = _CREDENTIAL_MODES.get(config.db_type)
return frozenset(definition[2].values()) if definition else frozenset()


def normalize_credential_options(config: ConnectionConfig) -> None:
definition = _CREDENTIAL_MODES.get(config.db_type)
if definition is None:
return
selector, _, _ = definition
config.options = dict(config.options)
config.extra_options = dict(config.extra_options)
if selector in config.extra_options:
config.options.setdefault(selector, config.extra_options.pop(selector))
selected = credential_option(config)
for name in secret_option_names(config):
extra_value = config.extra_options.pop(name, None)
value = config.options.pop(name, extra_value)
if name == selected and value is not None and config.tcp_endpoint is not None:
config.tcp_endpoint.password = value or None


def credential_kind_changed(previous: ConnectionConfig | None, current: ConnectionConfig) -> bool:
if previous is None or not (secret_option_names(previous) or secret_option_names(current)):
return False
return (previous.db_type, credential_option(previous)) != (current.db_type, credential_option(current))


def without_secret_options(config: ConnectionConfig, options: dict[str, Any]) -> dict[str, Any]:
names = secret_option_names(config)
return {key: value for key, value in options.items() if key not in names}


def public_connection_url(config: ConnectionConfig) -> str | None:
url = config.connection_url
if not url or config.db_type not in _CREDENTIAL_MODES:
return url
parsed = urlsplit(url)
secret_names = secret_option_names(config) | {'password'}
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True)
if key not in secret_names]
return urlunsplit(parsed._replace(netloc=parsed.netloc.rsplit('@', 1)[-1], query=urlencode(query)))
3 changes: 3 additions & 0 deletions sqlit/domains/connections/domain/passwords.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def uses_db_password(config: ConnectionConfig) -> bool:
if auth_type in ("ad_default", "ad_integrated", "windows"):
return False

if config.db_type == "databricks" and auth_type == "oauth-u2m":
return False

if config.db_type == "postgresql":
from sqlit.domains.connections.providers.postgresql.auth import (
POSTGRES_AUTH_AZURE_ENTRA_CLI,
Expand Down
1 change: 1 addition & 0 deletions sqlit/domains/connections/providers/databricks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Provider package."""
Loading
Loading