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,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-mapper
3
+ Version: 0.3.0
4
+ Summary: PostgreSQL XML mapper with asyncpg pooling and implicit transactions
5
+ Author: z77777777777
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/z77777777777/python-mapper
8
+ Project-URL: Repository, https://github.com/z77777777777/python-mapper
9
+ Project-URL: Issues, https://github.com/z77777777777/python-mapper/issues
10
+ Keywords: postgresql,asyncpg,sql,mapper,mybatis,xml,async
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: SQL
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Database :: Front-Ends
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: Jinja2<4,>=3.1
24
+ Requires-Dist: asyncpg<1,>=0.31
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8; extra == "test"
27
+ Requires-Dist: pytest-asyncio>=1.0; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # python-mapper
31
+
32
+ [English](README.md) · [简体中文](README.zh-CN.md)
33
+
34
+ PostgreSQL-first XML mapper runtime backed directly by asyncpg. The package is
35
+ framework-neutral: it does not import FastAPI, a host application's settings,
36
+ logging formatter, schema, or business models.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install "git+https://github.com/z77777777777/python-mapper.git@v0.3.0"
42
+ ```
43
+
44
+ Requires Python 3.12+. The only runtime dependencies are `asyncpg` and `Jinja2`.
45
+
46
+ ## Package layout
47
+
48
+ - `database.py`: asyncpg pool configuration, startup, shutdown, and connection checkout.
49
+ - `extension.py`: application bootstrap, mapper-package scanning and pool lifespan.
50
+ - `base.py`: implicit connection and transaction propagation.
51
+ - `compiler.py`: safe `:name` to `$1` compilation and collection expansion.
52
+ - `plugins.py`: generic around-execution plugin contract.
53
+ - `pagination.py`: opt-in list pagination and stable page result models.
54
+ - `observability.py`: parameter-safe structured SQL logging plugin.
55
+ - `mapping.py`: `resultType`/`resultMap`, model validation, row materialization, and
56
+ strict 0..1 cardinality.
57
+ - `errors.py`: public framework exception hierarchy.
58
+ - `runtime.py`: current public facade plus XML loading, mapper binding, SQL rendering,
59
+ and execution.
60
+
61
+ `runtime.py` can later be separated into `builder`, `binding`, and `executor`, but only
62
+ after its shared dictionaries and load lock are owned by a `MapperRegistry`/`Configuration`
63
+ object. Splitting those functions first would replace one cohesive module with circular
64
+ imports and shared global state spread across several files.
65
+
66
+ ## Application setup
67
+
68
+ Wire everything once through the extension:
69
+
70
+ ```python
71
+ from pathlib import Path
72
+
73
+ from python_mapper import PyMapperExtension, SqlLoggingPlugin
74
+
75
+ pymapper = PyMapperExtension(
76
+ database_url="postgresql://user:password@localhost/database",
77
+ mapper_paths=[Path(__file__).resolve().parent / "mapper"],
78
+ mapper_packages=["app.repositories"],
79
+ plugins=[SqlLoggingPlugin(slow_query_threshold_ms=500)],
80
+ )
81
+
82
+ # Embed this lifespan in FastAPI, Starlette, a CLI worker, or your own host.
83
+ async def run_application() -> None:
84
+ async with pymapper.lifespan() as state:
85
+ print(state.statement_count)
86
+ await serve_application()
87
+ ```
88
+
89
+ The extension imports every module under `mapper_packages`, loads the XML, runs
90
+ startup-time contract validation, opens the pool, and closes it on exit. Lower-level
91
+ wiring remains available through `configure()`, `load_all_mappers()` and
92
+ `open_database()`. Use `reset_state()` for test isolation instead of clearing the
93
+ registry by hand.
94
+
95
+ ## Mapper declaration
96
+
97
+ ```python
98
+ from python_mapper import amapper, transactional
99
+
100
+ @amapper()
101
+ class OrdersMapper:
102
+ async def find(*, order_id: int | None = None) -> list: ...
103
+ async def update(*, order_id: int | None = None) -> int: ...
104
+ async def add_log(*, order_id: int | None = None) -> int: ...
105
+
106
+ @transactional()
107
+ async def update_order(order_id: int) -> None:
108
+ await OrdersMapper.update(order_id=order_id)
109
+ await OrdersMapper.add_log(order_id=order_id)
110
+ ```
111
+
112
+ Mapper methods do not accept a connection. A direct mapper call borrows one connection
113
+ without opening an explicit transaction; PostgreSQL commits that statement as its own
114
+ transaction. Calls inside `@transactional()` reuse the task-local connection and commit or
115
+ roll back together. `REQUIRED` and `REQUIRES_NEW` propagation are supported; a `REQUIRED`
116
+ participant inherits the outer transaction's isolation level, matching Spring/MyBatis
117
+ semantics.
118
+
119
+ Jinja blocks may control SQL structure, but values must use named binds
120
+ such as `:order_id`. `{{ value }}` interpolation is rejected while loading XML.
121
+ Only `if/elif/else/endif` Jinja tags are accepted; output, include, macro, loop,
122
+ assignment and filter tags fail during mapper loading. Native asyncpg `$n` binds
123
+ are also rejected because XML statements use one binding contract: `:name`.
124
+
125
+ ## Result mapping
126
+
127
+ - `resultType="module.Row"` maps by matching names: only fields the model declares as
128
+ constructor arguments are passed in. Extra columns returned by the SQL are ignored, and
129
+ optional fields the query did not return fall back to the model's defaults.
130
+ - `resultMap="rowMap"` declares `column -> property` explicitly. If an explicit property
131
+ does not exist on the model, `load_all_mappers()` fails during startup validation.
132
+ - Every `resultType` path is validated after all XML has loaded, so a typo in a dotted
133
+ path surfaces at startup rather than on the first query.
134
+ - `single="true"` is a strict 0..1 row contract: zero rows return `None`, one row returns
135
+ the object, and more than one raises `TooManyResultsError` — it never silently takes the
136
+ first row.
137
+
138
+ Framework exceptions live in `python_mapper.errors` and are re-exported from the package
139
+ root:
140
+
141
+ ```python
142
+ from python_mapper import PyMapperError, TooManyResultsError
143
+ ```
144
+
145
+ ## Optional pagination
146
+
147
+ Pagination is explicit and opt-in. A plain mapper call never rewrites your SQL:
148
+
149
+ ```xml
150
+ <select id="list_orders" countRef="count_orders" resultType="app.types.OrderRow">
151
+ SELECT id, order_no, status
152
+ FROM orders
153
+ WHERE status = :status
154
+ ORDER BY created_at DESC, id DESC
155
+ </select>
156
+
157
+ <select id="count_orders" expose="false">
158
+ SELECT COUNT(*) FROM orders WHERE status = :status
159
+ </select>
160
+ ```
161
+
162
+ ```python
163
+ from python_mapper import Page
164
+
165
+ class OrdersMapper:
166
+ async def list_orders(
167
+ *, status: str | None = None, page: int = 1, page_size: int = 30,
168
+ ) -> Page[OrderRow]: ...
169
+
170
+ page_result = await OrdersMapper.list_orders(
171
+ page=2,
172
+ status="active",
173
+ )
174
+ ```
175
+
176
+ When the return annotation is `Page[T]`, the mapper call returns a finished, web-framework
177
+ agnostic page object whose fields are fixed as `items / total / page / page_size / pages`.
178
+ The framework puts the `list[T]` returned by the SQL into the `Page`; callers never touch
179
+ `PaginationOptions`, `QueryResult` or `PageMetadata`. Methods that do not paginate keep
180
+ declaring and returning `list[T]`.
181
+
182
+ `query(..., pagination=PaginationOptions(...))` remains as a low-level entry point, for
183
+ dynamically disabling pagination or for slice/`has_next` cases with `include_total=False`.
184
+
185
+ - `page_size` defaults to 30 and is capped at 200.
186
+ - With `enabled=False` no pagination clause is added and no count runs; if the XML
187
+ declares `<page/>`, only that internal marker is removed before executing the
188
+ unpaginated SQL.
189
+ - A stable top-level `ORDER BY` is required whenever pagination is enabled.
190
+ - `include_total=True` requires an explicit `countRef`. With `False`, `has_next` is
191
+ determined by fetching one extra row and no count is executed.
192
+ - Without `<page/>` the pagination clause is appended at the end of the SQL. Use `<page/>`
193
+ when the insertion point matters, for example with `FOR UPDATE`. The marker must sit
194
+ after a complete top-level `ORDER BY` and before any `FOR` locking clause — never inside
195
+ select columns, a `WHERE`, a subquery, a string, or a comment.
196
+ - A hand-written top-level `LIMIT/OFFSET/FETCH` conflicts with enabled framework
197
+ pagination; with framework pagination disabled, hand-written pagination is preserved
198
+ exactly. Declaring `<page/>` together with hand-written pagination fails at XML load
199
+ time.
200
+ - This feature provides page-number `LIMIT/OFFSET` pagination. For deep paging over
201
+ millions of rows, disable it and implement keyset (cursor) pagination explicitly in your
202
+ own mapper — the framework will not guess your business cursor.
203
+
204
+ ## SQL execution plugins and logging
205
+
206
+ Hosts configure execution plugins through `plugins=[...]`. A plugin depends only on
207
+ `StatementContext`, `StatementResult` and `StatementPlugin`, so it can serve metrics,
208
+ tracing, auditing or read-only protection without depending on any web framework.
209
+
210
+ `SqlLoggingPlugin` emits a structured `LogRecord.pymapper` by default: statement id, final
211
+ asyncpg SQL, SQL fingerprint, elapsed time, connection wait, row count, parameter names
212
+ and parameter types. It never logs parameter values, and exceptions go through
213
+ `logger.exception` so the traceback is preserved. A host can lift `record.pymapper` into
214
+ its own JSON formatter payload.
215
+
216
+ Plugin instances are process-level configuration and may be reused across concurrent
217
+ requests. Custom plugins must be stateless, or guard their own mutable state.
218
+
219
+ ## Known pitfalls (read before writing code)
220
+
221
+ - **An empty expanding bind silently matches zero rows.** Passing an empty list to
222
+ `IN :ids` renders an "empty set" expression — no error, no matching rows. `NOT IN :ids`
223
+ with an empty list behaves the same way in reverse: nothing is excluded, so everything
224
+ passes. When a collection can legitimately be empty, branch in the caller or give the
225
+ parameter a non-optional default.
226
+ - **Result sets are fully materialized.** A mapper call pulls the whole result set into
227
+ memory before mapping rows. Always paginate or `LIMIT` large sets; streaming is
228
+ deliberately not offered, so that a cursor's lifetime is never leaked to the caller.
229
+ - **Never call a mapper from `asyncio.create_task` inside a transaction.** The child task
230
+ would either use the same connection concurrently (asyncpg raises) or use a connection
231
+ that has already been released. See the `transactional` docstring.
232
+ - **`:name::type` is rejected at load time.** Write `CAST(:name AS type)` instead.
233
+
234
+ ## Tests
235
+
236
+ ```bash
237
+ pip install -e ".[test]"
238
+ pytest
239
+ ```
240
+
241
+ The package's test fixtures snapshot and restore global state, so they can run alongside a
242
+ host project's own suite without polluting the host's mapper registry.
243
+
244
+ CI runs the suite on Windows and Linux across Python 3.12 / 3.13 / 3.14. No host
245
+ application, business database or web framework takes part in that matrix.
@@ -0,0 +1,17 @@
1
+ python_mapper/__init__.py,sha256=Dwp4l-3mNcjfNbqnHBPxHauAX4vZkL_x6f53-L1PIgA,3263
2
+ python_mapper/base.py,sha256=vVIAeVuSqVI-MjJJOJMnUpTJ7sGhKqhLvcPKjuNLc6w,6313
3
+ python_mapper/compiler.py,sha256=cF4fmK235yBrn0qOj-Ohqirhd80U-IcSvd1Yc9PL6ik,12672
4
+ python_mapper/database.py,sha256=zYMDlHpoLzHSBJZsXU9dHl-4zaxo4z-om-0-zwNcujU,6397
5
+ python_mapper/errors.py,sha256=zlrv2YQ7zG2FfipnGI4SaHDpfTym9QJq5zbxf6fNYFQ,652
6
+ python_mapper/extension.py,sha256=Ogfd4osCtQx_Uz5n4G2gdS0qKhOE2ly6rquHC7BBQG4,4899
7
+ python_mapper/mapping.py,sha256=lWX8m28YQcz85_0fDK8CuYrO79tQzjiecMCxX3LGhrU,7793
8
+ python_mapper/observability.py,sha256=x2cD3vq-tbWTWTHlu9623_8BG6HvKZw7SEmOT9azvMk,3785
9
+ python_mapper/pagination.py,sha256=OhsViezQzb6TpJ1bc5Gx1cUZQLFKYUkJ6DL652qdxOw,7445
10
+ python_mapper/plugins.py,sha256=sMSyqPUaQiEf-qvCDUKHAJl-Hzpcw_sJ1BajYBOT36I,2636
11
+ python_mapper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ python_mapper/runtime.py,sha256=1EqwgFglxOIHIyOf_R2_pPY7nF_28b_Skt0sPtYa1ng,40540
13
+ python_mapper-0.3.0.dist-info/licenses/LICENSE,sha256=89AMHBodYQSUMhc1keoqcONt43U-ERdO9AChPNU4wKw,1069
14
+ python_mapper-0.3.0.dist-info/METADATA,sha256=niPhqdYs2GbK0COkU0-ppvckXMxuKR3MnYGNGdYROOU,10821
15
+ python_mapper-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ python_mapper-0.3.0.dist-info/top_level.txt,sha256=DTSU9ONdLTaXBUGMukwt6GcO34cUY52kYD2giQ3B638,14
17
+ python_mapper-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 z77777777777
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ python_mapper