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.
@@ -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
+ ]
@@ -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