- 
                Notifications
    You must be signed in to change notification settings 
- Fork 344
feat(generic): Reintroducing the generic SQL module #892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 23 commits
f4acd6c
              af01264
              869a135
              5c3ffbc
              39cb0a1
              5d15b6e
              80a5355
              04e418c
              09d9cad
              acec2c1
              f0bf381
              ec9d4e2
              0f53ccb
              29e4ece
              03dd5e0
              1da1724
              fe4604b
              ed1c991
              a99045a
              ad1575a
              0cce48b
              00e9eaf
              336cb55
              ee5ab80
              6dbb330
              bf6a553
              6694e44
              4c7a67f
              af2efe3
              d7e647e
              75263ed
              aacc13a
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -1 +1,2 @@ | ||
| from .server import ServerContainer # noqa: F401 | ||
| from .sql import SqlContainer # noqa: F401 | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from .sql_connector import SqlConnectWaitStrategy # noqa: F401 | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # This module provides a wait strategy for SQL database connectivity testing using SQLAlchemy. | ||
| # It includes handling for transient exceptions and connection retries. | ||
|  | ||
| import logging | ||
|  | ||
| from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget | ||
|  | ||
| logger = logging.getLogger(__name__) | ||
|  | ||
| ADDITIONAL_TRANSIENT_ERRORS = [] | ||
| try: | ||
| from sqlalchemy.exc import DBAPIError | ||
|  | ||
| ADDITIONAL_TRANSIENT_ERRORS.append(DBAPIError) | ||
| except ImportError: | ||
| logger.debug("SQLAlchemy not available, skipping DBAPIError handling") | ||
|  | ||
|  | ||
| class SqlConnectWaitStrategy(WaitStrategy): | ||
| """Wait strategy for database connectivity testing using SQLAlchemy.""" | ||
|  | ||
| def __init__(self): | ||
| super().__init__() | ||
| self.with_transient_exceptions(TimeoutError, ConnectionError, *ADDITIONAL_TRANSIENT_ERRORS) | ||
|  | ||
| def wait_until_ready(self, container: WaitStrategyTarget) -> None: | ||
| """Test database connectivity with retry logic until success or timeout.""" | ||
| if not hasattr(container, "get_connection_url"): | ||
| raise AttributeError(f"Container {container} must have a get_connection_url method") | ||
|  | ||
| try: | ||
| import sqlalchemy | ||
| except ImportError as e: | ||
| raise ImportError("SQLAlchemy is required for database containers") from e | ||
|  | ||
| def _test_connection() -> bool: | ||
| """Test database connection, returning True if successful.""" | ||
| engine = sqlalchemy.create_engine(container.get_connection_url()) | ||
| try: | ||
| with engine.connect(): | ||
| logger.info("Database connection successful") | ||
| return True | ||
| finally: | ||
| engine.dispose() | ||
|  | ||
| result = self._poll(_test_connection) | ||
| if not result: | ||
| raise TimeoutError(f"Database connection failed after {self._startup_timeout}s timeout") | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import logging | ||
| from typing import Any, Optional | ||
| from urllib.parse import quote, urlencode | ||
|  | ||
| from testcontainers.core.container import DockerContainer | ||
| from testcontainers.core.exceptions import ContainerStartException | ||
| from testcontainers.core.waiting_utils import WaitStrategy | ||
|  | ||
| logger = logging.getLogger(__name__) | ||
|  | ||
|  | ||
| class SqlContainer(DockerContainer): | ||
| """ | ||
| Generic SQL database container providing common functionality. | ||
| This class can serve as a base for database-specific container implementations. | ||
| It provides connection management, URL construction, and basic lifecycle methods. | ||
| Database connection readiness is automatically handled by the provided wait strategy. | ||
| Note: `SqlConnectWaitStrategy` from `sql_utils` is a provided wait strategy for SQL databases. | ||
| """ | ||
|  | ||
| def __init__(self, image: str, wait_strategy: WaitStrategy, **kwargs): | ||
| """ | ||
| Initialize SqlContainer with optional wait strategy. | ||
| Args: | ||
| image: Docker image name | ||
| wait_strategy: Wait strategy for SQL database connectivity | ||
| **kwargs: Additional arguments passed to DockerContainer | ||
| """ | ||
| super().__init__(image, **kwargs) | ||
| self.wait_strategy = wait_strategy | ||
|         
                  Tranquility2 marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
|  | ||
| def _create_connection_url( | ||
|         
                  Tranquility2 marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
| self, | ||
| dialect: str, | ||
| username: str, | ||
| password: str, | ||
| host: Optional[str] = None, | ||
| port: Optional[int] = None, | ||
| dbname: Optional[str] = None, | ||
| query_params: Optional[dict[str, str]] = None, | ||
| **kwargs: Any, | ||
| ) -> str: | ||
| """ | ||
| Create a database connection URL. | ||
| Args: | ||
| dialect: Database dialect (e.g., 'postgresql', 'mysql') | ||
| username: Database username | ||
| password: Database password | ||
| host: Database host (defaults to container host) | ||
| port: Database port | ||
| dbname: Database name | ||
| query_params: Additional query parameters for the URL | ||
| **kwargs: Additional parameters (checked for deprecated usage) | ||
| Returns: | ||
| str: Formatted database connection URL | ||
| Raises: | ||
| ValueError: If unexpected arguments are provided or required parameters are missing | ||
| ContainerStartException: If container is not started | ||
| """ | ||
|  | ||
| if self._container is None: | ||
| raise ContainerStartException("Container has not been started") | ||
|  | ||
| # Validate required parameters | ||
| if not dialect: | ||
|         
                  Tranquility2 marked this conversation as resolved.
              Outdated
          
            Show resolved
            Hide resolved | ||
| raise ValueError("Database dialect is required") | ||
| if not username: | ||
| raise ValueError("Database username is required") | ||
| if port is None: | ||
| raise ValueError("Database port is required") | ||
|  | ||
| host = host or self.get_container_host_ip() | ||
| exposed_port = self.get_exposed_port(port) | ||
|  | ||
| # Safely quote password to handle special characters | ||
| quoted_password = quote(password, safe="") | ||
| quoted_username = quote(username, safe="") | ||
|  | ||
| # Build base URL | ||
| url = f"{dialect}://{quoted_username}:{quoted_password}@{host}:{exposed_port}" | ||
|  | ||
| # Add database name if provided | ||
| if dbname: | ||
| quoted_dbname = quote(dbname, safe="") | ||
| url = f"{url}/{quoted_dbname}" | ||
|  | ||
| # Add query parameters if provided | ||
| if query_params: | ||
| query_string = urlencode(query_params) | ||
| url = f"{url}?{query_string}" | ||
|  | ||
| return url | ||
|  | ||
| def start(self) -> "SqlContainer": | ||
| """ | ||
| Start the database container and perform initialization. | ||
| Returns: | ||
| SqlContainer: Self for method chaining | ||
| Raises: | ||
| ContainerStartException: If container fails to start | ||
| Exception: If configuration, seed transfer, or connection fails | ||
| """ | ||
| logger.info(f"Starting database container: {self.image}") | ||
|  | ||
| try: | ||
| self._configure() | ||
| self.waiting_for(self.wait_strategy) | ||
| super().start() | ||
| self._transfer_seed() | ||
| logger.info("Database container started successfully") | ||
| except Exception as e: | ||
| logger.error(f"Failed to start database container: {e}") | ||
| raise | ||
|  | ||
| return self | ||
|  | ||
| def _configure(self) -> None: | ||
| """ | ||
| Configure the database container before starting. | ||
| Raises: | ||
| NotImplementedError: Must be implemented by subclasses | ||
| """ | ||
| raise NotImplementedError("Subclasses must implement _configure()") | ||
|         
                  Tranquility2 marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
|  | ||
| def _transfer_seed(self) -> None: | ||
| """ | ||
| Transfer seed data to the database container. | ||
| This method can be overridden by subclasses to provide | ||
| database-specific seeding functionality. | ||
| """ | ||
| logger.debug("No seed data to transfer") | ||
|  | ||
| def get_connection_url(self) -> str: | ||
| """ | ||
| Get the database connection URL. | ||
| Returns: | ||
| str: Database connection URL | ||
| Raises: | ||
| NotImplementedError: Must be implemented by subclasses | ||
| """ | ||
| raise NotImplementedError("Subclasses must implement get_connection_url()") | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this have a default implementation to just call  There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SimpleSqlContainer is just a test, are you sure this is the default for all SQL related implementations? | ||
Uh oh!
There was an error while loading. Please reload this page.