Skip to content

Commit af01264

Browse files
committed
Added base generic db
1 parent f4acd6c commit af01264

File tree

1 file changed

+212
-0
lines changed
  • modules/generic/testcontainers/generic

1 file changed

+212
-0
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import logging
14+
from typing import Any, Optional
15+
from urllib.parse import quote, urlencode
16+
17+
from testcontainers.core.container import DockerContainer
18+
from testcontainers.core.exceptions import ContainerStartException
19+
from testcontainers.core.utils import raise_for_deprecated_parameter
20+
from testcontainers.core.waiting_utils import wait_container_is_ready
21+
22+
logger = logging.getLogger(__name__)
23+
24+
ADDITIONAL_TRANSIENT_ERRORS = []
25+
try:
26+
from sqlalchemy.exc import DBAPIError
27+
28+
ADDITIONAL_TRANSIENT_ERRORS.append(DBAPIError)
29+
except ImportError:
30+
logger.debug("SQLAlchemy not available, skipping DBAPIError handling")
31+
32+
33+
class DbContainer(DockerContainer):
34+
"""
35+
Generic database container providing common database functionality.
36+
37+
This class serves as a base for database-specific container implementations.
38+
It provides connection management, URL construction, and basic lifecycle methods.
39+
40+
Note:
41+
This class is deprecated and will be removed in a future version.
42+
Use database-specific container classes instead.
43+
"""
44+
45+
@wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS)
46+
def _connect(self) -> None:
47+
"""
48+
Test database connectivity using SQLAlchemy.
49+
50+
Raises:
51+
ImportError: If SQLAlchemy is not installed
52+
Exception: If connection fails
53+
"""
54+
try:
55+
import sqlalchemy
56+
except ImportError as e:
57+
logger.error("SQLAlchemy is required for database connectivity testing")
58+
raise ImportError("SQLAlchemy is required for database containers") from e
59+
60+
connection_url = self.get_connection_url()
61+
logger.debug(f"Testing database connection to {self._mask_password_in_url(connection_url)}")
62+
63+
engine = sqlalchemy.create_engine(connection_url)
64+
try:
65+
with engine.connect():
66+
logger.info("Database connection test successful")
67+
except Exception as e:
68+
logger.error(f"Database connection test failed: {e}")
69+
raise
70+
finally:
71+
engine.dispose()
72+
73+
def get_connection_url(self) -> str:
74+
"""
75+
Get the database connection URL.
76+
77+
Returns:
78+
str: Database connection URL
79+
80+
Raises:
81+
NotImplementedError: Must be implemented by subclasses
82+
"""
83+
raise NotImplementedError("Subclasses must implement get_connection_url()")
84+
85+
def _create_connection_url(
86+
self,
87+
dialect: str,
88+
username: str,
89+
password: str,
90+
host: Optional[str] = None,
91+
port: Optional[int] = None,
92+
dbname: Optional[str] = None,
93+
query_params: Optional[dict[str, str]] = None,
94+
**kwargs: Any,
95+
) -> str:
96+
"""
97+
Create a database connection URL.
98+
99+
Args:
100+
dialect: Database dialect (e.g., 'postgresql', 'mysql')
101+
username: Database username
102+
password: Database password
103+
host: Database host (defaults to container host)
104+
port: Database port
105+
dbname: Database name
106+
query_params: Additional query parameters for the URL
107+
**kwargs: Additional parameters (checked for deprecated usage)
108+
109+
Returns:
110+
str: Formatted database connection URL
111+
112+
Raises:
113+
ValueError: If unexpected arguments are provided or required parameters are missing
114+
ContainerStartException: If container is not started
115+
"""
116+
if raise_for_deprecated_parameter(kwargs, "db_name", "dbname"):
117+
raise ValueError(f"Unexpected arguments: {','.join(kwargs)}")
118+
119+
if self._container is None:
120+
raise ContainerStartException("Container has not been started")
121+
122+
# Validate required parameters
123+
if not dialect:
124+
raise ValueError("Database dialect is required")
125+
if not username:
126+
raise ValueError("Database username is required")
127+
if port is None:
128+
raise ValueError("Database port is required")
129+
130+
host = host or self.get_container_host_ip()
131+
exposed_port = self.get_exposed_port(port)
132+
133+
# Safely quote password to handle special characters
134+
quoted_password = quote(password, safe="")
135+
quoted_username = quote(username, safe="")
136+
137+
# Build base URL
138+
url = f"{dialect}://{quoted_username}:{quoted_password}@{host}:{exposed_port}"
139+
140+
# Add database name if provided
141+
if dbname:
142+
quoted_dbname = quote(dbname, safe="")
143+
url = f"{url}/{quoted_dbname}"
144+
145+
# Add query parameters if provided
146+
if query_params:
147+
query_string = urlencode(query_params)
148+
url = f"{url}?{query_string}"
149+
150+
logger.debug(f"Created connection URL: {self._mask_password_in_url(url)}")
151+
return url
152+
153+
def _mask_password_in_url(self, url: str) -> str:
154+
"""
155+
Mask password in URL for safe logging.
156+
157+
Args:
158+
url: Database connection URL
159+
160+
Returns:
161+
str: URL with masked password
162+
"""
163+
try:
164+
# Simple regex-based masking for logging
165+
import re
166+
167+
return re.sub(r"://([^:]+):([^@]+)@", r"://\1:***@", url)
168+
except Exception:
169+
return "[URL with masked credentials]"
170+
171+
def start(self) -> "DbContainer":
172+
"""
173+
Start the database container and perform initialization.
174+
175+
Returns:
176+
DbContainer: Self for method chaining
177+
178+
Raises:
179+
ContainerStartException: If container fails to start
180+
Exception: If configuration, seed transfer, or connection fails
181+
"""
182+
logger.info(f"Starting database container: {self.image}")
183+
184+
try:
185+
self._configure()
186+
super().start()
187+
self._transfer_seed()
188+
self._connect()
189+
logger.info("Database container started successfully")
190+
except Exception as e:
191+
logger.error(f"Failed to start database container: {e}")
192+
raise
193+
194+
return self
195+
196+
def _configure(self) -> None:
197+
"""
198+
Configure the database container before starting.
199+
200+
Raises:
201+
NotImplementedError: Must be implemented by subclasses
202+
"""
203+
raise NotImplementedError("Subclasses must implement _configure()")
204+
205+
def _transfer_seed(self) -> None:
206+
"""
207+
Transfer seed data to the database container.
208+
209+
This method can be overridden by subclasses to provide
210+
database-specific seeding functionality.
211+
"""
212+
logger.debug("No seed data to transfer")

0 commit comments

Comments
 (0)