rowguard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rowguard/__init__.py +31 -0
- rowguard/adapters/__init__.py +6 -0
- rowguard/adapters/base.py +15 -0
- rowguard/adapters/sqlalchemy_row.py +46 -0
- rowguard/api.py +130 -0
- rowguard/cache.py +26 -0
- rowguard/diagnostics.py +15 -0
- rowguard/errors.py +59 -0
- rowguard/execution/__init__.py +6 -0
- rowguard/execution/processor.py +112 -0
- rowguard/execution/streaming.py +14 -0
- rowguard/execution/sync.py +152 -0
- rowguard/integrations/__init__.py +11 -0
- rowguard/integrations/sqlalchemy_core.py +28 -0
- rowguard/integrations/sqlalchemy_orm.py +5 -0
- rowguard/integrations/sqlmodel.py +5 -0
- rowguard/integrations/sqlrules.py +68 -0
- rowguard/planning/__init__.py +7 -0
- rowguard/planning/compiler.py +116 -0
- rowguard/planning/execution_plan.py +30 -0
- rowguard/planning/request.py +24 -0
- rowguard/plugins/__init__.py +0 -0
- rowguard/plugins/registry.py +13 -0
- rowguard/py.typed +0 -0
- rowguard/rejection/__init__.py +12 -0
- rowguard/rejection/base.py +14 -0
- rowguard/rejection/policies.py +40 -0
- rowguard/results/__init__.py +6 -0
- rowguard/results/query_result.py +45 -0
- rowguard/results/rejected_row.py +16 -0
- rowguard/statistics.py +19 -0
- rowguard/validation/__init__.py +6 -0
- rowguard/validation/base.py +20 -0
- rowguard/validation/pydantic.py +20 -0
- rowguard-0.1.0.dist-info/METADATA +174 -0
- rowguard-0.1.0.dist-info/RECORD +38 -0
- rowguard-0.1.0.dist-info/WHEEL +4 -0
- rowguard-0.1.0.dist-info/licenses/LICENSE +5 -0
rowguard/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from rowguard.api import execute, select, stream, validate_rows
|
|
2
|
+
from rowguard.errors import (
|
|
3
|
+
ConfigurationError,
|
|
4
|
+
QueryExecutionError,
|
|
5
|
+
RejectHandlerError,
|
|
6
|
+
RowAdaptationError,
|
|
7
|
+
RowGuardError,
|
|
8
|
+
RowValidationError,
|
|
9
|
+
)
|
|
10
|
+
from rowguard.results.query_result import QueryResult
|
|
11
|
+
from rowguard.results.rejected_row import RejectedRow
|
|
12
|
+
from rowguard.statistics import QueryStatistics
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ConfigurationError",
|
|
18
|
+
"QueryExecutionError",
|
|
19
|
+
"QueryResult",
|
|
20
|
+
"QueryStatistics",
|
|
21
|
+
"RejectHandlerError",
|
|
22
|
+
"RejectedRow",
|
|
23
|
+
"RowAdaptationError",
|
|
24
|
+
"RowGuardError",
|
|
25
|
+
"RowValidationError",
|
|
26
|
+
"__version__",
|
|
27
|
+
"execute",
|
|
28
|
+
"select",
|
|
29
|
+
"stream",
|
|
30
|
+
"validate_rows",
|
|
31
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class AdaptedRow:
|
|
10
|
+
mapping: Mapping[str, object]
|
|
11
|
+
raw_row: object | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RowAdapter(Protocol):
|
|
15
|
+
def adapt(self, row: object) -> AdaptedRow: ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from rowguard.adapters.base import AdaptedRow
|
|
6
|
+
from rowguard.errors import RowAdaptationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLAlchemyRowAdapter:
|
|
10
|
+
"""Adapt SQLAlchemy rows or mappings into Pydantic-ready dictionaries."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, field_map: Mapping[str, str] | None = None) -> None:
|
|
13
|
+
self._field_map = dict(field_map) if field_map else None
|
|
14
|
+
|
|
15
|
+
def adapt(self, row: object) -> AdaptedRow:
|
|
16
|
+
source = getattr(row, "_mapping", None)
|
|
17
|
+
if source is None:
|
|
18
|
+
if isinstance(row, Mapping):
|
|
19
|
+
source = row
|
|
20
|
+
else:
|
|
21
|
+
raise RowAdaptationError(f"Unsupported row type: {type(row).__name__}")
|
|
22
|
+
|
|
23
|
+
mapping: dict[str, object] = dict(source)
|
|
24
|
+
if self._field_map is not None:
|
|
25
|
+
remapped: dict[str, object] = {}
|
|
26
|
+
missing: list[str] = []
|
|
27
|
+
for field_name, column_key in self._field_map.items():
|
|
28
|
+
if column_key not in mapping:
|
|
29
|
+
missing.append(f"{field_name}->{column_key}")
|
|
30
|
+
continue
|
|
31
|
+
remapped[field_name] = mapping[column_key]
|
|
32
|
+
if missing:
|
|
33
|
+
raise RowAdaptationError(
|
|
34
|
+
"field_map source key(s) missing from row: " + ", ".join(missing)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
reserved_sources = set(self._field_map.values())
|
|
38
|
+
reserved_destinations = set(self._field_map.keys())
|
|
39
|
+
for key, value in mapping.items():
|
|
40
|
+
if key in reserved_sources or key in reserved_destinations:
|
|
41
|
+
continue
|
|
42
|
+
if key not in remapped:
|
|
43
|
+
remapped[key] = value
|
|
44
|
+
mapping = remapped
|
|
45
|
+
|
|
46
|
+
return AdaptedRow(mapping=mapping, raw_row=row)
|
rowguard/api.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from sqlalchemy.sql import Select
|
|
8
|
+
|
|
9
|
+
from rowguard.adapters.sqlalchemy_row import SQLAlchemyRowAdapter
|
|
10
|
+
from rowguard.errors import ConfigurationError
|
|
11
|
+
from rowguard.execution.sync import SyncExecutionEngine
|
|
12
|
+
from rowguard.planning.compiler import QueryPlanner
|
|
13
|
+
from rowguard.planning.execution_plan import ExecutionPlan
|
|
14
|
+
from rowguard.planning.request import QueryRequest
|
|
15
|
+
from rowguard.rejection.base import RejectionPolicy
|
|
16
|
+
from rowguard.rejection.policies import CollectPolicy, RaisePolicy, SkipPolicy
|
|
17
|
+
from rowguard.results.query_result import QueryResult
|
|
18
|
+
from rowguard.validation.pydantic import PydanticValidator
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
_POLICIES: dict[str, type[RejectionPolicy]] = {
|
|
23
|
+
"raise": RaisePolicy,
|
|
24
|
+
"collect": CollectPolicy,
|
|
25
|
+
"skip": SkipPolicy,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def select(
|
|
30
|
+
*,
|
|
31
|
+
session: Any | None = None,
|
|
32
|
+
connection: Any | None = None,
|
|
33
|
+
table: Any,
|
|
34
|
+
model: type[T],
|
|
35
|
+
where: Iterable[Any] = (),
|
|
36
|
+
field_map: Mapping[str, str] | None = None,
|
|
37
|
+
column_map: Mapping[str, Any] | None = None,
|
|
38
|
+
parameters: Mapping[str, object] | None = None,
|
|
39
|
+
on_reject: str = "raise",
|
|
40
|
+
use_sqlrules: bool = True,
|
|
41
|
+
) -> QueryResult[T]:
|
|
42
|
+
"""Build and execute a validation-first SQLAlchemy SELECT query."""
|
|
43
|
+
request: QueryRequest[T] = QueryRequest(
|
|
44
|
+
model=model,
|
|
45
|
+
source=table,
|
|
46
|
+
session=session,
|
|
47
|
+
connection=connection,
|
|
48
|
+
where=tuple(where),
|
|
49
|
+
field_map=field_map,
|
|
50
|
+
column_map=column_map,
|
|
51
|
+
parameters=dict(parameters or {}),
|
|
52
|
+
on_reject=on_reject,
|
|
53
|
+
use_sqlrules=use_sqlrules,
|
|
54
|
+
)
|
|
55
|
+
plan = QueryPlanner[T]().compile(request)
|
|
56
|
+
return SyncExecutionEngine[T]().execute(plan)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def execute(
|
|
60
|
+
*,
|
|
61
|
+
session: Any | None = None,
|
|
62
|
+
connection: Any | None = None,
|
|
63
|
+
statement: Select[Any],
|
|
64
|
+
model: type[T],
|
|
65
|
+
source: Any | None = None,
|
|
66
|
+
where: Iterable[Any] = (),
|
|
67
|
+
field_map: Mapping[str, str] | None = None,
|
|
68
|
+
column_map: Mapping[str, Any] | None = None,
|
|
69
|
+
parameters: Mapping[str, object] | None = None,
|
|
70
|
+
on_reject: str = "raise",
|
|
71
|
+
use_sqlrules: bool = True,
|
|
72
|
+
) -> QueryResult[T]:
|
|
73
|
+
"""Execute an existing SQLAlchemy statement and validate every row."""
|
|
74
|
+
request: QueryRequest[T] = QueryRequest(
|
|
75
|
+
model=model,
|
|
76
|
+
source=source,
|
|
77
|
+
statement=statement,
|
|
78
|
+
session=session,
|
|
79
|
+
connection=connection,
|
|
80
|
+
where=tuple(where),
|
|
81
|
+
field_map=field_map,
|
|
82
|
+
column_map=column_map,
|
|
83
|
+
parameters=dict(parameters or {}),
|
|
84
|
+
on_reject=on_reject,
|
|
85
|
+
use_sqlrules=use_sqlrules,
|
|
86
|
+
)
|
|
87
|
+
plan = QueryPlanner[T]().compile(request)
|
|
88
|
+
return SyncExecutionEngine[T]().execute(plan)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def stream(
|
|
92
|
+
*,
|
|
93
|
+
session: Any,
|
|
94
|
+
statement: Select[Any],
|
|
95
|
+
model: type[T],
|
|
96
|
+
on_reject: str = "raise",
|
|
97
|
+
) -> Iterable[T]:
|
|
98
|
+
"""Stream validated models without buffering all accepted rows.
|
|
99
|
+
|
|
100
|
+
Deferred until 0.3.0.
|
|
101
|
+
"""
|
|
102
|
+
raise NotImplementedError(
|
|
103
|
+
"stream() is deferred to RowGuard 0.3.0; use select()/execute() for buffered results"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_rows(
|
|
108
|
+
*,
|
|
109
|
+
rows: Iterable[Mapping[str, object]],
|
|
110
|
+
model: type[T],
|
|
111
|
+
field_map: Mapping[str, str] | None = None,
|
|
112
|
+
on_reject: str = "raise",
|
|
113
|
+
) -> QueryResult[T]:
|
|
114
|
+
"""Validate row mappings without executing SQL."""
|
|
115
|
+
policy_cls = _POLICIES.get(on_reject)
|
|
116
|
+
if policy_cls is None:
|
|
117
|
+
raise ConfigurationError(
|
|
118
|
+
f"Unsupported on_reject policy: {on_reject!r}. "
|
|
119
|
+
f"Supported: {', '.join(sorted(_POLICIES))}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
plan: ExecutionPlan[T] = ExecutionPlan(
|
|
123
|
+
statement=None,
|
|
124
|
+
model=model,
|
|
125
|
+
adapter=SQLAlchemyRowAdapter(field_map=field_map),
|
|
126
|
+
validator=PydanticValidator(model),
|
|
127
|
+
rejection_policy=policy_cls(),
|
|
128
|
+
use_sqlrules=False,
|
|
129
|
+
)
|
|
130
|
+
return SyncExecutionEngine[T]().validate_rows(plan=plan, rows=rows)
|
rowguard/cache.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
K = TypeVar("K")
|
|
5
|
+
V = TypeVar("V")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LRUCache(Generic[K, V]):
|
|
9
|
+
def __init__(self, max_entries: int = 512) -> None:
|
|
10
|
+
self._max_entries = max_entries
|
|
11
|
+
self._items: OrderedDict[K, V] = OrderedDict()
|
|
12
|
+
|
|
13
|
+
def get(self, key: K) -> V | None:
|
|
14
|
+
value = self._items.get(key)
|
|
15
|
+
if value is not None:
|
|
16
|
+
self._items.move_to_end(key)
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
def set(self, key: K, value: V) -> None:
|
|
20
|
+
self._items[key] = value
|
|
21
|
+
self._items.move_to_end(key)
|
|
22
|
+
while len(self._items) > self._max_entries:
|
|
23
|
+
self._items.popitem(last=False)
|
|
24
|
+
|
|
25
|
+
def clear(self) -> None:
|
|
26
|
+
self._items.clear()
|
rowguard/diagnostics.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Diagnostic:
|
|
10
|
+
code: str
|
|
11
|
+
severity: str
|
|
12
|
+
execution_id: str
|
|
13
|
+
row_index: int | None = None
|
|
14
|
+
metadata: Mapping[str, object] = field(default_factory=dict)
|
|
15
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
rowguard/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RowGuardError(Exception):
|
|
9
|
+
"""Base exception for all RowGuard errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(RowGuardError):
|
|
13
|
+
"""Invalid or incompatible RowGuard configuration."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QueryExecutionError(RowGuardError):
|
|
17
|
+
"""SQLAlchemy query execution failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RowAdaptationError(RowGuardError):
|
|
21
|
+
"""A database row could not be adapted safely."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
model: type[Any] | None = None,
|
|
28
|
+
row_index: int | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.model = model
|
|
31
|
+
self.row_index = row_index
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RowValidationError(RowGuardError):
|
|
36
|
+
"""A row failed Pydantic validation under the raise policy."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
model: type[Any],
|
|
42
|
+
validation_error: ValidationError,
|
|
43
|
+
row_index: int | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.model = model
|
|
46
|
+
self.validation_error = validation_error
|
|
47
|
+
self.row_index = row_index
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"Row {row_index!r} failed validation for {model.__name__} "
|
|
50
|
+
f"with {validation_error.error_count()} error(s)."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RejectHandlerError(RowGuardError):
|
|
55
|
+
"""A rejection callback or quarantine provider failed."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ResultAssemblyError(RowGuardError):
|
|
59
|
+
"""An inconsistent public result was about to be created."""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from time import perf_counter_ns
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from rowguard.errors import RowAdaptationError
|
|
10
|
+
from rowguard.planning.execution_plan import ExecutionPlan
|
|
11
|
+
from rowguard.rejection.base import RejectionDecision
|
|
12
|
+
from rowguard.results.rejected_row import RejectedRow
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=BaseModel)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ProcessedRow(Generic[T]):
|
|
19
|
+
model: T | None
|
|
20
|
+
rejected: RejectedRow | None
|
|
21
|
+
retain_rejection: bool
|
|
22
|
+
continue_processing: bool
|
|
23
|
+
adaptation_time_ns: int = 0
|
|
24
|
+
validation_time_ns: int = 0
|
|
25
|
+
rejection_time_ns: int = 0
|
|
26
|
+
validated: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def process_row(
|
|
30
|
+
*,
|
|
31
|
+
row: object,
|
|
32
|
+
index: int,
|
|
33
|
+
plan: ExecutionPlan[T],
|
|
34
|
+
) -> ProcessedRow[T]:
|
|
35
|
+
"""Adapt, validate, and apply rejection policy for a single row."""
|
|
36
|
+
adaptation_time_ns = 0
|
|
37
|
+
validation_time_ns = 0
|
|
38
|
+
|
|
39
|
+
started = perf_counter_ns()
|
|
40
|
+
try:
|
|
41
|
+
adapted = plan.adapter.adapt(row)
|
|
42
|
+
except RowAdaptationError as error:
|
|
43
|
+
adaptation_time_ns = perf_counter_ns() - started
|
|
44
|
+
rejected = RejectedRow(
|
|
45
|
+
index=index,
|
|
46
|
+
model=plan.model,
|
|
47
|
+
mapping=None,
|
|
48
|
+
validation_error=None,
|
|
49
|
+
adaptation_error=error,
|
|
50
|
+
raw_row=row,
|
|
51
|
+
)
|
|
52
|
+
return _handle_rejection(
|
|
53
|
+
plan,
|
|
54
|
+
rejected,
|
|
55
|
+
adaptation_time_ns=adaptation_time_ns,
|
|
56
|
+
validated=False,
|
|
57
|
+
)
|
|
58
|
+
adaptation_time_ns = perf_counter_ns() - started
|
|
59
|
+
|
|
60
|
+
started = perf_counter_ns()
|
|
61
|
+
outcome = plan.validator.validate(adapted.mapping)
|
|
62
|
+
validation_time_ns = perf_counter_ns() - started
|
|
63
|
+
|
|
64
|
+
if outcome.accepted:
|
|
65
|
+
assert outcome.model is not None
|
|
66
|
+
return ProcessedRow(
|
|
67
|
+
model=outcome.model,
|
|
68
|
+
rejected=None,
|
|
69
|
+
retain_rejection=False,
|
|
70
|
+
continue_processing=True,
|
|
71
|
+
adaptation_time_ns=adaptation_time_ns,
|
|
72
|
+
validation_time_ns=validation_time_ns,
|
|
73
|
+
validated=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
rejected = RejectedRow(
|
|
77
|
+
index=index,
|
|
78
|
+
model=plan.model,
|
|
79
|
+
mapping=adapted.mapping,
|
|
80
|
+
validation_error=outcome.error,
|
|
81
|
+
raw_row=adapted.raw_row,
|
|
82
|
+
)
|
|
83
|
+
return _handle_rejection(
|
|
84
|
+
plan,
|
|
85
|
+
rejected,
|
|
86
|
+
adaptation_time_ns=adaptation_time_ns,
|
|
87
|
+
validation_time_ns=validation_time_ns,
|
|
88
|
+
validated=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _handle_rejection(
|
|
93
|
+
plan: ExecutionPlan[T],
|
|
94
|
+
rejected: RejectedRow,
|
|
95
|
+
*,
|
|
96
|
+
adaptation_time_ns: int = 0,
|
|
97
|
+
validation_time_ns: int = 0,
|
|
98
|
+
validated: bool = False,
|
|
99
|
+
) -> ProcessedRow[T]:
|
|
100
|
+
started = perf_counter_ns()
|
|
101
|
+
decision: RejectionDecision = plan.rejection_policy.handle(rejected)
|
|
102
|
+
rejection_time_ns = perf_counter_ns() - started
|
|
103
|
+
return ProcessedRow(
|
|
104
|
+
model=None,
|
|
105
|
+
rejected=rejected,
|
|
106
|
+
retain_rejection=decision.retain_rejection,
|
|
107
|
+
continue_processing=decision.continue_processing,
|
|
108
|
+
adaptation_time_ns=adaptation_time_ns,
|
|
109
|
+
validation_time_ns=validation_time_ns,
|
|
110
|
+
rejection_time_ns=rejection_time_ns,
|
|
111
|
+
validated=validated,
|
|
112
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T", bound=BaseModel)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StreamResult(Generic[T], Iterator[T]):
|
|
10
|
+
def __iter__(self) -> "StreamResult[T]":
|
|
11
|
+
return self
|
|
12
|
+
|
|
13
|
+
def __next__(self) -> T:
|
|
14
|
+
raise StopIteration
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from time import perf_counter_ns
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from rowguard.diagnostics import Diagnostic
|
|
11
|
+
from rowguard.errors import QueryExecutionError, ResultAssemblyError, RowGuardError
|
|
12
|
+
from rowguard.execution.processor import ProcessedRow, process_row
|
|
13
|
+
from rowguard.planning.execution_plan import ExecutionPlan
|
|
14
|
+
from rowguard.results.query_result import QueryResult
|
|
15
|
+
from rowguard.results.rejected_row import RejectedRow
|
|
16
|
+
from rowguard.statistics import QueryStatistics
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T", bound=BaseModel)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class _MutableStatistics:
|
|
23
|
+
rows_read: int = 0
|
|
24
|
+
rows_validated: int = 0
|
|
25
|
+
rows_accepted: int = 0
|
|
26
|
+
rows_rejected: int = 0
|
|
27
|
+
execution_time_ns: int = 0
|
|
28
|
+
adaptation_time_ns: int = 0
|
|
29
|
+
validation_time_ns: int = 0
|
|
30
|
+
rejection_time_ns: int = 0
|
|
31
|
+
|
|
32
|
+
def snapshot(self) -> QueryStatistics:
|
|
33
|
+
return QueryStatistics(
|
|
34
|
+
rows_read=self.rows_read,
|
|
35
|
+
rows_validated=self.rows_validated,
|
|
36
|
+
rows_accepted=self.rows_accepted,
|
|
37
|
+
rows_rejected=self.rows_rejected,
|
|
38
|
+
execution_time_ns=self.execution_time_ns,
|
|
39
|
+
adaptation_time_ns=self.adaptation_time_ns,
|
|
40
|
+
validation_time_ns=self.validation_time_ns,
|
|
41
|
+
rejection_time_ns=self.rejection_time_ns,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class ExecutionState(Generic[T]):
|
|
47
|
+
plan: ExecutionPlan[T]
|
|
48
|
+
statistics: _MutableStatistics = field(default_factory=_MutableStatistics)
|
|
49
|
+
accepted: list[T] = field(default_factory=list)
|
|
50
|
+
rejected: list[RejectedRow] = field(default_factory=list)
|
|
51
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SyncExecutionEngine(Generic[T]):
|
|
55
|
+
def execute(self, plan: ExecutionPlan[T]) -> QueryResult[T]:
|
|
56
|
+
state = ExecutionState(plan=plan)
|
|
57
|
+
state.diagnostics.extend(plan.diagnostics)
|
|
58
|
+
started = perf_counter_ns()
|
|
59
|
+
result: Any | None = None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = self._execute_statement(plan)
|
|
63
|
+
for index, row in enumerate(result):
|
|
64
|
+
processed = process_row(row=row, index=index, plan=plan)
|
|
65
|
+
if not self._consume_processed(state, processed):
|
|
66
|
+
break
|
|
67
|
+
except RowGuardError:
|
|
68
|
+
raise
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
raise QueryExecutionError(f"Query execution failed: {exc}") from exc
|
|
71
|
+
finally:
|
|
72
|
+
if result is not None:
|
|
73
|
+
close = getattr(result, "close", None)
|
|
74
|
+
if callable(close):
|
|
75
|
+
close()
|
|
76
|
+
state.statistics.execution_time_ns = perf_counter_ns() - started
|
|
77
|
+
|
|
78
|
+
return self._assemble(state)
|
|
79
|
+
|
|
80
|
+
def validate_rows(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
plan: ExecutionPlan[T],
|
|
84
|
+
rows: Iterable[Mapping[str, object]],
|
|
85
|
+
) -> QueryResult[T]:
|
|
86
|
+
state = ExecutionState(plan=plan)
|
|
87
|
+
state.diagnostics.extend(plan.diagnostics)
|
|
88
|
+
started = perf_counter_ns()
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
for index, row in enumerate(rows):
|
|
92
|
+
processed = process_row(row=row, index=index, plan=plan)
|
|
93
|
+
if not self._consume_processed(state, processed):
|
|
94
|
+
break
|
|
95
|
+
finally:
|
|
96
|
+
state.statistics.execution_time_ns = perf_counter_ns() - started
|
|
97
|
+
|
|
98
|
+
return self._assemble(state)
|
|
99
|
+
|
|
100
|
+
def _consume_processed(
|
|
101
|
+
self,
|
|
102
|
+
state: ExecutionState[T],
|
|
103
|
+
processed: ProcessedRow[T],
|
|
104
|
+
) -> bool:
|
|
105
|
+
"""Update state from a processed row. Returns whether to continue."""
|
|
106
|
+
state.statistics.rows_read += 1
|
|
107
|
+
state.statistics.adaptation_time_ns += processed.adaptation_time_ns
|
|
108
|
+
state.statistics.validation_time_ns += processed.validation_time_ns
|
|
109
|
+
state.statistics.rejection_time_ns += processed.rejection_time_ns
|
|
110
|
+
if processed.validated:
|
|
111
|
+
state.statistics.rows_validated += 1
|
|
112
|
+
|
|
113
|
+
if processed.model is not None:
|
|
114
|
+
state.statistics.rows_accepted += 1
|
|
115
|
+
state.accepted.append(processed.model)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
state.statistics.rows_rejected += 1
|
|
119
|
+
if processed.retain_rejection and processed.rejected is not None:
|
|
120
|
+
state.rejected.append(processed.rejected)
|
|
121
|
+
return processed.continue_processing
|
|
122
|
+
|
|
123
|
+
def _execute_statement(self, plan: ExecutionPlan[T]) -> Any:
|
|
124
|
+
params = dict(plan.parameters) if plan.parameters else {}
|
|
125
|
+
if plan.session is not None:
|
|
126
|
+
if params:
|
|
127
|
+
return plan.session.execute(plan.statement, params)
|
|
128
|
+
return plan.session.execute(plan.statement)
|
|
129
|
+
if plan.connection is not None:
|
|
130
|
+
if params:
|
|
131
|
+
return plan.connection.execute(plan.statement, params)
|
|
132
|
+
return plan.connection.execute(plan.statement)
|
|
133
|
+
raise QueryExecutionError("No session or connection available for execution")
|
|
134
|
+
|
|
135
|
+
def _assemble(self, state: ExecutionState[T]) -> QueryResult[T]:
|
|
136
|
+
stats = state.statistics.snapshot()
|
|
137
|
+
if stats.rows_accepted != len(state.accepted):
|
|
138
|
+
raise ResultAssemblyError("Accepted count does not match models")
|
|
139
|
+
if stats.rows_rejected < len(state.rejected):
|
|
140
|
+
raise ResultAssemblyError("Rejected count is less than retained rejections")
|
|
141
|
+
if stats.rows_accepted + stats.rows_rejected != stats.rows_read:
|
|
142
|
+
raise ResultAssemblyError("Read rows are not fully classified")
|
|
143
|
+
if stats.rows_validated > stats.rows_read:
|
|
144
|
+
raise ResultAssemblyError("Validated count exceeds rows read")
|
|
145
|
+
|
|
146
|
+
return QueryResult(
|
|
147
|
+
models=tuple(state.accepted),
|
|
148
|
+
rejected=tuple(state.rejected),
|
|
149
|
+
statistics=stats,
|
|
150
|
+
statement=state.plan.statement,
|
|
151
|
+
diagnostics=tuple(state.diagnostics),
|
|
152
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Database and SQLRules integrations."""
|
|
2
|
+
|
|
3
|
+
from rowguard.integrations.sqlalchemy_core import apply_where, build_select
|
|
4
|
+
from rowguard.integrations.sqlrules import CompiledPushdown, SQLRulesBridge
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CompiledPushdown",
|
|
8
|
+
"SQLRulesBridge",
|
|
9
|
+
"apply_where",
|
|
10
|
+
"build_select",
|
|
11
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Select, select
|
|
7
|
+
from sqlalchemy.sql import ColumnElement
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_select(source: object) -> Select[Any]:
|
|
11
|
+
"""Build a SELECT over a Core table or selectable."""
|
|
12
|
+
return cast(Select[Any], select(source)) # type: ignore[call-overload]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_where(statement: Select[Any], expressions: Iterable[object]) -> Select[Any]:
|
|
16
|
+
"""Return a new statement with additional WHERE expressions."""
|
|
17
|
+
exprs = tuple(expressions)
|
|
18
|
+
if not exprs:
|
|
19
|
+
return statement
|
|
20
|
+
return statement.where(*exprs) # type: ignore[arg-type]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_select(value: object) -> bool:
|
|
24
|
+
return isinstance(value, Select)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_column_element(value: object) -> bool:
|
|
28
|
+
return isinstance(value, ColumnElement)
|