python-mapper 0.3.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.
- python_mapper/__init__.py +139 -0
- python_mapper/base.py +176 -0
- python_mapper/compiler.py +365 -0
- python_mapper/database.py +212 -0
- python_mapper/errors.py +26 -0
- python_mapper/extension.py +140 -0
- python_mapper/mapping.py +210 -0
- python_mapper/observability.py +117 -0
- python_mapper/pagination.py +224 -0
- python_mapper/plugins.py +94 -0
- python_mapper/py.typed +0 -0
- python_mapper/runtime.py +885 -0
- python_mapper-0.3.0.dist-info/METADATA +245 -0
- python_mapper-0.3.0.dist-info/RECORD +17 -0
- python_mapper-0.3.0.dist-info/WHEEL +5 -0
- python_mapper-0.3.0.dist-info/licenses/LICENSE +21 -0
- python_mapper-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Structured, parameter-safe SQL execution logging."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from python_mapper.plugins import StatementContext, StatementExecutor, StatementResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalized_sql(sql: str) -> str:
|
|
14
|
+
return " ".join(sql.split())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fingerprint(sql: str) -> str:
|
|
18
|
+
return hashlib.sha256(_normalized_sql(sql).encode("utf-8")).hexdigest()[:16]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SqlLoggingPlugin:
|
|
22
|
+
"""Observe final SQL shape without logging parameter values by default."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
slow_query_threshold_ms: float = 500.0,
|
|
28
|
+
logger_name: str = "python_mapper.query",
|
|
29
|
+
include_sql: bool = True,
|
|
30
|
+
) -> None:
|
|
31
|
+
if slow_query_threshold_ms < 0:
|
|
32
|
+
raise ValueError("slow_query_threshold_ms must be non-negative")
|
|
33
|
+
self.slow_query_threshold_ms = slow_query_threshold_ms
|
|
34
|
+
self.logger = logging.getLogger(logger_name)
|
|
35
|
+
self.include_sql = include_sql
|
|
36
|
+
|
|
37
|
+
def _event(
|
|
38
|
+
self,
|
|
39
|
+
context: StatementContext,
|
|
40
|
+
*,
|
|
41
|
+
duration_ms: float,
|
|
42
|
+
row_count: int | None,
|
|
43
|
+
slow: bool,
|
|
44
|
+
error_type: str | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
sql = context.compiled_sql or context.sql
|
|
47
|
+
event: dict[str, Any] = {
|
|
48
|
+
"event": "pymapper.query",
|
|
49
|
+
"statement_id": context.statement_id,
|
|
50
|
+
"operation": context.operation,
|
|
51
|
+
"parent_statement_id": context.parent_statement_id,
|
|
52
|
+
"sql_fingerprint": _fingerprint(sql),
|
|
53
|
+
"duration_ms": round(duration_ms, 3),
|
|
54
|
+
"connection_wait_ms": round(context.connection_wait_ms, 3),
|
|
55
|
+
"row_count": row_count,
|
|
56
|
+
"parameter_names": sorted(context.parameters),
|
|
57
|
+
"parameter_types": [
|
|
58
|
+
type(context.parameters[name]).__name__
|
|
59
|
+
for name in sorted(context.parameters)
|
|
60
|
+
],
|
|
61
|
+
"slow": slow,
|
|
62
|
+
"error_type": error_type,
|
|
63
|
+
}
|
|
64
|
+
if self.include_sql:
|
|
65
|
+
event["sql"] = _normalized_sql(sql)
|
|
66
|
+
return event
|
|
67
|
+
|
|
68
|
+
async def execute(
|
|
69
|
+
self,
|
|
70
|
+
context: StatementContext,
|
|
71
|
+
call_next: StatementExecutor,
|
|
72
|
+
) -> StatementResult:
|
|
73
|
+
started_at = time.perf_counter()
|
|
74
|
+
try:
|
|
75
|
+
result = await call_next(context)
|
|
76
|
+
except asyncio.CancelledError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception as error:
|
|
79
|
+
duration_ms = (time.perf_counter() - started_at) * 1000
|
|
80
|
+
event = self._event(
|
|
81
|
+
context,
|
|
82
|
+
duration_ms=duration_ms,
|
|
83
|
+
row_count=None,
|
|
84
|
+
slow=duration_ms >= self.slow_query_threshold_ms,
|
|
85
|
+
error_type=type(error).__name__,
|
|
86
|
+
)
|
|
87
|
+
self.logger.exception(
|
|
88
|
+
"pymapper query failed statement_id=%s fingerprint=%s duration_ms=%.3f",
|
|
89
|
+
context.statement_id,
|
|
90
|
+
event["sql_fingerprint"],
|
|
91
|
+
duration_ms,
|
|
92
|
+
extra={"pymapper": event},
|
|
93
|
+
)
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
duration_ms = (time.perf_counter() - started_at) * 1000
|
|
97
|
+
slow = duration_ms >= self.slow_query_threshold_ms
|
|
98
|
+
event = self._event(
|
|
99
|
+
context,
|
|
100
|
+
duration_ms=duration_ms,
|
|
101
|
+
row_count=result.rowcount,
|
|
102
|
+
slow=slow,
|
|
103
|
+
)
|
|
104
|
+
level = logging.WARNING if slow else logging.DEBUG
|
|
105
|
+
self.logger.log(
|
|
106
|
+
level,
|
|
107
|
+
"pymapper query statement_id=%s fingerprint=%s duration_ms=%.3f rows=%d",
|
|
108
|
+
context.statement_id,
|
|
109
|
+
event["sql_fingerprint"],
|
|
110
|
+
duration_ms,
|
|
111
|
+
result.rowcount,
|
|
112
|
+
extra={"pymapper": event},
|
|
113
|
+
)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["SqlLoggingPlugin"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Opt-in pagination models and statement plugin."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from python_mapper.compiler import (
|
|
9
|
+
contains_top_level_keyword,
|
|
10
|
+
contains_top_level_sequence,
|
|
11
|
+
)
|
|
12
|
+
from python_mapper.errors import PaginationConflictError, PaginationError
|
|
13
|
+
from python_mapper.plugins import StatementContext, StatementExecutor, StatementResult
|
|
14
|
+
|
|
15
|
+
DEFAULT_PAGE_SIZE = 30
|
|
16
|
+
MAX_PAGE_SIZE = 200
|
|
17
|
+
PAGE_MARKER = "/*__PYTHON_MAPPER_PAGE__*/"
|
|
18
|
+
PAGE_LIMIT_PARAMETER = "__page_size"
|
|
19
|
+
PAGE_OFFSET_PARAMETER = "__page_offset"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class PaginationOptions:
|
|
24
|
+
"""Per-call pagination switch; disabled calls must remain raw mapper calls."""
|
|
25
|
+
|
|
26
|
+
enabled: bool = False
|
|
27
|
+
page_number: int = 1
|
|
28
|
+
page_size: int = DEFAULT_PAGE_SIZE
|
|
29
|
+
include_total: bool = True
|
|
30
|
+
|
|
31
|
+
def normalized(self) -> PaginationOptions:
|
|
32
|
+
if self.page_number < 1:
|
|
33
|
+
raise PaginationError("page_number must be greater than or equal to 1")
|
|
34
|
+
if self.page_size < 1 or self.page_size > MAX_PAGE_SIZE:
|
|
35
|
+
raise PaginationError(
|
|
36
|
+
f"page_size must be between 1 and {MAX_PAGE_SIZE}"
|
|
37
|
+
)
|
|
38
|
+
return PaginationOptions(
|
|
39
|
+
enabled=self.enabled,
|
|
40
|
+
page_number=self.page_number,
|
|
41
|
+
page_size=self.page_size,
|
|
42
|
+
include_total=self.include_total,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class PageMetadata:
|
|
48
|
+
page_number: int
|
|
49
|
+
page_size: int
|
|
50
|
+
total: int | None
|
|
51
|
+
total_pages: int | None
|
|
52
|
+
has_next: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class Page[ItemValue]:
|
|
57
|
+
"""Framework-neutral page returned directly by a ``Page[T]`` mapper."""
|
|
58
|
+
|
|
59
|
+
items: list[ItemValue] = field(default_factory=list)
|
|
60
|
+
total: int = 0
|
|
61
|
+
page: int = 1
|
|
62
|
+
page_size: int = DEFAULT_PAGE_SIZE
|
|
63
|
+
pages: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class QueryResult[ItemValue]:
|
|
68
|
+
"""Stable result from the optional ``query`` facade."""
|
|
69
|
+
|
|
70
|
+
items: list[ItemValue]
|
|
71
|
+
pagination: PageMetadata | None = None
|
|
72
|
+
|
|
73
|
+
def map[MappedValue](
|
|
74
|
+
self,
|
|
75
|
+
converter: Callable[[ItemValue], MappedValue],
|
|
76
|
+
) -> QueryResult[MappedValue]:
|
|
77
|
+
return QueryResult(
|
|
78
|
+
items=[converter(item) for item in self.items],
|
|
79
|
+
pagination=self.pagination,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class PaginationSpec:
|
|
85
|
+
statement_id: str
|
|
86
|
+
count_statement_id: str | None = None
|
|
87
|
+
marker_count: int = 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scalar_count(result: StatementResult, statement_id: str) -> int:
|
|
91
|
+
if not result.rows:
|
|
92
|
+
raise PaginationError(
|
|
93
|
+
f"pagination count statement '{statement_id}' returned no rows"
|
|
94
|
+
)
|
|
95
|
+
row = result.rows[0]
|
|
96
|
+
values = list(dict(row).values())
|
|
97
|
+
if len(values) != 1:
|
|
98
|
+
raise PaginationError(
|
|
99
|
+
f"pagination count statement '{statement_id}' must return exactly one column"
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
return int(values[0])
|
|
103
|
+
except (TypeError, ValueError) as error:
|
|
104
|
+
raise PaginationError(
|
|
105
|
+
f"pagination count statement '{statement_id}' returned a non-integer value"
|
|
106
|
+
) from error
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _append_page_clause(sql: str, clause: str) -> str:
|
|
110
|
+
stripped = sql.rstrip()
|
|
111
|
+
if stripped.endswith(";"):
|
|
112
|
+
return f"{stripped[:-1].rstrip()}\n{clause};"
|
|
113
|
+
return f"{stripped}\n{clause}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PaginationPlugin:
|
|
117
|
+
"""Add LIMIT/OFFSET only for one explicitly enabled query invocation."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, options: PaginationOptions, spec: PaginationSpec) -> None:
|
|
120
|
+
self.options = options.normalized()
|
|
121
|
+
self.spec = spec
|
|
122
|
+
|
|
123
|
+
async def execute(
|
|
124
|
+
self,
|
|
125
|
+
context: StatementContext,
|
|
126
|
+
call_next: StatementExecutor,
|
|
127
|
+
) -> StatementResult:
|
|
128
|
+
if not self.options.enabled:
|
|
129
|
+
return await call_next(context)
|
|
130
|
+
if context.statement_kind != "select":
|
|
131
|
+
raise PaginationError(
|
|
132
|
+
f"pagination only supports <select>: {context.statement_id}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
sql = context.sql
|
|
136
|
+
marker_count = sql.count(PAGE_MARKER)
|
|
137
|
+
has_manual_page = any(
|
|
138
|
+
contains_top_level_keyword(sql, keyword)
|
|
139
|
+
for keyword in ("LIMIT", "OFFSET", "FETCH")
|
|
140
|
+
)
|
|
141
|
+
if marker_count and has_manual_page:
|
|
142
|
+
raise PaginationConflictError(
|
|
143
|
+
f"mapper '{context.statement_id}' contains both <page/> and top-level "
|
|
144
|
+
"LIMIT/OFFSET/FETCH"
|
|
145
|
+
)
|
|
146
|
+
if marker_count > 1:
|
|
147
|
+
raise PaginationConflictError(
|
|
148
|
+
f"mapper '{context.statement_id}' contains more than one <page/> marker"
|
|
149
|
+
)
|
|
150
|
+
if has_manual_page:
|
|
151
|
+
raise PaginationConflictError(
|
|
152
|
+
f"mapper '{context.statement_id}' already contains top-level "
|
|
153
|
+
"LIMIT/OFFSET/FETCH; disable pagination or remove the manual clause"
|
|
154
|
+
)
|
|
155
|
+
if not contains_top_level_sequence(sql, "ORDER", "BY"):
|
|
156
|
+
raise PaginationError(
|
|
157
|
+
f"pagination statement '{context.statement_id}' requires a top-level ORDER BY"
|
|
158
|
+
)
|
|
159
|
+
if marker_count == 0 and contains_top_level_sequence(sql, "FOR", "UPDATE"):
|
|
160
|
+
raise PaginationError(
|
|
161
|
+
f"pagination statement '{context.statement_id}' uses FOR UPDATE; "
|
|
162
|
+
"add <page/> to declare the clause position"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
total: int | None = None
|
|
166
|
+
if self.options.include_total:
|
|
167
|
+
count_statement_id = self.spec.count_statement_id
|
|
168
|
+
if count_statement_id is None:
|
|
169
|
+
raise PaginationError(
|
|
170
|
+
f"pagination statement '{context.statement_id}' requires countRef "
|
|
171
|
+
"when include_total=True"
|
|
172
|
+
)
|
|
173
|
+
count_result = await context.execute_related(
|
|
174
|
+
count_statement_id,
|
|
175
|
+
context.input_parameters,
|
|
176
|
+
"pagination.count",
|
|
177
|
+
context.statement_id,
|
|
178
|
+
)
|
|
179
|
+
total = _scalar_count(count_result, count_statement_id)
|
|
180
|
+
|
|
181
|
+
page_size = self.options.page_size
|
|
182
|
+
offset = (self.options.page_number - 1) * page_size
|
|
183
|
+
query_limit = page_size if total is not None else page_size + 1
|
|
184
|
+
clause = f"LIMIT :{PAGE_LIMIT_PARAMETER} OFFSET :{PAGE_OFFSET_PARAMETER}"
|
|
185
|
+
context.sql = (
|
|
186
|
+
sql.replace(PAGE_MARKER, clause)
|
|
187
|
+
if marker_count
|
|
188
|
+
else _append_page_clause(sql, clause)
|
|
189
|
+
)
|
|
190
|
+
context.parameters[PAGE_LIMIT_PARAMETER] = query_limit
|
|
191
|
+
context.parameters[PAGE_OFFSET_PARAMETER] = offset
|
|
192
|
+
result = await call_next(context)
|
|
193
|
+
|
|
194
|
+
rows = list(result.rows or [])
|
|
195
|
+
if total is None:
|
|
196
|
+
has_next = len(rows) > page_size
|
|
197
|
+
rows = rows[:page_size]
|
|
198
|
+
result = StatementResult(rows=rows, rowcount=len(rows))
|
|
199
|
+
total_pages = None
|
|
200
|
+
else:
|
|
201
|
+
has_next = offset + len(rows) < total
|
|
202
|
+
total_pages = math.ceil(total / page_size) if total else 0
|
|
203
|
+
|
|
204
|
+
context.attributes["pagination"] = PageMetadata(
|
|
205
|
+
page_number=self.options.page_number,
|
|
206
|
+
page_size=page_size,
|
|
207
|
+
total=total,
|
|
208
|
+
total_pages=total_pages,
|
|
209
|
+
has_next=has_next,
|
|
210
|
+
)
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"DEFAULT_PAGE_SIZE",
|
|
216
|
+
"MAX_PAGE_SIZE",
|
|
217
|
+
"PAGE_MARKER",
|
|
218
|
+
"Page",
|
|
219
|
+
"PageMetadata",
|
|
220
|
+
"PaginationOptions",
|
|
221
|
+
"PaginationPlugin",
|
|
222
|
+
"PaginationSpec",
|
|
223
|
+
"QueryResult",
|
|
224
|
+
]
|
python_mapper/plugins.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Framework-neutral statement execution plugin contracts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from python_mapper.database import ConnectionLike
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class StatementResult:
|
|
13
|
+
"""Raw database result before resultType/resultMap materialization."""
|
|
14
|
+
|
|
15
|
+
rows: list[Any] | None
|
|
16
|
+
rowcount: int
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def returns_rows(self) -> bool:
|
|
20
|
+
return self.rows is not None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
RelatedStatementExecutor = Callable[
|
|
24
|
+
[str, Mapping[str, Any], str, str | None],
|
|
25
|
+
Awaitable[StatementResult],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class StatementContext:
|
|
31
|
+
"""Mutable execution state shared by one statement plugin chain."""
|
|
32
|
+
|
|
33
|
+
statement_id: str
|
|
34
|
+
statement_kind: str | None
|
|
35
|
+
sql: str
|
|
36
|
+
input_parameters: dict[str, Any]
|
|
37
|
+
parameters: dict[str, Any]
|
|
38
|
+
connection: ConnectionLike
|
|
39
|
+
execute_related: RelatedStatementExecutor
|
|
40
|
+
operation: str = "query"
|
|
41
|
+
parent_statement_id: str | None = None
|
|
42
|
+
connection_wait_ms: float = 0.0
|
|
43
|
+
compiled_sql: str | None = None
|
|
44
|
+
compiled_args: tuple[Any, ...] = ()
|
|
45
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
StatementExecutor = Callable[[StatementContext], Awaitable[StatementResult]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StatementPlugin(Protocol):
|
|
52
|
+
"""Around-execution extension point.
|
|
53
|
+
|
|
54
|
+
Plugin instances are process-level configuration and may serve concurrent
|
|
55
|
+
statements. Implementations should therefore remain stateless or protect
|
|
56
|
+
their own mutable state, and must call ``call_next`` exactly once unless they
|
|
57
|
+
intentionally short-circuit execution.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
async def execute(
|
|
61
|
+
self,
|
|
62
|
+
context: StatementContext,
|
|
63
|
+
call_next: StatementExecutor,
|
|
64
|
+
) -> StatementResult: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_plugin_chain(
|
|
68
|
+
context: StatementContext,
|
|
69
|
+
plugins: Sequence[StatementPlugin],
|
|
70
|
+
terminal: StatementExecutor,
|
|
71
|
+
) -> StatementResult:
|
|
72
|
+
"""Run a reusable, non-destructive interceptor chain."""
|
|
73
|
+
|
|
74
|
+
async def invoke(index: int, current: StatementContext) -> StatementResult:
|
|
75
|
+
if index >= len(plugins):
|
|
76
|
+
return await terminal(current)
|
|
77
|
+
plugin = plugins[index]
|
|
78
|
+
|
|
79
|
+
async def call_next(next_context: StatementContext) -> StatementResult:
|
|
80
|
+
return await invoke(index + 1, next_context)
|
|
81
|
+
|
|
82
|
+
return await plugin.execute(current, call_next)
|
|
83
|
+
|
|
84
|
+
return await invoke(0, context)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"RelatedStatementExecutor",
|
|
89
|
+
"StatementContext",
|
|
90
|
+
"StatementExecutor",
|
|
91
|
+
"StatementPlugin",
|
|
92
|
+
"StatementResult",
|
|
93
|
+
"run_plugin_chain",
|
|
94
|
+
]
|
python_mapper/py.typed
ADDED
|
File without changes
|