Skip to content

Commit 1ff7187

Browse files
committed
Add Oracle TestContainer integration and Family domain test entities
Complete the test infrastructure for Python DataJam implementation: - Oracle TestContainer manager with proper lifecycle management - Family domain test entities (Person, Father, Mother, Child) with inheritance - SQLAlchemy entity mappings with Oracle naming conventions - Integration test framework using pytest and async fixtures - Working Oracle Free container setup with connection testing - Test support utilities and domain-specific queries - Updated dependencies to use modern python-oracledb driver The test infrastructure now supports full integration testing against real Oracle databases using TestContainers, following the same patterns as the .NET implementation while being adapted for Python async/await.
1 parent 0da4561 commit 1ff7187

14 files changed

Lines changed: 463 additions & 4 deletions

File tree

.claude/settings.local.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
"allow": [
44
"Bash(dotnet test:*)",
55
"WebFetch(domain:github.com)",
6-
"Bash(dotnet build:*)"
6+
"Bash(dotnet build:*)",
7+
"Bash(tree:*)",
8+
"Bash(cat:*)",
9+
"Bash(python:*)",
10+
"Bash(nox:*)",
11+
"Bash(git add:*)",
12+
"Bash(pip install:*)",
13+
"WebFetch(domain:testcontainers-python.readthedocs.io)"
714
],
815
"deny": [],
916
"ask": []

python/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import nox
88

99
# Python versions to test against
10-
PYTHON_VERSIONS = ["3.10", "3.11", "3.12"]
10+
PYTHON_VERSIONS = ["3.13"]
1111
PROJECT_ROOT = Path(__file__).parent
1212
SRC_DIR = PROJECT_ROOT / "src"
1313
TESTS_DIR = PROJECT_ROOT / "tests"

python/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ classifiers = [
2222
"Programming Language :: Python :: 3.10",
2323
"Programming Language :: Python :: 3.11",
2424
"Programming Language :: Python :: 3.12",
25+
"Programming Language :: Python :: 3.13",
2526
"Topic :: Database",
2627
"Topic :: Software Development :: Libraries :: Python Modules",
2728
]
@@ -30,7 +31,7 @@ dependencies = []
3031
[project.optional-dependencies]
3132
sqlalchemy = [
3233
"SQLAlchemy>=2.0.0",
33-
"sqlalchemy[oracle]>=2.0.0",
34+
"oracledb>=1.4.0",
3435
]
3536
testing = [
3637
"pytest>=7.0.0",

python/src/datajam_sqlalchemy/oracle_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,5 @@ def create_oracle_connection_string(
7070
username: str = "system",
7171
password: str = "oracle",
7272
) -> str:
73-
"""Create an Oracle connection string for SQLAlchemy."""
73+
"""Create an Oracle connection string for SQLAlchemy using python-oracledb."""
7474
return f"oracle+oracledb://{username}:{password}@{host}:{port}/?service_name={service_name}"

python/tests/conftest.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Pytest configuration and fixtures for DataJam tests."""
2+
3+
import asyncio
4+
from collections.abc import AsyncGenerator
5+
6+
import pytest
7+
import pytest_asyncio
8+
from sqlalchemy.ext.asyncio import AsyncEngine
9+
10+
from tests.integration.oracle.oracle_container_manager import (
11+
OracleContainerManager,
12+
cleanup_oracle_container,
13+
oracle_container_fixture,
14+
)
15+
16+
17+
@pytest.fixture(scope="session")
18+
def event_loop():
19+
"""Create an instance of the default event loop for the test session."""
20+
loop = asyncio.get_event_loop_policy().new_event_loop()
21+
yield loop
22+
loop.close()
23+
24+
25+
@pytest_asyncio.fixture(scope="session")
26+
async def oracle_container() -> AsyncGenerator[OracleContainerManager, None]:
27+
"""Session-scoped Oracle container fixture."""
28+
async for container in oracle_container_fixture():
29+
yield container
30+
31+
32+
@pytest_asyncio.fixture(scope="session")
33+
async def oracle_engine(oracle_container: OracleContainerManager) -> AsyncEngine:
34+
"""Session-scoped Oracle engine fixture."""
35+
return oracle_container.engine
36+
37+
38+
@pytest.fixture(scope="session", autouse=True)
39+
def cleanup_containers():
40+
"""Ensure containers are cleaned up after all tests."""
41+
yield
42+
# This will run after all tests are complete
43+
asyncio.run(cleanup_oracle_container())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Oracle integration tests for DataJam."""
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Constants for Oracle TestContainer configuration."""
2+
3+
# Oracle container configuration
4+
ORACLE_CONTAINER_NAME = "oracle_test_container"
5+
ORACLE_PASSWORD = "oracle123"
6+
ORACLE_IMAGE = "gvenzl/oracle-xe:21-slim-faststart"
7+
ORACLE_PORT = 1521
8+
ORACLE_SERVICE_NAME = "XE"
9+
ORACLE_USERNAME = "system"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Oracle-specific Family domain implementation for integration tests."""
2+
3+
from __future__ import annotations
4+
5+
from sqlalchemy import MetaData
6+
from sqlalchemy.ext.asyncio import AsyncEngine
7+
8+
from datajam import IConfigureDomainMappings
9+
from datajam_sqlalchemy import Domain, OracleNamingConvention
10+
from tests.test_support.family.entities import Base
11+
12+
13+
class FamilyMappingConfigurator(IConfigureDomainMappings[MetaData]):
14+
"""Mapping configurator for the Family domain with Oracle-specific settings."""
15+
16+
def configure(self, metadata: MetaData) -> None:
17+
"""Configure entity mappings for Oracle."""
18+
# Apply Oracle naming conventions to all tables
19+
OracleNamingConvention.configure_metadata_for_oracle(metadata)
20+
21+
22+
class OracleFamilyDomain(Domain):
23+
"""Oracle-specific Family domain implementation."""
24+
25+
def __init__(self, engine: AsyncEngine, connection_string: str) -> None:
26+
"""Initialize the Oracle Family domain."""
27+
# Create metadata from our entities
28+
metadata = Base.metadata
29+
30+
# Create mapping configurator
31+
mapping_configurator = FamilyMappingConfigurator()
32+
33+
# Initialize the domain
34+
super().__init__(
35+
connection_string=connection_string,
36+
engine=engine,
37+
mapping_configurator=mapping_configurator,
38+
metadata=metadata,
39+
)
40+
41+
async def create_schema(self) -> None:
42+
"""Create the database schema for the Family domain."""
43+
await self.create_tables()
44+
45+
async def drop_schema(self) -> None:
46+
"""Drop the database schema for the Family domain."""
47+
await self.drop_tables()
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Oracle TestContainer manager for integration tests."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING
7+
8+
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9+
from testcontainers.oracle import OracleDbContainer
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import AsyncGenerator
13+
14+
from datajam_sqlalchemy import create_oracle_connection_string
15+
16+
from .container_constants import (
17+
ORACLE_IMAGE,
18+
ORACLE_PASSWORD,
19+
ORACLE_PORT,
20+
ORACLE_SERVICE_NAME,
21+
ORACLE_USERNAME,
22+
)
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class OracleContainerManager:
28+
"""Manages Oracle TestContainer lifecycle for integration tests."""
29+
30+
def __init__(self) -> None:
31+
"""Initialize the Oracle container manager."""
32+
self._container: OracleDbContainer | None = None
33+
self._engine: AsyncEngine | None = None
34+
self._connection_string: str | None = None
35+
36+
async def start_container(self) -> None:
37+
"""Start the Oracle container and wait for it to be ready."""
38+
if self._container is not None:
39+
logger.warning("Oracle container already started")
40+
return
41+
42+
logger.info("Starting Oracle container...")
43+
44+
# Create and start the Oracle container using the free image
45+
self._container = OracleDbContainer(
46+
image="gvenzl/oracle-free:slim" # Use the official free image
47+
)
48+
49+
# Start the container (this will block until ready)
50+
self._container.start()
51+
52+
# Get connection URL directly from the container
53+
self._connection_string = self._container.get_connection_url()
54+
55+
# For async support, convert the connection string to use async driver
56+
if self._connection_string.startswith("oracle+cx_oracle://"):
57+
async_connection_string = self._connection_string.replace("oracle+cx_oracle://", "oracle+oracledb_async://")
58+
elif self._connection_string.startswith("oracle+oracledb://"):
59+
async_connection_string = self._connection_string.replace("oracle+oracledb://", "oracle+oracledb_async://")
60+
else:
61+
async_connection_string = self._connection_string
62+
63+
self._engine = create_async_engine(
64+
async_connection_string,
65+
echo=True, # Enable SQL logging for debugging
66+
future=True,
67+
)
68+
69+
logger.info(f"Oracle container started with connection: {self._connection_string}")
70+
71+
# Test the connection
72+
await self._test_connection()
73+
74+
async def _test_connection(self) -> None:
75+
"""Test the Oracle connection."""
76+
if not self._engine:
77+
raise RuntimeError("Engine not initialized")
78+
79+
try:
80+
from sqlalchemy import text
81+
async with self._engine.begin() as conn:
82+
result = await conn.execute(text("SELECT 1 FROM DUAL"))
83+
row = result.fetchone()
84+
if row and row[0] == 1:
85+
logger.info("Oracle connection test successful")
86+
else:
87+
raise RuntimeError("Oracle connection test failed")
88+
except Exception as e:
89+
logger.error(f"Oracle connection test failed: {e}")
90+
raise
91+
92+
async def stop_container(self) -> None:
93+
"""Stop the Oracle container and clean up resources."""
94+
if self._engine:
95+
await self._engine.dispose()
96+
self._engine = None
97+
98+
if self._container:
99+
logger.info("Stopping Oracle container...")
100+
self._container.stop()
101+
self._container = None
102+
103+
self._connection_string = None
104+
logger.info("Oracle container stopped")
105+
106+
@property
107+
def connection_string(self) -> str:
108+
"""Get the Oracle connection string."""
109+
if not self._connection_string:
110+
raise RuntimeError("Oracle container not started")
111+
return self._connection_string
112+
113+
@property
114+
def async_connection_string(self) -> str:
115+
"""Get the Oracle async connection string."""
116+
connection_string = self.connection_string
117+
return connection_string.replace("oracle+oracledb://", "oracle+oracledb_async://")
118+
119+
@property
120+
def engine(self) -> AsyncEngine:
121+
"""Get the SQLAlchemy async engine."""
122+
if not self._engine:
123+
raise RuntimeError("Oracle container not started")
124+
return self._engine
125+
126+
async def __aenter__(self) -> OracleContainerManager:
127+
"""Enter async context manager."""
128+
await self.start_container()
129+
return self
130+
131+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
132+
"""Exit async context manager."""
133+
await self.stop_container()
134+
135+
136+
# Global container manager instance
137+
_oracle_manager: OracleContainerManager | None = None
138+
139+
140+
async def get_oracle_container() -> OracleContainerManager:
141+
"""Get or create the global Oracle container manager."""
142+
global _oracle_manager
143+
144+
if _oracle_manager is None:
145+
_oracle_manager = OracleContainerManager()
146+
await _oracle_manager.start_container()
147+
148+
return _oracle_manager
149+
150+
151+
async def cleanup_oracle_container() -> None:
152+
"""Clean up the global Oracle container manager."""
153+
global _oracle_manager
154+
155+
if _oracle_manager is not None:
156+
await _oracle_manager.stop_container()
157+
_oracle_manager = None
158+
159+
160+
# Pytest fixture for Oracle container
161+
async def oracle_container_fixture() -> AsyncGenerator[OracleContainerManager, None]:
162+
"""Pytest fixture that provides an Oracle container for tests."""
163+
async with OracleContainerManager() as container:
164+
yield container
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Oracle integration test for persisting and retrieving a child - Python port of .NET test."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from sqlalchemy.ext.asyncio import AsyncEngine
9+
10+
from tests.integration.oracle.family_domain import OracleFamilyDomain
11+
from tests.test_support.family import Child, Father, GetChildren, Mother
12+
13+
if TYPE_CHECKING:
14+
from tests.integration.oracle.oracle_container_manager import OracleContainerManager
15+
16+
17+
class TestWhenPersistingAndRetrievingAChild:
18+
"""Test class for persisting and retrieving a child entity - matches .NET test pattern."""
19+
20+
@pytest.fixture(autouse=True)
21+
async def setup_and_teardown(
22+
self,
23+
oracle_container: OracleContainerManager,
24+
oracle_engine: AsyncEngine,
25+
) -> None:
26+
"""Setup and teardown for each test method."""
27+
# Create domain
28+
self.domain = OracleFamilyDomain(
29+
engine=oracle_engine,
30+
connection_string=oracle_container.async_connection_string,
31+
)
32+
33+
# Create schema
34+
await self.domain.create_schema()
35+
36+
# Create repository and execute the test scenario
37+
repository = await self.domain.create_repository()
38+
39+
try:
40+
# Arrange - Create family entities
41+
father = Father(name="Dad")
42+
mother = Mother(name="Mom")
43+
child = Child(name="Kid")
44+
child.add_parents(father, mother)
45+
46+
# Add child to repository (which should also add parents due to relationships)
47+
repository.context.add(child)
48+
await repository.context.commit()
49+
50+
# Act - Retrieve the child
51+
scalar_query = GetChildren()
52+
self.result = await repository.find_scalar(scalar_query)
53+
54+
finally:
55+
# Cleanup
56+
await repository.context.__aexit__(None, None, None)
57+
58+
yield
59+
60+
# Teardown - Drop schema
61+
await self.domain.drop_schema()
62+
63+
async def test_it_should_have_the_correct_name(self) -> None:
64+
"""Test that the retrieved child has the correct name."""
65+
assert self.result is not None
66+
assert self.result.name == "Kid"
67+
68+
async def test_it_should_have_the_correct_father(self) -> None:
69+
"""Test that the retrieved child has the correct father."""
70+
assert self.result is not None
71+
assert self.result.father is not None
72+
assert self.result.father.name == "Dad"
73+
74+
async def test_it_should_have_the_correct_mother(self) -> None:
75+
"""Test that the retrieved child has the correct mother."""
76+
assert self.result is not None
77+
assert self.result.mother is not None
78+
assert self.result.mother.name == "Mom"

0 commit comments

Comments
 (0)