Skip to content

Commit 152640b

Browse files
Jake-EtherflowclaudeMaxteabag
authored
Add ClickHouse database support (#21)
* Add ClickHouse database support Adds a new adapter for ClickHouse, the column-oriented OLAP database. Features: - Full adapter implementation using clickhouse-connect (HTTP interface) - Support for databases, tables, views, and columns via system tables - Parameterized queries to prevent SQL injection - SSH tunnel support ClickHouse-specific notes: - Uses HTTP interface (port 8123) rather than native protocol - No schema layer - uses databases directly - Views include MaterializedView, LiveView, WindowView - No stored procedures support (ClickHouse doesn't have them) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add integration tests for clickhouse. Fix indexes/seq/triggers - Fix docker default db --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Adams <18162810+Maxteabag@users.noreply.github.com>
1 parent 1c0efb0 commit 152640b

File tree

9 files changed

+589
-12
lines changed

9 files changed

+589
-12
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# sqlit
22

3-
**The lazygit of SQL databases.** Connect to Postgres, MySQL, SQL Server, SQLite, Supabase, Turso, and more from your terminal in seconds.
3+
**The lazygit of SQL databases.** Connect to Postgres, MySQL, SQL Server, SQLite, ClickHouse, Supabase, Turso, and more from your terminal in seconds.
44

55
A lightweight TUI for people who just want to run some queries fast.
66

@@ -23,7 +23,7 @@ A lightweight TUI for people who just want to run some queries fast.
2323

2424
- **Connection manager UI** - Save connections, switch between databases without CLI args
2525
- **Just run `sqlit`** - No CLI config needed, pick a connection and go
26-
- **Multi-database out of the box** - SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, Oracle, DuckDB, CockroachDB, Supabase, Turso - no adapters to install
26+
- **Multi-database out of the box** - SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, Oracle, DuckDB, CockroachDB, ClickHouse, Supabase, Turso - no adapters to install
2727
- Connect directly to database docker container
2828
- **SSH tunnels built-in** - Connect to remote databases securely with password or key auth
2929
- **Vim-style editing** - Modal editing for terminal purists
@@ -48,7 +48,7 @@ The problem got severely worse when I switched to Linux and had to rely on VS CO
4848

4949
I tried to use some existing TUI's for SQL, but they were not intuitive for me and I missed the immediate ease of use that other TUI's such as Lazygit provides.
5050

51-
sqlit is a lightweight database TUI that is easy to use and beautiful to look at, just connect and query. It's for you that just wants to run queries toward your database without launching applications that eats your ram and takes time to load up. Sqlit supports SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, Oracle, DuckDB, CockroachDB, Supabase, and Turso, and is designed to make it easy and enjoyable to access your data, not painful.
51+
sqlit is a lightweight database TUI that is easy to use and beautiful to look at, just connect and query. It's for you that just wants to run queries toward your database without launching applications that eats your ram and takes time to load up. Sqlit supports SQL Server, PostgreSQL, MySQL, SQLite, MariaDB, Oracle, DuckDB, CockroachDB, ClickHouse, Supabase, and Turso, and is designed to make it easy and enjoyable to access your data, not painful.
5252

5353

5454
## Installation
@@ -207,6 +207,7 @@ Most of the time you can just run `sqlit` and connect. If a Python driver is mis
207207
| MariaDB | `mariadb` | `pipx inject sqlit-tui mariadb` | `python -m pip install mariadb` |
208208
| Oracle | `oracledb` | `pipx inject sqlit-tui oracledb` | `python -m pip install oracledb` |
209209
| DuckDB | `duckdb` | `pipx inject sqlit-tui duckdb` | `python -m pip install duckdb` |
210+
| ClickHouse | `clickhouse-connect` | `pipx inject sqlit-tui clickhouse-connect` | `python -m pip install clickhouse-connect` |
210211
| Turso | `libsql-client` | `pipx inject sqlit-tui libsql-client` | `python -m pip install libsql-client` |
211212
| Cloudflare D1 | `requests` | `pipx inject sqlit-tui requests` | `python -m pip install requests` |
212213

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ all = [
4242
"mariadb>=1.1.0",
4343
"oracledb>=2.0.0",
4444
"duckdb>=1.1.0", # min avoids known CVEs
45+
"clickhouse-connect>=0.7.0",
4546
"requests>=2.32.4", # min avoids known CVEs
4647
"libsql-client>=0.1.0",
4748
"sshtunnel>=0.4.0",
@@ -54,6 +55,7 @@ mysql = ["mysql-connector-python>=9.1.0"] # min avoids known CVEs
5455
mariadb = ["mariadb>=1.1.0"]
5556
oracle = ["oracledb>=2.0.0"]
5657
duckdb = ["duckdb>=1.1.0"] # min avoids known CVEs
58+
clickhouse = ["clickhouse-connect>=0.7.0"]
5759
d1 = ["requests>=2.32.4"] # min avoids known CVEs
5860
turso = ["libsql-client>=0.1.0"]
5961
ssh = [
@@ -110,6 +112,7 @@ markers = [
110112
"postgresql: PostgreSQL database tests",
111113
"mysql: MySQL database tests",
112114
"oracle: Oracle database tests",
115+
"clickhouse: ClickHouse database tests",
113116
"asyncio: async tests",
114117
"integration: integration tests (may require external services)",
115118
]
@@ -142,6 +145,7 @@ module = [
142145
"mariadb",
143146
"oracledb",
144147
"duckdb",
148+
"clickhouse_connect",
145149
"libsql_client",
146150
"sshtunnel",
147151
"docker",

sqlit/db/adapters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"DatabaseAdapter",
2020
"TableInfo",
2121
# Adapter classes (lazy via __getattr__)
22+
"ClickHouseAdapter",
2223
"CockroachDBAdapter",
2324
"DuckDBAdapter",
2425
"MariaDBAdapter",
@@ -35,6 +36,7 @@
3536
]
3637

3738
if TYPE_CHECKING:
39+
from .clickhouse import ClickHouseAdapter
3840
from .cockroachdb import CockroachDBAdapter
3941
from .duckdb import DuckDBAdapter
4042
from .mariadb import MariaDBAdapter

sqlit/db/adapters/clickhouse.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""ClickHouse adapter for real-time analytics database."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
from .base import ColumnInfo, DatabaseAdapter, IndexInfo, SequenceInfo, TableInfo, TriggerInfo
8+
9+
if TYPE_CHECKING:
10+
from ...config import ConnectionConfig
11+
12+
13+
class ClickHouseAdapter(DatabaseAdapter):
14+
"""Adapter for ClickHouse analytics database.
15+
16+
ClickHouse is a column-oriented OLAP database designed for real-time analytics.
17+
It uses its own SQL dialect with some differences from standard SQL:
18+
- No traditional schemas - uses databases directly
19+
- No stored procedures
20+
- Uses backticks for identifier quoting (like MySQL)
21+
- System tables provide metadata (system.databases, system.tables, system.columns)
22+
"""
23+
24+
@property
25+
def name(self) -> str:
26+
return "ClickHouse"
27+
28+
@property
29+
def install_extra(self) -> str:
30+
return "clickhouse"
31+
32+
@property
33+
def install_package(self) -> str:
34+
return "clickhouse-connect"
35+
36+
@property
37+
def driver_import_names(self) -> tuple[str, ...]:
38+
return ("clickhouse_connect",)
39+
40+
@property
41+
def supports_multiple_databases(self) -> bool:
42+
return True
43+
44+
@property
45+
def supports_stored_procedures(self) -> bool:
46+
# ClickHouse doesn't have traditional stored procedures
47+
return False
48+
49+
@property
50+
def supports_triggers(self) -> bool:
51+
# ClickHouse doesn't support triggers
52+
return False
53+
54+
@property
55+
def supports_indexes(self) -> bool:
56+
# ClickHouse has data skipping indexes, but they work differently
57+
# than traditional indexes - we'll expose them anyway
58+
return True
59+
60+
def execute_test_query(self, conn: Any) -> None:
61+
"""Execute a simple query to verify the connection works.
62+
63+
clickhouse-connect uses query() method, not cursors.
64+
"""
65+
conn.query(self.test_query)
66+
67+
def connect(self, config: ConnectionConfig) -> Any:
68+
"""Connect to ClickHouse database.
69+
70+
Uses clickhouse-connect which provides an HTTP interface to ClickHouse.
71+
This is generally easier to set up than the native TCP protocol.
72+
"""
73+
try:
74+
import clickhouse_connect
75+
except ImportError as e:
76+
from ...db.exceptions import MissingDriverError
77+
78+
if not self.install_extra or not self.install_package:
79+
raise e
80+
raise MissingDriverError(self.name, self.install_extra, self.install_package) from e
81+
82+
# Default to port 8123 (HTTP interface) if not specified
83+
port = int(config.port) if config.port else 8123
84+
85+
# Determine if we should use HTTPS based on port
86+
# 8443 is the standard HTTPS port for ClickHouse
87+
secure = port == 8443
88+
89+
client = clickhouse_connect.get_client(
90+
host=config.server,
91+
port=port,
92+
username=config.username or "default",
93+
password=config.password or "",
94+
database=config.database or "default",
95+
secure=secure,
96+
)
97+
return client
98+
99+
def get_databases(self, conn: Any) -> list[str]:
100+
"""Get list of databases from ClickHouse."""
101+
result = conn.query("SELECT name FROM system.databases ORDER BY name")
102+
return [row[0] for row in result.result_rows]
103+
104+
def get_tables(self, conn: Any, database: str | None = None) -> list[TableInfo]:
105+
"""Get list of tables from ClickHouse.
106+
107+
Returns (database, table_name) tuples. ClickHouse doesn't have schemas
108+
within databases, so we use database as the "schema" for consistency.
109+
"""
110+
if database:
111+
result = conn.query(
112+
"SELECT database, name FROM system.tables "
113+
"WHERE database = {db:String} "
114+
"AND engine NOT IN ('View', 'MaterializedView', 'LiveView', 'WindowView') "
115+
"ORDER BY name",
116+
parameters={"db": database},
117+
)
118+
else:
119+
# Get tables from current database
120+
result = conn.query(
121+
"SELECT database, name FROM system.tables "
122+
"WHERE database = currentDatabase() "
123+
"AND engine NOT IN ('View', 'MaterializedView', 'LiveView', 'WindowView') "
124+
"ORDER BY name"
125+
)
126+
return [(row[0], row[1]) for row in result.result_rows]
127+
128+
def get_views(self, conn: Any, database: str | None = None) -> list[TableInfo]:
129+
"""Get list of views from ClickHouse.
130+
131+
Views in ClickHouse are stored in system.tables with engine = 'View'.
132+
MaterializedViews are also included as they're a common ClickHouse pattern.
133+
"""
134+
if database:
135+
result = conn.query(
136+
"SELECT database, name FROM system.tables "
137+
"WHERE database = {db:String} "
138+
"AND engine IN ('View', 'MaterializedView', 'LiveView', 'WindowView') "
139+
"ORDER BY name",
140+
parameters={"db": database},
141+
)
142+
else:
143+
result = conn.query(
144+
"SELECT database, name FROM system.tables "
145+
"WHERE database = currentDatabase() "
146+
"AND engine IN ('View', 'MaterializedView', 'LiveView', 'WindowView') "
147+
"ORDER BY name"
148+
)
149+
return [(row[0], row[1]) for row in result.result_rows]
150+
151+
def get_columns(
152+
self, conn: Any, table: str, database: str | None = None, schema: str | None = None
153+
) -> list[ColumnInfo]:
154+
"""Get columns for a table from ClickHouse.
155+
156+
ClickHouse doesn't have traditional schemas, so we use database directly.
157+
The schema parameter is treated as database for consistency with other adapters.
158+
"""
159+
# Use schema as database if provided, otherwise fall back to database param
160+
db = schema or database
161+
162+
if db:
163+
result = conn.query(
164+
"SELECT name, type, is_in_primary_key "
165+
"FROM system.columns "
166+
"WHERE database = {db:String} AND table = {tbl:String} "
167+
"ORDER BY position",
168+
parameters={"db": db, "tbl": table},
169+
)
170+
else:
171+
result = conn.query(
172+
"SELECT name, type, is_in_primary_key "
173+
"FROM system.columns "
174+
"WHERE database = currentDatabase() AND table = {tbl:String} "
175+
"ORDER BY position",
176+
parameters={"tbl": table},
177+
)
178+
179+
return [
180+
ColumnInfo(
181+
name=row[0],
182+
data_type=row[1],
183+
is_primary_key=bool(row[2]),
184+
)
185+
for row in result.result_rows
186+
]
187+
188+
def get_procedures(self, conn: Any, database: str | None = None) -> list[str]:
189+
"""ClickHouse doesn't support stored procedures - return empty list."""
190+
return []
191+
192+
def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo]:
193+
"""Get data skipping indexes from ClickHouse.
194+
195+
ClickHouse uses data skipping indexes (minmax, set, bloom_filter, etc.)
196+
rather than traditional B-tree indexes. These are stored in system.data_skipping_indices.
197+
"""
198+
if database:
199+
result = conn.query(
200+
"SELECT name, table, type "
201+
"FROM system.data_skipping_indices "
202+
"WHERE database = {db:String} "
203+
"ORDER BY table, name",
204+
parameters={"db": database},
205+
)
206+
else:
207+
result = conn.query(
208+
"SELECT name, table, type "
209+
"FROM system.data_skipping_indices "
210+
"WHERE database = currentDatabase() "
211+
"ORDER BY table, name"
212+
)
213+
return [
214+
IndexInfo(name=row[0], table_name=row[1], is_unique=False)
215+
for row in result.result_rows
216+
]
217+
218+
def get_triggers(self, conn: Any, database: str | None = None) -> list[TriggerInfo]:
219+
"""ClickHouse doesn't support triggers - return empty list."""
220+
return []
221+
222+
def get_sequences(self, conn: Any, database: str | None = None) -> list[SequenceInfo]:
223+
"""ClickHouse doesn't support sequences - return empty list."""
224+
return []
225+
226+
def quote_identifier(self, name: str) -> str:
227+
"""Quote identifier using backticks for ClickHouse.
228+
229+
ClickHouse uses backticks like MySQL for identifier quoting.
230+
Escapes embedded backticks by doubling them.
231+
"""
232+
escaped = name.replace("`", "``")
233+
return f"`{escaped}`"
234+
235+
def build_select_query(
236+
self, table: str, limit: int, database: str | None = None, schema: str | None = None
237+
) -> str:
238+
"""Build SELECT LIMIT query for ClickHouse.
239+
240+
ClickHouse uses standard LIMIT syntax.
241+
"""
242+
# Use schema as database if provided
243+
db = schema or database
244+
quoted_table = self.quote_identifier(table)
245+
if db:
246+
quoted_db = self.quote_identifier(db)
247+
return f"SELECT * FROM {quoted_db}.{quoted_table} LIMIT {limit}"
248+
return f"SELECT * FROM {quoted_table} LIMIT {limit}"
249+
250+
def execute_query(
251+
self, conn: Any, query: str, max_rows: int | None = None
252+
) -> tuple[list[str], list[tuple], bool]:
253+
"""Execute a query on ClickHouse with optional row limit.
254+
255+
clickhouse-connect returns results differently than cursor-based adapters,
256+
so we implement this directly rather than using CursorBasedAdapter.
257+
"""
258+
# If we have a row limit, modify the query to add LIMIT if not present
259+
# This is more efficient than fetching all rows and truncating
260+
modified_query = query
261+
truncated = False
262+
263+
if max_rows is not None:
264+
query_upper = query.upper().strip()
265+
# Only add limit for SELECT queries that don't already have one
266+
if query_upper.startswith("SELECT") and "LIMIT" not in query_upper:
267+
# Fetch one extra to detect truncation
268+
modified_query = f"{query.rstrip().rstrip(';')} LIMIT {max_rows + 1}"
269+
270+
result = conn.query(modified_query)
271+
272+
if result.column_names:
273+
columns = list(result.column_names)
274+
rows = result.result_rows
275+
276+
if max_rows is not None and len(rows) > max_rows:
277+
truncated = True
278+
rows = rows[:max_rows]
279+
280+
return columns, [tuple(row) for row in rows], truncated
281+
282+
return [], [], False
283+
284+
def execute_non_query(self, conn: Any, query: str) -> int:
285+
"""Execute a non-query on ClickHouse (INSERT, ALTER, etc.).
286+
287+
ClickHouse doesn't return row counts for most operations,
288+
so we return -1 to indicate unknown affected rows.
289+
"""
290+
conn.command(query)
291+
# ClickHouse command() doesn't return row count
292+
return -1

sqlit/db/providers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
# Pre-import all schemas (no external dependencies)
1616
from .schema import (
17+
CLICKHOUSE_SCHEMA,
1718
COCKROACHDB_SCHEMA,
1819
D1_SCHEMA,
1920
DUCKDB_SCHEMA,
@@ -29,6 +30,7 @@
2930
)
3031

3132
# Pre-import all adapter classes (they lazy-load their dependencies internally)
33+
from .adapters.clickhouse import ClickHouseAdapter
3234
from .adapters.cockroachdb import CockroachDBAdapter
3335
from .adapters.d1 import D1Adapter
3436
from .adapters.duckdb import DuckDBAdapter
@@ -63,6 +65,7 @@ class ProviderSpec:
6365
"turso": ProviderSpec(schema=TURSO_SCHEMA, adapter_class=TursoAdapter),
6466
"supabase": ProviderSpec(schema=SUPABASE_SCHEMA, adapter_class=SupabaseAdapter),
6567
"d1": ProviderSpec(schema=D1_SCHEMA, adapter_class=D1Adapter),
68+
"clickhouse": ProviderSpec(schema=CLICKHOUSE_SCHEMA, adapter_class=ClickHouseAdapter),
6669
}
6770

6871

0 commit comments

Comments
 (0)