Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ Most of the time you can just run `sqlit` and connect. If a Python driver is mis
| Database | Driver package | `pipx` | `pip` / venv |
| :---------------------------------- | :--------------------------- | :------------------------------------------------- | :------------------------------------------------- |
| SQLite | *(built-in)* | *(built-in)* | *(built-in)* |
| PostgreSQL / CockroachDB / Supabase | `psycopg2-binary` | `pipx inject sqlit-tui psycopg2-binary` | `python -m pip install psycopg2-binary` |
| PostgreSQL / CockroachDB / Supabase | `psycopg[binary]` | `pipx inject sqlit-tui 'psycopg[binary]'` | `python -m pip install 'psycopg[binary]'` |
| SQL Server | `mssql-python` | `pipx inject sqlit-tui mssql-python` | `python -m pip install mssql-python` |
| MySQL | `PyMySQL` | `pipx inject sqlit-tui PyMySQL` | `python -m pip install PyMySQL` |
| MariaDB | `PyMySQL` | `pipx inject sqlit-tui PyMySQL` | `python -m pip install PyMySQL` |
Expand Down
4 changes: 2 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
# here; install with `pipx inject` or a custom derivation.
nixpkgsExtras = {
ssh = [ pyPkgs.sshtunnel pyPkgs.paramiko ];
postgres = [ pyPkgs.psycopg2 ];
cockroachdb = [ pyPkgs.psycopg2 ];
postgres = [ pyPkgs.psycopg ];
cockroachdb = [ pyPkgs.psycopg ];
mysql = [ pyPkgs.pymysql ];
duckdb = [ pyPkgs.duckdb ];
bigquery = [ pyPkgs.google-cloud-bigquery ];
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dynamic = ["version"]

[project.optional-dependencies]
all = [
"psycopg2-binary>=2.9.0",
"psycopg[binary]>=3.2.0",
"mssql-python>=1.1.0",
"PyMySQL>=1.1.0",
"oracledb>=2.0.0",
Expand All @@ -63,8 +63,8 @@ all = [
"osquery>=3.0.0",
"surrealdb>=1.0.0",
]
postgres = ["psycopg2-binary>=2.9.0"]
cockroachdb = ["psycopg2-binary>=2.9.0"]
postgres = ["psycopg[binary]>=3.2.0"]
cockroachdb = ["psycopg[binary]>=3.2.0"]
mssql = ["mssql-python>=1.1.0"]
mysql = ["PyMySQL>=1.1.0"]
mariadb = ["PyMySQL>=1.1.0"]
Expand Down
2 changes: 2 additions & 0 deletions sqlit/domains/connections/app/install_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def _get_arch_package_name(package_name: str) -> str | None:
mapping = {
"psycopg2-binary": "python-psycopg2",
"psycopg2": "python-psycopg2",
"psycopg[binary]": "python-psycopg",
"psycopg": "python-psycopg",
"mssql-python": "python-mssql",
"PyMySQL": "python-pymysql",
"mysql-connector-python": "python-mysql-connector",
Expand Down
22 changes: 21 additions & 1 deletion sqlit/domains/connections/providers/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,19 @@ def execute_query(self, conn: Any, query: str, max_rows: int | None = None) -> t
"""Execute a query using cursor-based approach with optional row limit."""
cursor = conn.cursor()
cursor.execute(query)
# Multi-statement queries (e.g. "BEGIN; INSERT; SELECT") produce
# several result sets on drivers that expose them — psycopg v3
# leaves the cursor positioned on the first set (the BEGIN, which
# has no columns), so advance to the last one. psycopg2 and other
# drivers either don't implement nextset() or return None for
# single-statement queries; either way this loop terminates.
while True:
try:
more = cursor.nextset()
except Exception:
more = None
if not more:
break
if cursor.description:
columns = [col[0] for col in cursor.description]
if max_rows is not None:
Expand All @@ -511,7 +524,14 @@ def execute_non_query(self, conn: Any, query: str) -> int:
cursor = conn.cursor()
cursor.execute(query)
rowcount = int(cursor.rowcount)
conn.commit()
# When the connection is in autocommit mode, an explicit conn.commit()
# has driver-dependent semantics: psycopg2 makes it a no-op, but
# psycopg v3 actively ends any open transaction. Calling commit()
# here would unconditionally close out an explicit BEGIN before the
# caller's INSERT/ROLLBACK gets a chance to run. Skipping it when
# autocommit is on preserves the intent in both worlds.
if not getattr(conn, "autocommit", False):
conn.commit()
return rowcount


Expand Down
16 changes: 8 additions & 8 deletions sqlit/domains/connections/providers/cockroachdb/adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""CockroachDB adapter using psycopg2 (PostgreSQL wire-compatible)."""
"""CockroachDB adapter using psycopg v3 (PostgreSQL wire-compatible)."""

from __future__ import annotations

Expand All @@ -18,7 +18,7 @@


class CockroachDBAdapter(PostgresBaseAdapter):
"""Adapter for CockroachDB using psycopg2 (PostgreSQL wire-compatible)."""
"""Adapter for CockroachDB using psycopg v3 (PostgreSQL wire-compatible)."""

@property
def name(self) -> str:
Expand All @@ -30,11 +30,11 @@ def install_extra(self) -> str:

@property
def install_package(self) -> str:
return "psycopg2-binary"
return "psycopg[binary]"

@property
def driver_import_names(self) -> tuple[str, ...]:
return ("psycopg2",)
return ("psycopg",)

@property
def supports_stored_procedures(self) -> bool:
Expand All @@ -47,8 +47,8 @@ def supports_triggers(self) -> bool:

def connect(self, config: ConnectionConfig) -> Any:
"""Connect to CockroachDB database."""
psycopg2 = self._import_driver_module(
"psycopg2",
psycopg = self._import_driver_module(
"psycopg",
driver_name=self.name,
extra_name=self.install_extra,
package_name=self.install_package,
Expand All @@ -61,7 +61,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args: dict[str, Any] = {
"host": endpoint.host,
"port": port,
"database": endpoint.database or "defaultdb",
"dbname": endpoint.database or "defaultdb",
"user": endpoint.username,
"password": endpoint.password,
"connect_timeout": 10,
Expand All @@ -88,7 +88,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args["sslpassword"] = tls_key_password

connect_args.update(config.extra_options)
conn = psycopg2.connect(**connect_args)
conn = psycopg.connect(**connect_args)
# Enable autocommit to avoid transaction issues
conn.autocommit = True
return conn
Expand Down
21 changes: 13 additions & 8 deletions sqlit/domains/connections/providers/postgresql/adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""PostgreSQL adapter using psycopg2."""
"""PostgreSQL adapter using psycopg (v3)."""

from __future__ import annotations

Expand All @@ -18,7 +18,12 @@


class PostgreSQLAdapter(PostgresBaseAdapter):
"""Adapter for PostgreSQL using psycopg2."""
"""Adapter for PostgreSQL using psycopg (v3).

psycopg3 delegates TLS to libpq instead of bundling libssl, which avoids
pulling Security.framework into the parent process at import time on
macOS — one of the crash classes covered by issue #189.
"""

@property
def name(self) -> str:
Expand All @@ -30,16 +35,16 @@ def install_extra(self) -> str:

@property
def install_package(self) -> str:
return "psycopg2-binary"
return "psycopg[binary]"

@property
def driver_import_names(self) -> tuple[str, ...]:
return ("psycopg2",)
return ("psycopg",)

def connect(self, config: ConnectionConfig) -> Any:
"""Connect to PostgreSQL database."""
psycopg2 = self._import_driver_module(
"psycopg2",
psycopg = self._import_driver_module(
"psycopg",
driver_name=self.name,
extra_name=self.install_extra,
package_name=self.install_package,
Expand All @@ -50,7 +55,7 @@ def connect(self, config: ConnectionConfig) -> Any:
raise ValueError("PostgreSQL connections require a TCP-style endpoint.")
connect_args: dict[str, Any] = {
"connect_timeout": 10,
"database": endpoint.database or "postgres",
"dbname": endpoint.database or "postgres",
}
host = endpoint.host
# If the user only set a port (e.g. Postgres on a non-default port
Expand Down Expand Up @@ -82,7 +87,7 @@ def connect(self, config: ConnectionConfig) -> Any:
connect_args["sslpassword"] = tls_key_password

connect_args.update(config.extra_options)
conn = psycopg2.connect(**connect_args)
conn = psycopg.connect(**connect_args)
# Enable autocommit to avoid "transaction aborted" errors on failed statements
conn.autocommit = True
return conn
Expand Down
16 changes: 8 additions & 8 deletions tests/fixtures/cockroachdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
pytest.skip("CockroachDB is not available")

try:
import psycopg2
import psycopg
except ImportError:
pytest.skip("psycopg2 is not installed")
pytest.skip("psycopg is not installed")

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database="defaultdb",
dbname="defaultdb",
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand All @@ -60,10 +60,10 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
cursor.execute(f"CREATE DATABASE {COCKROACHDB_DATABASE}")
conn.close()

conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database=COCKROACHDB_DATABASE,
dbname=COCKROACHDB_DATABASE,
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand Down Expand Up @@ -139,10 +139,10 @@ def cockroachdb_db(cockroachdb_server_ready: bool) -> str:
yield COCKROACHDB_DATABASE

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=COCKROACHDB_HOST,
port=COCKROACHDB_PORT,
database="defaultdb",
dbname="defaultdb",
user=COCKROACHDB_USER,
password=COCKROACHDB_PASSWORD or None,
connect_timeout=10,
Expand Down
12 changes: 6 additions & 6 deletions tests/fixtures/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ def postgres_db(postgres_server_ready: bool) -> str:
pytest.skip("PostgreSQL is not available")

try:
import psycopg2
import psycopg
except ImportError:
pytest.skip("psycopg2 is not installed")
pytest.skip("psycopg is not installed")

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DATABASE,
dbname=POSTGRES_DATABASE,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD,
connect_timeout=10,
Expand Down Expand Up @@ -122,10 +122,10 @@ def postgres_db(postgres_server_ready: bool) -> str:
yield POSTGRES_DATABASE

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DATABASE,
dbname=POSTGRES_DATABASE,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD,
connect_timeout=10,
Expand Down
12 changes: 6 additions & 6 deletions tests/fixtures/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,19 @@ def ssh_postgres_db(ssh_server_ready: bool) -> str:
pytest.skip("SSH server is not available")

try:
import psycopg2
import psycopg
except ImportError:
pytest.skip("psycopg2 is not installed")
pytest.skip("psycopg is not installed")

# postgres-ssh container is accessible on port 5433
pg_host = os.environ.get("SSH_DIRECT_PG_HOST", "localhost")
pg_port = int(os.environ.get("SSH_DIRECT_PG_PORT", "5433"))

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=pg_host,
port=pg_port,
database=POSTGRES_DATABASE,
dbname=POSTGRES_DATABASE,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD,
connect_timeout=10,
Expand Down Expand Up @@ -109,10 +109,10 @@ def ssh_postgres_db(ssh_server_ready: bool) -> str:
yield POSTGRES_DATABASE

try:
conn = psycopg2.connect(
conn = psycopg.connect(
host=pg_host,
port=pg_port,
database=POSTGRES_DATABASE,
dbname=POSTGRES_DATABASE,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD,
connect_timeout=10,
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/test_stale_connection_reconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,15 +496,15 @@ def work() -> None:

if spec.key == "postgres":
try:
import psycopg2
import psycopg
except ImportError:
pytest.skip("psycopg2 is not installed")
pytest.skip("psycopg is not installed")

def work() -> None:
conn = psycopg2.connect(
conn = psycopg.connect(
host=spec.host,
port=spec.port,
database=spec.database,
dbname=spec.database,
user=spec.username,
password=spec.password,
connect_timeout=10,
Expand Down
30 changes: 24 additions & 6 deletions tests/integration/test_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ def test_multi_statement_as_single_query_works(self, postgres_db: str):
assert len(result.columns) > 0
assert result.row_count > 0

# The query opened a transaction (BEGIN) but never closed it. Close
# the executor so its persistent connection rolls back, otherwise
# the DROP TABLE below blocks waiting for the lock.
reset_tui_executor()

# Cleanup
conn = adapter.connect(config)
try:
Expand Down Expand Up @@ -509,13 +514,19 @@ def test_atomic_execute_rolls_back_on_error(self, postgres_db: str):
initial_count = result.rows[0][0]

# This should fail on the second INSERT (NULL not allowed)
# and rollback the first INSERT too
# and rollback the first INSERT too. atomic_execute reports
# the failure via a MultiStatementResult with completed=False
# rather than raising.
from sqlit.domains.query.app.multi_statement import MultiStatementResult

query = """
INSERT INTO atomic_test (name) VALUES ('row1');
INSERT INTO atomic_test (name) VALUES (NULL);
"""
with pytest.raises(Exception):
executor.atomic_execute(query)
atomic_result = executor.atomic_execute(query)
assert isinstance(atomic_result, MultiStatementResult)
assert atomic_result.completed is False
assert atomic_result.error_index is not None

# Count should be unchanged (both inserts rolled back)
result = executor.execute("SELECT COUNT(*) FROM atomic_test")
Expand Down Expand Up @@ -556,16 +567,23 @@ def test_atomic_execute_commits_on_success(self, postgres_db: str):

executor = TransactionExecutor(config, provider)
try:
from sqlit.domains.query.app.multi_statement import MultiStatementResult

query = """
INSERT INTO atomic_test (name) VALUES ('row1');
INSERT INTO atomic_test (name) VALUES ('row2');
SELECT * FROM atomic_test;
"""
result = executor.atomic_execute(query)

# Should return the SELECT result
assert isinstance(result, QueryResult)
assert result.row_count == 2
# Multi-statement atomic_execute returns a MultiStatementResult
# carrying each statement's outcome. The SELECT lands as the
# last entry and exposes the rows.
assert isinstance(result, MultiStatementResult)
assert result.completed is True
select_result = result.results[-1].result
assert isinstance(select_result, QueryResult)
assert select_result.row_count == 2
finally:
executor.close()
conn = adapter.connect(config)
Expand Down
Loading