Skip to content

Commit 76a8f53

Browse files
authored
Merge pull request #241 from finos/fix/sql_strategy
fix(storage): repair SQL/DynamoDB strategies + add cross-backend contract tests
2 parents d022dac + 63d9a7b commit 76a8f53

11 files changed

Lines changed: 411 additions & 21 deletions

File tree

src/orb/domain/base/domain_interfaces.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from abc import ABC, abstractmethod
66
from typing import Any, Generic, Optional, Protocol, TypeVar
77

8-
from .entity import AggregateRoot, Entity
8+
from .entity import AggregateRoot
99

1010
T = TypeVar("T") # Generic type for domain entities/aggregates
1111
A = TypeVar("A", bound=AggregateRoot)
@@ -90,18 +90,6 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
9090
"""Exit the unit of work context."""
9191
...
9292

93-
def register_new(self, entity: Entity) -> None:
94-
"""Register a new entity."""
95-
...
96-
97-
def register_dirty(self, entity: Entity) -> None:
98-
"""Register a dirty entity."""
99-
...
100-
101-
def register_removed(self, entity: Entity) -> None:
102-
"""Register a removed entity."""
103-
...
104-
10593
@abstractmethod
10694
def begin(self) -> None:
10795
"""Begin a new transaction."""

src/orb/infrastructure/storage/components/sql_query_builder.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def __init__(self, table_name: str, columns: dict[str, str]) -> None:
3333
table_name: Name of the database table
3434
columns: Dictionary of column names and types
3535
"""
36+
super().__init__()
3637
self.table_name = table_name
3738
self.columns = columns
3839
self.logger = get_logger(__name__)
@@ -42,6 +43,64 @@ def __init__(self, table_name: str, columns: dict[str, str]) -> None:
4243
for column in columns:
4344
self._validate_identifier(column)
4445

46+
def build_query(self, query_spec: dict[str, Any]) -> str:
47+
"""
48+
Build a SQL query from specification (implements QueryManager interface).
49+
50+
Dispatches to existing build_* methods based on query_spec["type"].
51+
"""
52+
query_type = query_spec.get("type", "").upper()
53+
if query_type == "CREATE_TABLE":
54+
return self.build_create_table()
55+
if query_type == "SELECT_ALL":
56+
return self.build_select_all()
57+
if query_type == "SELECT_BY_ID":
58+
id_column = query_spec.get("id_column", "id")
59+
sql, _ = self.build_select_by_id(id_column)
60+
return sql
61+
if query_type == "INSERT":
62+
sql, _ = self.build_insert(query_spec.get("data", {}))
63+
return sql
64+
if query_type == "UPDATE":
65+
sql, _ = self.build_update(
66+
query_spec.get("data", {}),
67+
query_spec.get("id_column", "id"),
68+
query_spec.get("entity_id", ""),
69+
)
70+
return sql
71+
if query_type == "DELETE":
72+
sql, _ = self.build_delete(query_spec.get("id_column", "id"))
73+
return sql
74+
raise ValueError(f"Unknown query type: {query_type}")
75+
76+
def execute_query(self, query: str, parameters: Optional[dict[str, Any]] = None) -> Any:
77+
"""
78+
Reject direct execution — execution belongs to SQLConnectionManager.
79+
80+
SQLQueryBuilder is build-only; this method exists to satisfy the
81+
QueryManager interface but should not be used. Callers must execute
82+
the built query via SQLConnectionManager.execute_query.
83+
"""
84+
raise NotImplementedError(
85+
"SQLQueryBuilder does not execute queries; use SQLConnectionManager.execute_query"
86+
)
87+
88+
def validate_query(self, query: str) -> bool:
89+
"""
90+
Validate a SQL query string (implements QueryManager interface).
91+
92+
Performs basic structural validation: non-empty, contains a known
93+
SQL keyword, and no unbalanced quotes.
94+
"""
95+
if not query or not query.strip():
96+
return False
97+
keywords = ("SELECT", "INSERT", "UPDATE", "DELETE", "CREATE")
98+
if not any(kw in query.upper() for kw in keywords):
99+
return False
100+
if query.count("'") % 2 != 0:
101+
return False
102+
return True
103+
45104
def _validate_identifier(self, identifier: str) -> None:
46105
"""
47106
Validate SQL identifier against whitelist pattern.

src/orb/infrastructure/storage/sql/registration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,12 @@ def create_sql_unit_of_work(config: Any) -> Any:
118118
echo=getattr(sql_config, "echo", False),
119119
)
120120

121-
return SQLUnitOfWork(engine) # type: ignore[abstract]
121+
return SQLUnitOfWork(engine)
122122
else:
123123
# For testing or other scenarios - assume it's a dict with connection info
124124
connection_string = config.get("connection_string", "sqlite:///data/test.db")
125125
engine = create_engine(connection_string)
126-
return SQLUnitOfWork(engine) # type: ignore[abstract]
126+
return SQLUnitOfWork(engine)
127127

128128

129129
def register_sql_storage() -> None:

src/orb/infrastructure/storage/sql/strategy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self, config: dict[str, Any], table_name: str, columns: dict[str, s
4343

4444
# Initialize components
4545
self.connection_manager = SQLConnectionManager(config)
46-
self.query_builder = SQLQueryBuilder(table_name, columns) # type: ignore[abstract]
46+
self.query_builder = SQLQueryBuilder(table_name, columns)
4747
self.serializer = SQLSerializer(id_column=self._get_id_column())
4848
self.lock_manager = LockManager("simple") # Simple lock for SQL
4949

@@ -265,7 +265,7 @@ def save_batch(self, entities: dict[str, dict[str, Any]]) -> None:
265265

266266
with self.connection_manager.get_session() as session:
267267
for serialized_data in serialized_list:
268-
session.execute(query, serialized_data)
268+
session.execute(text(query), serialized_data)
269269
session.commit()
270270

271271
self.logger.debug("Saved batch of %s entities", len(entities))

src/orb/providers/aws/storage/components/dynamodb_transaction_manager.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from contextlib import contextmanager
44
from typing import Any, Callable, Optional
55

6+
from boto3.dynamodb.types import TypeSerializer
67
from botocore.exceptions import ClientError
78

89
from orb.infrastructure.storage.components.transaction_manager import (
@@ -30,6 +31,11 @@ def __init__(self, client_manager) -> None:
3031
self.client_manager = client_manager
3132
self.transaction_items: list[dict[str, Any]] = []
3233
self.max_transaction_items = 25 # DynamoDB limit
34+
# transact_write_items uses the low-level Client API, which requires
35+
# AttributeValue-tagged dicts (e.g. {"id": {"S": "abc"}}). The
36+
# higher-level Resource API (used elsewhere) accepts plain dicts;
37+
# this serializer converts at the boundary.
38+
self._serializer = TypeSerializer()
3339

3440
def begin_transaction(self) -> None:
3541
"""Begin a new DynamoDB transaction."""
@@ -60,7 +66,8 @@ def add_put_item(
6066
if len(self.transaction_items) >= self.max_transaction_items:
6167
raise RuntimeError(f"Transaction cannot exceed {self.max_transaction_items} items")
6268

63-
put_request = {"Put": {"TableName": table_name, "Item": item}}
69+
serialized_item = {k: self._serializer.serialize(v) for k, v in item.items()}
70+
put_request = {"Put": {"TableName": table_name, "Item": serialized_item}}
6471

6572
if condition_expression:
6673
put_request["Put"]["ConditionExpression"] = condition_expression
@@ -92,12 +99,16 @@ def add_update_item(
9299
if len(self.transaction_items) >= self.max_transaction_items:
93100
raise RuntimeError(f"Transaction cannot exceed {self.max_transaction_items} items")
94101

102+
serialized_key = {k: self._serializer.serialize(v) for k, v in key.items()}
103+
serialized_values = {
104+
k: self._serializer.serialize(v) for k, v in expression_attribute_values.items()
105+
}
95106
update_request = {
96107
"Update": {
97108
"TableName": table_name,
98-
"Key": key,
109+
"Key": serialized_key,
99110
"UpdateExpression": update_expression,
100-
"ExpressionAttributeValues": expression_attribute_values,
111+
"ExpressionAttributeValues": serialized_values,
101112
}
102113
}
103114

@@ -127,7 +138,8 @@ def add_delete_item(
127138
if len(self.transaction_items) >= self.max_transaction_items:
128139
raise RuntimeError(f"Transaction cannot exceed {self.max_transaction_items} items")
129140

130-
delete_request = {"Delete": {"TableName": table_name, "Key": key}}
141+
serialized_key = {k: self._serializer.serialize(v) for k, v in key.items()}
142+
delete_request = {"Delete": {"TableName": table_name, "Key": serialized_key}}
131143

132144
if condition_expression:
133145
delete_request["Delete"]["ConditionExpression"] = condition_expression

tests/integration/storage/__init__.py

Whitespace-only changes.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Fixtures for storage strategy contract tests.
2+
3+
Provides parameterised `storage_strategy` and `unit_of_work` fixtures so
4+
each backend (JSON, SQL/SQLite, DynamoDB/moto) runs the same contract tests.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from contextlib import contextmanager
10+
from typing import Iterator
11+
12+
import pytest
13+
14+
try:
15+
from moto import mock_aws as _moto_mock_aws
16+
17+
HAS_MOTO = True
18+
except ImportError:
19+
HAS_MOTO = False
20+
_moto_mock_aws = None
21+
22+
23+
@contextmanager
24+
def _mock_aws():
25+
if _moto_mock_aws is None:
26+
yield
27+
return
28+
with _moto_mock_aws():
29+
yield
30+
31+
32+
_ENTITY_TABLE = "entities"
33+
_ENTITY_COLUMNS = {"id": "TEXT PRIMARY KEY", "data": "TEXT", "name": "TEXT"}
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Strategy fixtures (low-level: SQLStorageStrategy / JSONStorageStrategy / DynamoDB)
38+
# ---------------------------------------------------------------------------
39+
40+
41+
@pytest.fixture
42+
def json_strategy(tmp_path):
43+
from orb.infrastructure.storage.json.strategy import JSONStorageStrategy
44+
45+
file_path = tmp_path / "entities.json"
46+
return JSONStorageStrategy(file_path=str(file_path), entity_type="entities")
47+
48+
49+
@pytest.fixture
50+
def sql_strategy():
51+
from orb.infrastructure.storage.sql.strategy import SQLStorageStrategy
52+
53+
return SQLStorageStrategy(
54+
config={"type": "sqlite", "name": ":memory:"},
55+
table_name=_ENTITY_TABLE,
56+
columns=_ENTITY_COLUMNS,
57+
)
58+
59+
60+
@pytest.fixture
61+
def dynamodb_strategy() -> Iterator:
62+
if not HAS_MOTO:
63+
pytest.skip("moto not installed")
64+
65+
from orb.providers.aws.storage.strategy import DynamoDBStorageStrategy
66+
67+
with _mock_aws():
68+
# aws_client=None forces internal boto3 session, which moto intercepts.
69+
from orb.infrastructure.adapters.logging_adapter import LoggingAdapter
70+
71+
strategy = DynamoDBStorageStrategy(
72+
logger=LoggingAdapter("test.dynamo"),
73+
aws_client=None,
74+
region="us-east-1",
75+
table_name=_ENTITY_TABLE,
76+
)
77+
yield strategy
78+
79+
80+
@pytest.fixture(params=["json", "sql", "dynamodb"])
81+
def storage_strategy(request, json_strategy, sql_strategy, dynamodb_strategy):
82+
"""Parameterised strategy fixture used by contract tests."""
83+
return {
84+
"json": json_strategy,
85+
"sql": sql_strategy,
86+
"dynamodb": dynamodb_strategy,
87+
}[request.param]
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# UnitOfWork fixtures
92+
# ---------------------------------------------------------------------------
93+
94+
95+
@pytest.fixture
96+
def json_uow(tmp_path):
97+
from orb.infrastructure.storage.json.unit_of_work import JSONUnitOfWork
98+
99+
return JSONUnitOfWork(data_dir=str(tmp_path))
100+
101+
102+
@pytest.fixture
103+
def sql_uow():
104+
from sqlalchemy import create_engine
105+
106+
from orb.infrastructure.storage.sql.unit_of_work import SQLUnitOfWork
107+
108+
engine = create_engine("sqlite:///:memory:")
109+
return SQLUnitOfWork(engine)
110+
111+
112+
@pytest.fixture
113+
def dynamodb_uow():
114+
if not HAS_MOTO:
115+
pytest.skip("moto not installed")
116+
117+
from orb.providers.aws.storage.unit_of_work import DynamoDBUnitOfWork
118+
119+
from orb.infrastructure.adapters.logging_adapter import LoggingAdapter
120+
121+
with _mock_aws():
122+
uow = DynamoDBUnitOfWork(
123+
aws_client=None,
124+
logger=LoggingAdapter("test.dynamo.uow"),
125+
region="us-east-1",
126+
)
127+
yield uow
128+
129+
130+
@pytest.fixture(params=["json", "sql", "dynamodb"])
131+
def unit_of_work(request, json_uow, sql_uow, dynamodb_uow):
132+
"""Parameterised UoW fixture used by UoW contract tests."""
133+
return {
134+
"json": json_uow,
135+
"sql": sql_uow,
136+
"dynamodb": dynamodb_uow,
137+
}[request.param]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Aurora SQL storage factory tests.
2+
3+
Aurora's storage strategy is SQLAlchemy-based; the strategy and UoW
4+
themselves are exercised by SQL contract tests against sqlite. These
5+
tests verify the Aurora factory wires its connection string and engine
6+
into a working SQLUnitOfWork without contacting AWS.
7+
"""
8+
9+
import pytest
10+
11+
12+
@pytest.mark.integration
13+
class TestAuroraFactory:
14+
def test_create_aurora_strategy_with_simple_config(self):
15+
from orb.providers.aws.storage.registration import create_aurora_strategy
16+
from orb.infrastructure.storage.sql.strategy import SQLStorageStrategy
17+
18+
class _Cfg:
19+
connection_string = "sqlite:///:memory:"
20+
21+
strategy = create_aurora_strategy(_Cfg())
22+
assert isinstance(strategy, SQLStorageStrategy)
23+
24+
def test_create_aurora_unit_of_work_with_dict_config(self):
25+
from orb.providers.aws.storage.registration import create_aurora_unit_of_work
26+
from orb.infrastructure.storage.sql.unit_of_work import SQLUnitOfWork
27+
28+
uow = create_aurora_unit_of_work({"connection_string": "sqlite:///:memory:"})
29+
assert isinstance(uow, SQLUnitOfWork)
30+
assert uow.machines is not None
31+
assert uow.requests is not None
32+
assert uow.templates is not None

0 commit comments

Comments
 (0)