Skip to content

Commit 6e02196

Browse files
authored
refactor(database): type-check immutable filter builders (#360)
* refactor(database): keep filter state on concrete builders * test(database): reject invalid filter mutations
1 parent fa3b41f commit 6e02196

5 files changed

Lines changed: 45 additions & 20 deletions

File tree

‎maintainers/quality-policy.lock.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,8 @@
272272
"src/volcano_sdk/realtime.py",
273273
"src/volcano_sdk/auth.py",
274274
"src/volcano_sdk/storage.py",
275-
"src/volcano_sdk/functions.py"
275+
"src/volcano_sdk/functions.py",
276+
"src/volcano_sdk/database.py"
276277
]
277278
},
278279
"mypy": {

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ type_check_command = [
426426
"src/volcano_sdk/auth.py",
427427
"src/volcano_sdk/storage.py",
428428
"src/volcano_sdk/functions.py",
429+
"src/volcano_sdk/database.py",
429430
]
430431
cache_invalidation_files = ["tests/**/*.py"]
431432
pytest_add_cli_args_test_selection = ["tests/unit"]

‎scripts/check_quality_policy.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from collections.abc import Iterable
1919

2020
GENERATED = "src/volcano_sdk/_generated"
21-
LOCK_SHA256 = "0929858d578e0b330eaaaedf739e16a0053aff1659604b4f8c0fd9cbc4d20b2e"
21+
LOCK_SHA256 = "217925dcf7067ed20a1f560c54d2fccfd47df28702ded83e9f4352fc69334651"
2222
TYPE_FIXTURES = {
2323
"tests/typing/contract_steps.py",
2424
"tests/typing/durable_callbacks.py",

‎src/volcano_sdk/database.py‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@
1919
_INVALID_DATABASE_ROWS = "Expected a list of database rows with string keys"
2020

2121

22+
def _is_object_mapping(value: object) -> TypeGuard[Mapping[object, object]]:
23+
return isinstance(value, Mapping)
24+
25+
26+
def _is_object_sequence(
27+
value: object,
28+
) -> TypeGuard[list[object] | tuple[object, ...]]:
29+
return isinstance(value, (list, tuple))
30+
31+
2232
def _is_database_row(value: object) -> TypeGuard[dict[str, object]]:
2333
if not isinstance(value, dict):
2434
return False
@@ -54,12 +64,10 @@ def _snapshot_row(values: Mapping[str, JSONValue]) -> dict[str, JSONValue]:
5464

5565

5666
def _snapshot_filter_value(value: object) -> object:
57-
if isinstance(value, Mapping):
58-
mapping = cast("Mapping[object, object]", value)
59-
return {key: _snapshot_filter_value(item) for key, item in mapping.items()}
60-
if isinstance(value, (list, tuple)):
61-
sequence = cast("list[object] | tuple[object, ...]", value)
62-
return [_snapshot_filter_value(item) for item in sequence]
67+
if _is_object_mapping(value):
68+
return {key: _snapshot_filter_value(item) for key, item in value.items()}
69+
if _is_object_sequence(value):
70+
return [_snapshot_filter_value(item) for item in value]
6371
return value
6472

6573

@@ -86,10 +94,8 @@ class _OrderClause(TypedDict):
8694
class FilterBuilder:
8795
"""Shared immutable filters for database operations."""
8896

89-
_filters: tuple[_FilterCondition, ...] = ()
90-
91-
def _with_filters(self, filters: tuple[_FilterCondition, ...]) -> Self:
92-
del filters
97+
def _append_filter(self, condition: _FilterCondition) -> Self:
98+
del condition
9399
raise NotImplementedError
94100

95101
def eq(self, column: str, value: object) -> Self:
@@ -208,7 +214,7 @@ def _filter(self, column: str, operator: str, value: object) -> Self:
208214
"operator": operator,
209215
"value": _snapshot_filter_value(value),
210216
}
211-
return self._with_filters((*self._filters, condition))
217+
return self._append_filter(condition)
212218

213219

214220
@dataclass(frozen=True, slots=True)
@@ -319,8 +325,8 @@ def offset(self, count: int) -> QueryBuilder:
319325
return replace(self, _offset=count)
320326

321327
@override
322-
def _with_filters(self, filters: tuple[_FilterCondition, ...]) -> QueryBuilder:
323-
return replace(self, _filters=filters)
328+
def _append_filter(self, condition: _FilterCondition) -> QueryBuilder:
329+
return replace(self, _filters=(*self._filters, condition))
324330

325331
def _request_body(self) -> dict[str, object]:
326332
body: dict[str, object] = {"table": self._table}
@@ -402,8 +408,8 @@ class UpdateBuilder(FilterBuilder):
402408
_filters: tuple[_FilterCondition, ...] = ()
403409

404410
@override
405-
def _with_filters(self, filters: tuple[_FilterCondition, ...]) -> UpdateBuilder:
406-
return replace(self, _filters=filters)
411+
def _append_filter(self, condition: _FilterCondition) -> UpdateBuilder:
412+
return replace(self, _filters=(*self._filters, condition))
407413

408414
def execute(self) -> list[dict[str, object]]:
409415
"""Update matching rows and return them.
@@ -440,8 +446,8 @@ class DeleteBuilder(FilterBuilder):
440446
_filters: tuple[_FilterCondition, ...] = ()
441447

442448
@override
443-
def _with_filters(self, filters: tuple[_FilterCondition, ...]) -> DeleteBuilder:
444-
return replace(self, _filters=filters)
449+
def _append_filter(self, condition: _FilterCondition) -> DeleteBuilder:
450+
return replace(self, _filters=(*self._filters, condition))
445451

446452
def execute(self) -> list[dict[str, object]]:
447453
"""Delete matching rows and return them.

‎tests/unit/test_database_snapshots.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,21 @@ def handle(request: httpx.Request) -> httpx.Response:
5252

5353
def test_filter_base_requires_a_concrete_builder() -> None:
5454
with pytest.raises(NotImplementedError):
55-
_ = FilterBuilder()._with_filters(())
55+
_ = FilterBuilder()._append_filter(
56+
{"column": "id", "operator": "eq", "value": 1}
57+
)
58+
59+
60+
@pytest.mark.parametrize("value", [True, False])
61+
def test_identity_filter_preserves_boolean_values(value: object) -> None:
62+
requests: list[httpx.Request] = []
63+
64+
def handle(request: httpx.Request) -> httpx.Response:
65+
requests.append(request)
66+
return rows_response()
67+
68+
query = make_client(handle).database("db").from_("items").is_("enabled", value)
69+
assert query.execute() == [{"id": 1}]
70+
assert json.loads(requests[0].content)["filters"] == [
71+
{"column": "enabled", "operator": "is", "value": value}
72+
]

0 commit comments

Comments
 (0)