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
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ jobs:

services:
oracle:
image: gvenzl/oracle-free:23-slim
image: gvenzl/oracle-free:23-slim@sha256:fbbd3023d5abc33e36d3814816e6fd740e8efabeaa70cf470ddeab5874a3f6f8
env:
ORACLE_PASSWORD: TestPassword123!
APP_USER: testuser
Expand Down Expand Up @@ -287,6 +287,53 @@ jobs:
ORACLE_SERVICE: FREEPDB1
run: uv run pytest tests/test_oracle.py -v --timeout=120

- name: Require Oracle Native Network Encryption
env:
ORACLE_CONTAINER: ${{ job.services.oracle.id }}
run: |
docker exec --user root "$ORACLE_CONTAINER" sh -lc 'printf "\nSQLNET.CRYPTO_CHECKSUM_SERVER = required\nSQLNET.CRYPTO_CHECKSUM_TYPES_SERVER = (SHA512)\nSQLNET.ENCRYPTION_SERVER = required\nSQLNET.ENCRYPTION_TYPES_SERVER = (AES256)\n" >> /opt/oracle/oradata/dbconfig/FREE/sqlnet.ora'
docker restart "$ORACLE_CONTAINER"
for attempt in {1..60}; do
if docker exec "$ORACLE_CONTAINER" healthcheck.sh; then
exit 0
fi
echo "Waiting for NNE-enabled Oracle restart ($attempt/60)"
sleep 2
done
docker logs "$ORACLE_CONTAINER"
exit 1

- name: Install Oracle Instant Client
env:
INSTANT_CLIENT_URL: https://download.oracle.com/otn_software/linux/instantclient/2326300/instantclient-basiclite-linux.x64-23.26.3.0.0.zip
INSTANT_CLIENT_SHA256: 88c2b59d473f0d34615556dbee51cb90cce61a9104b4c1c772d13c344fba19c9
run: |
sudo apt-get update
sudo apt-get install -y libaio1t64 || sudo apt-get install -y libaio1
libaio_path=$(ldconfig -p | awk '/libaio\.so\.1t64/{path=$NF} END{print path}')
if [ -n "$libaio_path" ]; then
sudo ln -sf "$libaio_path" "$(dirname "$libaio_path")/libaio.so.1"
sudo ldconfig
fi
curl --fail --location --retry 3 "$INSTANT_CLIENT_URL" --output "$RUNNER_TEMP/instantclient.zip"
echo "$INSTANT_CLIENT_SHA256 $RUNNER_TEMP/instantclient.zip" | sha256sum --check --strict
mkdir -p "$RUNNER_TEMP/instantclient"
unzip -q "$RUNNER_TEMP/instantclient.zip" -d "$RUNNER_TEMP/instantclient"
client_dir=$(find "$RUNNER_TEMP/instantclient" -mindepth 1 -maxdepth 1 -type d -name 'instantclient_*' -print -quit)
test -n "$client_dir"
echo "ORACLE_CLIENT_LIB_DIR=$client_dir" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=$client_dir" >> "$GITHUB_ENV"

- name: Verify Oracle Thin rejection and Thick connection
env:
SQLIT_ORACLE_NNE_TEST: "1"
ORACLE_HOST: localhost
ORACLE_PORT: 1521
ORACLE_USER: testuser
ORACLE_PASSWORD: TestPassword123!
ORACLE_SERVICE: FREEPDB1
run: uv run pytest tests/integration/test_oracle_nne.py -v --timeout=60

test-mariadb:
runs-on: ubuntu-latest
needs: build
Expand Down
43 changes: 43 additions & 0 deletions sqlit/domains/connections/providers/oracle/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class OracleAdapter(DatabaseAdapter):
connected user may access, including objects owned by other schemas.
"""

_client_mode_default = "thin"

@property
def name(self) -> str:
return "Oracle"
Expand Down Expand Up @@ -113,6 +115,45 @@ def supports_foreign_keys(self) -> bool:
def test_query(self) -> str:
return "SELECT 1 FROM DUAL"

def _ensure_client_mode(self, oracledb: Any, config: ConnectionConfig) -> None:
"""Enable Thick mode before the first connection when requested.

python-oracledb chooses one mode for the lifetime of the process, so
initialization must happen before ``connect()``. Calling
``init_oracle_client()`` repeatedly with the same arguments is
supported by the driver.
"""
client_mode = str(
config.get_option("oracle_client_mode", self._client_mode_default)
).strip().lower()
if client_mode == "thin":
is_thin_mode = getattr(oracledb, "is_thin_mode", None)
if callable(is_thin_mode) and is_thin_mode() is False:
raise ValueError(
"Oracle Thin mode cannot be selected after Thick mode was "
"initialized in this sqlit process. Restart sqlit before "
"opening this connection."
)
return
if client_mode != "thick":
raise ValueError("Oracle client mode must be Thin or Thick")

lib_dir = str(
config.get_option("oracle_client_lib_dir", "") or ""
).strip()
try:
if lib_dir:
oracledb.init_oracle_client(lib_dir=lib_dir)
else:
oracledb.init_oracle_client()
except Exception as exc:
raise ValueError(
"Oracle Thick mode initialization failed. Install Oracle Client "
"libraries and make them available to sqlit before connecting. "
"Restart sqlit if a Thin-mode connection was already opened. "
f"Driver error: {exc}"
) from exc

def connect(self, config: ConnectionConfig) -> Any:
"""Connect to Oracle database."""
oracledb = self._import_driver_module(
Expand All @@ -122,6 +163,8 @@ def connect(self, config: ConnectionConfig) -> Any:
package_name=self.install_package,
)

self._ensure_client_mode(oracledb, config)

# Fetch CLOB/BLOB values inline as str/bytes instead of LOB locators.
# Locators need a live connection to be read, but results are pickled
# across the process worker pipe after the query connection is closed,
Expand Down
27 changes: 27 additions & 0 deletions sqlit/domains/connections/providers/oracle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ def _get_oracle_protocol_options() -> tuple[SelectOption, ...]:
)


def _get_oracle_client_mode_options() -> tuple[SelectOption, ...]:
return (
SelectOption("thin", "Thin (default)"),
SelectOption("thick", "Thick (Oracle Client)"),
)


def _oracle_connection_type_is_service_name(values: dict) -> bool:
return values.get("oracle_connection_type", "service_name") != "sid"

Expand All @@ -43,6 +50,10 @@ def _oracle_connection_type_is_sid(values: dict) -> bool:
return values.get("oracle_connection_type") == "sid"


def _oracle_thick_mode_enabled(values: dict) -> bool:
return values.get("oracle_client_mode") == "thick"


SCHEMA = ConnectionSchema(
db_type="oracle",
display_name="Oracle",
Expand Down Expand Up @@ -100,6 +111,22 @@ def _oracle_connection_type_is_sid(values: dict) -> bool:
options=_get_oracle_role_options(),
default="normal",
),
SchemaField(
name="oracle_client_mode",
label="Client Mode",
field_type=FieldType.DROPDOWN,
options=_get_oracle_client_mode_options(),
default="thin",
description=("Thick mode requires separately installed Oracle Client libraries and supports Native Network Encryption."),
),
SchemaField(
name="oracle_client_lib_dir",
label="Client Library Directory",
field_type=FieldType.DIRECTORY,
placeholder="/path/to/instantclient",
description=("Optional on Windows and macOS. On Linux, normally leave blank and configure ldconfig or LD_LIBRARY_PATH before starting sqlit."),
visible_when=_oracle_thick_mode_enabled,
),
)
+ SSH_FIELDS,
default_port="1521",
Expand Down
33 changes: 1 addition & 32 deletions sqlit/domains/connections/providers/oracle_legacy/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
class OracleLegacyAdapter(OracleAdapter):
"""Adapter for Oracle 11g and older using thick client mode."""

_client_initialized = False
_client_lib_dir: str | None = None
_client_mode_default = "thick"

@property
def name(self) -> str:
Expand All @@ -32,26 +31,6 @@ def install_package(self) -> str:
def driver_import_names(self) -> tuple[str, ...]:
return ("oracledb",)

def _ensure_thick_client(self, oracledb: Any, config: ConnectionConfig) -> None:
mode = str(config.get_option("oracle_client_mode", "thick")).lower()
if mode == "thin":
return
lib_dir = config.get_option("oracle_client_lib_dir") or None
if OracleLegacyAdapter._client_initialized:
return
try:
if lib_dir:
oracledb.init_oracle_client(lib_dir=str(lib_dir))
else:
oracledb.init_oracle_client()
except Exception as exc:
raise ValueError(
"Oracle thick client initialization failed. Install Oracle Instant Client "
"and optionally set the client library path."
) from exc
OracleLegacyAdapter._client_initialized = True
OracleLegacyAdapter._client_lib_dir = str(lib_dir) if lib_dir else None

def get_post_connect_warnings(self, config: ConnectionConfig) -> list[str]:
mode = str(config.get_option("oracle_client_mode", "thick")).lower()
if mode == "thin":
Expand All @@ -60,16 +39,6 @@ def get_post_connect_warnings(self, config: ConnectionConfig) -> list[str]:
]
return []

def connect(self, config: ConnectionConfig) -> Any:
oracledb = self._import_driver_module(
"oracledb",
driver_name=self.name,
extra_name=self.install_extra,
package_name=self.install_package,
)
self._ensure_thick_client(oracledb, config)
return super().connect(config)

def build_select_query(self, table: str, limit: int, database: str | None = None, schema: str | None = None) -> str:
"""Build a schema-aware SELECT query with Oracle 11g ROWNUM pagination."""
qualified = self.catalog_qualified_name(database, schema, table)
Expand Down
67 changes: 67 additions & 0 deletions tests/integration/test_oracle_nne.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Real Thin/Thick connection checks against an NNE-required Oracle server."""

from __future__ import annotations

import os
import subprocess
import sys

import pytest

pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
os.environ.get("SQLIT_ORACLE_NNE_TEST") != "1",
reason="Oracle NNE fixture is not enabled",
),
]

_CONNECT_SCRIPT = r"""
import os
import sys
import oracledb

if sys.argv[1] == "thick":
oracledb.init_oracle_client(lib_dir=os.environ["ORACLE_CLIENT_LIB_DIR"])

connection = oracledb.connect(
user=os.environ.get("ORACLE_USER", "testuser"),
password=os.environ.get("ORACLE_PASSWORD", "TestPassword123!"),
dsn=(
f"{os.environ.get('ORACLE_HOST', '127.0.0.1')}:"
f"{os.environ.get('ORACLE_PORT', '1521')}/"
f"{os.environ.get('ORACLE_SERVICE', 'FREEPDB1')}"
),
)
value = connection.cursor().execute("SELECT 1 FROM dual").fetchone()[0]
print(f"mode={'thin' if connection.thin else 'thick'} value={value}")
connection.close()
"""


def _connect(mode: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-c", _CONNECT_SCRIPT, mode],
text=True,
capture_output=True,
env=os.environ.copy(),
check=False,
timeout=30,
)


def test_thin_mode_is_rejected_when_native_encryption_is_required() -> None:
result = _connect("thin")

assert result.returncode != 0
assert "DPY-3001" in result.stderr


def test_thick_mode_connects_with_instant_client() -> None:
if not os.environ.get("ORACLE_CLIENT_LIB_DIR"):
pytest.skip("Oracle Instant Client is not configured")

result = _connect("thick")

assert result.returncode == 0, result.stderr
assert "mode=thick value=1" in result.stdout
Loading
Loading