Skip to content

Commit 43172d8

Browse files
feat(tests): add Starrocks TestContainer support
1 parent c01afd3 commit 43172d8

8 files changed

Lines changed: 323 additions & 136 deletions

File tree

.env.test

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ POSTGRES_SQLALCHEMY__DATABASE=test_db
3939
POSTGRES_SQLALCHEMY__USERNAME=test_user
4040
POSTGRES_SQLALCHEMY__PASSWORD=test_password
4141

42+
# StarRocks Configuration
43+
STARROCKS__IMAGE=starrocks/allin1-ubuntu:4.0.3
44+
STARROCKS_SQLALCHEMY__DRIVER_NAME=starrocks
45+
STARROCKS_SQLALCHEMY__HOST=localhost
46+
STARROCKS_SQLALCHEMY__PORT=9030
47+
STARROCKS_SQLALCHEMY__DATABASE=test_db
48+
STARROCKS_SQLALCHEMY__USERNAME=root
49+
STARROCKS_SQLALCHEMY__PASSWORD=
50+
4251
# Keycloak Configuration
4352
KEYCLOAK__IMAGE=quay.io/keycloak/keycloak:26.4.5
4453
KEYCLOAK__SERVER_URL=http://localhost:8080

archipy/adapters/starrocks/sqlalchemy/session_managers.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from typing import override
22

33
from sqlalchemy import URL
4+
from sqlalchemy.dialects.postgresql import UUID as PostgresUUID
45
from sqlalchemy.exc import SQLAlchemyError
6+
from sqlalchemy.sql.functions import GenericFunction
7+
from starrocks.dialect import StarRocksSQLCompiler, StarRocksTypeCompiler
58

69
from archipy.adapters.base.sqlalchemy.session_managers import (
710
AsyncBaseSQLAlchemySessionManager,
@@ -13,6 +16,52 @@
1316
from archipy.models.errors import DatabaseConnectionError
1417

1518

19+
# Patch the StarRocks type compiler to map UUID to VARCHAR at module level
20+
# This ensures the patch is applied before any engines are created
21+
def _patch_starrocks_uuid_mapping() -> None:
22+
"""Patch the StarRocks type compiler to map UUID to VARCHAR.
23+
24+
StarRocks doesn't support UUID type natively, so we need to map it to VARCHAR(36).
25+
This is patched at module level to ensure it's applied before engine creation.
26+
"""
27+
28+
def visit_UUID(self: StarRocksTypeCompiler, type_: PostgresUUID, **kw: object) -> str: # noqa: ARG001
29+
"""Map PostgreSQL UUID to VARCHAR(36) for StarRocks."""
30+
return "VARCHAR(36)"
31+
32+
# Patch the type compiler class
33+
StarRocksTypeCompiler.visit_UUID = visit_UUID # ty:ignore[invalid-assignment]
34+
35+
36+
def _patch_starrocks_now_function() -> None:
37+
"""Patch the StarRocks SQL compiler to map func.now() to CURRENT_TIMESTAMP.
38+
39+
StarRocks doesn't support now() function, it requires CURRENT_TIMESTAMP instead.
40+
This is patched at module level to ensure it's applied before engine creation.
41+
"""
42+
# Store original visit_function if it exists
43+
original_visit_function = getattr(StarRocksSQLCompiler, "visit_function", None)
44+
45+
def visit_function(self: StarRocksSQLCompiler, func_: GenericFunction, **kw: object) -> str:
46+
"""Map func.now() to CURRENT_TIMESTAMP for StarRocks."""
47+
# Check if this is func.now()
48+
if func_.name == "now":
49+
return "CURRENT_TIMESTAMP"
50+
# For other functions, use the original handler if it exists
51+
if original_visit_function:
52+
return original_visit_function(self, func_, **kw)
53+
# Fallback to default behavior
54+
return f"{func_.name}()"
55+
56+
# Patch the SQL compiler class
57+
StarRocksSQLCompiler.visit_function = visit_function # ty:ignore[invalid-assignment]
58+
59+
60+
# Apply the patches when the module is imported
61+
_patch_starrocks_uuid_mapping()
62+
_patch_starrocks_now_function()
63+
64+
1665
class StarRocksSQlAlchemySessionManager(BaseSQLAlchemySessionManager[StarRocksSQLAlchemyConfig], metaclass=Singleton):
1766
"""Synchronous SQLAlchemy session manager for StarRocks.
1867
@@ -122,18 +171,27 @@ def _get_database_name(self) -> str:
122171
def _create_url(self, configs: StarRocksSQLAlchemyConfig) -> URL:
123172
"""Create an async StarRocks connection URL.
124173
174+
For async operations, StarRocks requires an async driver (mysql+aiomysql)
175+
instead of the sync driver (mysql+pymysql).
176+
125177
Args:
126178
configs: StarRocks configuration.
127179
128180
Returns:
129-
A SQLAlchemy URL object for StarRocks.
181+
A SQLAlchemy URL object for StarRocks with async driver.
130182
131183
Raises:
132184
DatabaseConnectionError: If there's an error creating the URL.
133185
"""
134186
try:
187+
# For async operations, use mysql+aiomysql driver
188+
# If the driver is mysql+pymysql or starrocks, replace with mysql+aiomysql
189+
async_driver = configs.DRIVER_NAME
190+
if async_driver in ("mysql+pymysql", "starrocks", "mysql"):
191+
async_driver = "mysql+aiomysql"
192+
135193
return URL.create(
136-
drivername=configs.DRIVER_NAME,
194+
drivername=async_driver,
137195
username=configs.USERNAME,
138196
password=configs.PASSWORD,
139197
host=configs.HOST,
@@ -144,3 +202,15 @@ def _create_url(self, configs: StarRocksSQLAlchemyConfig) -> URL:
144202
raise DatabaseConnectionError(
145203
database=self._get_database_name(),
146204
) from e
205+
206+
@override
207+
def _get_connect_args(self) -> dict:
208+
"""Return connection arguments for async StarRocks to ensure proper transaction support.
209+
210+
StarRocks (using MySQL protocol) requires autocommit to be explicitly disabled
211+
to ensure transactions work properly with rollback support.
212+
213+
Returns:
214+
A dictionary with autocommit=False to ensure transaction support.
215+
"""
216+
return {"autocommit": False}

archipy/configs/config_template.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Literal, Self
1212
from urllib.parse import urlparse
1313

14-
from pydantic import BaseModel, Field, PostgresDsn, SecretStr, model_validator
14+
from pydantic import BaseModel, Field, PostgresDsn, SecretStr, field_validator, model_validator
1515

1616
from archipy.models.errors import FailedPreconditionError, InvalidArgumentError
1717

@@ -541,9 +541,39 @@ class StarRocksSQLAlchemyConfig(SQLAlchemyConfig):
541541
"""Configuration settings for Starrocks SQLAlchemy ORM.
542542
543543
Extends SQLAlchemyConfig with Starrocks-specific settings.
544+
545+
Note: StarRocks only supports READ COMMITTED isolation level.
544546
"""
545547

548+
DRIVER_NAME: str = Field(default="starrocks", description="StarRocks driver name")
546549
CATALOG: str | None = Field(default=None, description="Starrocks catalog name")
550+
ISOLATION_LEVEL: str = Field(
551+
default="READ COMMITTED",
552+
description="Transaction isolation level (StarRocks only supports READ COMMITTED)",
553+
)
554+
555+
@field_validator("ISOLATION_LEVEL")
556+
@classmethod
557+
def validate_isolation_level(cls, v: str) -> str:
558+
"""Validate that isolation level is READ COMMITTED for StarRocks.
559+
560+
Args:
561+
v: The isolation level value to validate.
562+
563+
Returns:
564+
The validated isolation level.
565+
566+
Raises:
567+
ValueError: If the isolation level is not READ COMMITTED.
568+
"""
569+
# Normalize the value (handle case variations and underscores)
570+
normalized = v.upper().replace("_", " ").strip()
571+
if normalized != "READ COMMITTED":
572+
raise ValueError(
573+
f"StarRocks only supports READ COMMITTED isolation level. Got: {v}. "
574+
"StarRocks does not support other isolation levels like REPEATABLE READ or SERIALIZABLE.",
575+
)
576+
return "READ COMMITTED"
547577

548578

549579
class PrometheusConfig(BaseModel):

features/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class TestConfig(BaseConfig):
3434
MINIO__IMAGE: str
3535
KEYCLOAK__IMAGE: str
3636
SCYLLADB__IMAGE: str
37+
STARROCKS__IMAGE: str
3738
TESTCONTAINERS_RYUK_CONTAINER_IMAGE: str | None = None
3839

3940
def __init__(self, **kwargs) -> None:

features/test_containers.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
PostgresSQLAlchemyConfig,
2020
RedisConfig,
2121
ScyllaDBConfig,
22+
StarRocksSQLAlchemyConfig,
2223
)
2324
from archipy.helpers.metaclasses.singleton import Singleton
2425

@@ -33,6 +34,7 @@
3334
"needs-keycloak": "keycloak",
3435
"needs-redis": "redis",
3536
"needs-scylladb": "scylladb",
37+
"needs-starrocks": "starrocks",
3638
}
3739

3840

@@ -642,3 +644,83 @@ def stop(self) -> None:
642644
self.port = None
643645

644646
logger.info("ScyllaDB container stopped")
647+
648+
649+
@ContainerManager.register("starrocks")
650+
class StarRocksTestContainer(metaclass=Singleton, thread_safe=True):
651+
"""Test container for StarRocks."""
652+
653+
def __init__(self, config: StarRocksSQLAlchemyConfig | None = None, image: str | None = None) -> None:
654+
"""Initialize StarRocks test container.
655+
656+
Args:
657+
config (StarRocksSQLAlchemyConfig | None): Configuration for StarRocks. Defaults to None.
658+
image (str | None): Docker image to use. Defaults to None (uses STARROCKS__IMAGE from config).
659+
"""
660+
self.name = "starrocks"
661+
self.config = config or BaseConfig.global_config().STARROCKS_SQLALCHEMY
662+
self.image = image or BaseConfig.global_config().STARROCKS__IMAGE
663+
self._is_running: bool = False
664+
665+
# Container properties
666+
self.host: str | None = None
667+
self.port: int | None = None
668+
self.database: str | None = self.config.DATABASE
669+
self.username: str | None = self.config.USERNAME
670+
self.password: str | None = self.config.PASSWORD
671+
672+
# Set up the container
673+
self._container = DockerContainer(self.image)
674+
# Expose ports: 9030 (MySQL protocol), 8030 (FE HTTP), 8040 (BE HTTP)
675+
# These will be mapped to random available host ports automatically
676+
self._container.with_exposed_ports(9030, 8030, 8040)
677+
678+
def start(self) -> DockerContainer:
679+
"""Start the StarRocks container.
680+
681+
Returns:
682+
DockerContainer: The running container instance.
683+
"""
684+
if self._is_running:
685+
return self._container
686+
687+
# Start the container
688+
self._container.start()
689+
690+
# Wait for StarRocks to be ready
691+
# StarRocks logs "cluster initialization DONE!" when the cluster is fully initialized
692+
# This appears after "FE service query port:9030 is alive!" and indicates readiness
693+
wait_for_logs(self._container, "Enjoy the journey to StarRocks blazing-fast lake-house engine!", timeout=120)
694+
695+
self._is_running = True
696+
697+
# Get dynamic host and random port (container port 9030 mapped to random host port)
698+
self.host = self._container.get_container_host_ip()
699+
# get_exposed_port returns the random host port that maps to container port 9030
700+
self.port = int(self._container.get_exposed_port(9030))
701+
702+
# Update global config with actual container endpoint
703+
global_config = BaseConfig.global_config()
704+
global_config.STARROCKS_SQLALCHEMY.HOST = self.host
705+
global_config.STARROCKS_SQLALCHEMY.PORT = self.port
706+
707+
logger.info("StarRocks container started on %s:%s", self.host, self.port)
708+
709+
return self._container
710+
711+
def stop(self) -> None:
712+
"""Stop the StarRocks container."""
713+
if not self._is_running:
714+
return
715+
716+
if self._container:
717+
self._container.stop()
718+
719+
self._container = None
720+
self._is_running = False
721+
722+
# Reset container properties
723+
self.host = None
724+
self.port = None
725+
726+
logger.info("StarRocks container stopped")

features/test_entity.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ class TestEntity(UpdatableDeletableEntity):
2929

3030
__tablename__ = "test_entities"
3131

32+
__table_args__ = {
33+
"comment": "Test entity table",
34+
"starrocks_primary_key": "test_uuid",
35+
"starrocks_distributed_by": "HASH(test_uuid) BUCKETS 10",
36+
"starrocks_properties": {"replication_num": "1"},
37+
}
38+
3239
test_uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
3340
pk_uuid = Synonym("test_uuid")
3441

@@ -85,6 +92,13 @@ class TestManagerEntity(UpdatableManagerEntity):
8592

8693
__tablename__ = "test_manager_entities"
8794

95+
__table_args__ = {
96+
"comment": "Test manager entity table",
97+
"starrocks_primary_key": "test_uuid",
98+
"starrocks_distributed_by": "HASH(test_uuid) BUCKETS 10",
99+
"starrocks_properties": {"replication_num": "1"},
100+
}
101+
88102
test_uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
89103
pk_uuid = Synonym("test_uuid")
90104

@@ -143,6 +157,13 @@ class TestAdminEntity(UpdatableAdminEntity):
143157

144158
__tablename__ = "test_admin_entities"
145159

160+
__table_args__ = {
161+
"comment": "Test admin entity table",
162+
"starrocks_primary_key": "test_uuid",
163+
"starrocks_distributed_by": "HASH(test_uuid) BUCKETS 10",
164+
"starrocks_properties": {"replication_num": "1"},
165+
}
166+
146167
test_uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
147168
pk_uuid = Synonym("test_uuid")
148169

@@ -202,6 +223,13 @@ class RelatedTestEntity(BaseEntity):
202223

203224
__tablename__ = "related_test_entities"
204225

226+
__table_args__ = {
227+
"comment": "Related test entity table",
228+
"starrocks_primary_key": "related_uuid",
229+
"starrocks_distributed_by": "HASH(related_uuid) BUCKETS 10",
230+
"starrocks_properties": {"replication_num": "1"},
231+
}
232+
205233
related_uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
206234
pk_uuid = Synonym("related_uuid")
207235

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ invalid-type-variable-constraints = "error"
360360
missing-argument = "error"
361361
missing-typed-dict-key = "error"
362362
no-matching-overload = "error"
363-
non-subscriptable = "error"
363+
not-subscriptable = "error"
364364
not-iterable = "error"
365365
override-of-final-method = "error"
366366
parameter-already-assigned = "error"

0 commit comments

Comments
 (0)