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,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,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
|