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,212 @@
|
|
|
1
|
+
"""asyncpg pool lifecycle owned by python-mapper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol, cast
|
|
10
|
+
|
|
11
|
+
import asyncpg
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectionLike(Protocol):
|
|
15
|
+
"""Execution surface used by the mapper runtime and test doubles."""
|
|
16
|
+
|
|
17
|
+
async def execute(self, query: str, *args: Any, timeout: float | None = None) -> str: ...
|
|
18
|
+
|
|
19
|
+
async def fetch(self, query: str, *args: Any, timeout: float | None = None) -> list[Any]: ...
|
|
20
|
+
|
|
21
|
+
async def fetchval(
|
|
22
|
+
self,
|
|
23
|
+
query: str,
|
|
24
|
+
*args: Any,
|
|
25
|
+
column: int = 0,
|
|
26
|
+
timeout: float | None = None,
|
|
27
|
+
) -> Any: ...
|
|
28
|
+
|
|
29
|
+
def transaction(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
isolation: str | None = None,
|
|
33
|
+
readonly: bool = False,
|
|
34
|
+
deferrable: bool = False,
|
|
35
|
+
) -> Any: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PoolLike(Protocol):
|
|
39
|
+
"""Pool surface required by the runtime."""
|
|
40
|
+
|
|
41
|
+
def acquire(self) -> Any: ...
|
|
42
|
+
|
|
43
|
+
async def close(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PoolFactory = Callable[..., Any]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class DatabaseConfig:
|
|
51
|
+
dsn: str
|
|
52
|
+
min_size: int = 1
|
|
53
|
+
max_size: int = 10
|
|
54
|
+
command_timeout: float | None = None
|
|
55
|
+
ssl: Any = None
|
|
56
|
+
statement_cache_size: int = 100
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DATABASE_CONFIG: DatabaseConfig | None = None
|
|
60
|
+
_POOL: PoolLike | None = None
|
|
61
|
+
_POOL_OWNED = False
|
|
62
|
+
_POOL_FACTORY: PoolFactory = asyncpg.create_pool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _encode_json(value: Any) -> str:
|
|
66
|
+
"""Accept native JSON values and legacy pre-serialized JSON strings."""
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
return value
|
|
69
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _initialize_connection(connection: asyncpg.Connection) -> None:
|
|
73
|
+
"""Decode PostgreSQL JSON/JSONB columns into native Python values."""
|
|
74
|
+
for type_name in ("json", "jsonb"):
|
|
75
|
+
await connection.set_type_codec(
|
|
76
|
+
type_name,
|
|
77
|
+
schema="pg_catalog",
|
|
78
|
+
encoder=_encode_json,
|
|
79
|
+
decoder=json.loads,
|
|
80
|
+
format="text",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_asyncpg_dsn(dsn: str) -> str:
|
|
85
|
+
"""Convert SQLAlchemy-style PostgreSQL URLs into asyncpg-compatible DSNs."""
|
|
86
|
+
normalized = dsn.strip()
|
|
87
|
+
if normalized.startswith("postgresql+asyncpg://"):
|
|
88
|
+
return "postgresql://" + normalized[len("postgresql+asyncpg://"):]
|
|
89
|
+
if normalized.startswith("postgres+asyncpg://"):
|
|
90
|
+
return "postgres://" + normalized[len("postgres+asyncpg://"):]
|
|
91
|
+
return normalized
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def configure_database(
|
|
95
|
+
*,
|
|
96
|
+
dsn: str,
|
|
97
|
+
min_size: int = 1,
|
|
98
|
+
max_size: int = 10,
|
|
99
|
+
command_timeout: float | None = None,
|
|
100
|
+
ssl: Any = None,
|
|
101
|
+
statement_cache_size: int = 100,
|
|
102
|
+
pool_factory: PoolFactory | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Register pool settings. Network connections are opened by ``open_database``."""
|
|
105
|
+
if _POOL is not None:
|
|
106
|
+
raise RuntimeError("cannot reconfigure python-mapper while its database pool is open")
|
|
107
|
+
if not dsn.strip():
|
|
108
|
+
raise ValueError("database dsn must not be empty")
|
|
109
|
+
if min_size < 0 or max_size < 1 or min_size > max_size:
|
|
110
|
+
raise ValueError("pool sizes must satisfy 0 <= min_size <= max_size")
|
|
111
|
+
global _DATABASE_CONFIG, _POOL_FACTORY
|
|
112
|
+
_DATABASE_CONFIG = DatabaseConfig(
|
|
113
|
+
dsn=normalize_asyncpg_dsn(dsn),
|
|
114
|
+
min_size=min_size,
|
|
115
|
+
max_size=max_size,
|
|
116
|
+
command_timeout=command_timeout,
|
|
117
|
+
ssl=ssl,
|
|
118
|
+
statement_cache_size=statement_cache_size,
|
|
119
|
+
)
|
|
120
|
+
if pool_factory is not None:
|
|
121
|
+
_POOL_FACTORY = pool_factory
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configure_pool(pool: PoolLike) -> None:
|
|
125
|
+
"""Inject an externally managed pool, primarily for tests and host integration."""
|
|
126
|
+
global _POOL, _POOL_OWNED
|
|
127
|
+
if _POOL is pool:
|
|
128
|
+
return
|
|
129
|
+
if _POOL is not None:
|
|
130
|
+
raise RuntimeError("python-mapper database pool is already configured")
|
|
131
|
+
_POOL = pool
|
|
132
|
+
_POOL_OWNED = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def open_database() -> PoolLike:
|
|
136
|
+
"""Open the configured asyncpg pool once during application startup."""
|
|
137
|
+
global _POOL, _POOL_OWNED
|
|
138
|
+
if _POOL is not None:
|
|
139
|
+
return _POOL
|
|
140
|
+
config = _DATABASE_CONFIG
|
|
141
|
+
if config is None:
|
|
142
|
+
raise RuntimeError("python-mapper database is not configured")
|
|
143
|
+
created = await _POOL_FACTORY(
|
|
144
|
+
dsn=config.dsn,
|
|
145
|
+
min_size=config.min_size,
|
|
146
|
+
max_size=config.max_size,
|
|
147
|
+
command_timeout=config.command_timeout,
|
|
148
|
+
ssl=config.ssl,
|
|
149
|
+
statement_cache_size=config.statement_cache_size,
|
|
150
|
+
init=_initialize_connection,
|
|
151
|
+
)
|
|
152
|
+
_POOL = cast(PoolLike, created)
|
|
153
|
+
_POOL_OWNED = True
|
|
154
|
+
return _POOL
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_pool() -> PoolLike:
|
|
158
|
+
"""Return the active pool or fail before any SQL is attempted."""
|
|
159
|
+
if _POOL is None:
|
|
160
|
+
raise RuntimeError("python-mapper database pool is not open; call open_database() at startup")
|
|
161
|
+
return _POOL
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@asynccontextmanager
|
|
165
|
+
async def acquire_raw_connection() -> AsyncGenerator[ConnectionLike]:
|
|
166
|
+
"""Borrow one connection without opening an explicit transaction."""
|
|
167
|
+
async with get_pool().acquire() as connection:
|
|
168
|
+
yield cast(ConnectionLike, connection)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def ping_database() -> bool:
|
|
172
|
+
"""Check the primary database through the same pool used by mappers."""
|
|
173
|
+
async with acquire_raw_connection() as connection:
|
|
174
|
+
return await connection.fetchval("SELECT 1") == 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def close_database(*, timeout: float = 10.0) -> None:
|
|
178
|
+
"""Close an owned pool during application shutdown."""
|
|
179
|
+
global _POOL, _POOL_OWNED
|
|
180
|
+
pool, owned = _POOL, _POOL_OWNED
|
|
181
|
+
_POOL = None
|
|
182
|
+
_POOL_OWNED = False
|
|
183
|
+
if pool is None or not owned:
|
|
184
|
+
return
|
|
185
|
+
await asyncio.wait_for(pool.close(), timeout=timeout)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def clear_database_configuration() -> None:
|
|
189
|
+
"""Clear unopened configuration and injected test pools."""
|
|
190
|
+
global _DATABASE_CONFIG, _POOL, _POOL_OWNED, _POOL_FACTORY
|
|
191
|
+
if _POOL is not None and _POOL_OWNED:
|
|
192
|
+
raise RuntimeError("close_database() must be awaited before resetting an owned pool")
|
|
193
|
+
_DATABASE_CONFIG = None
|
|
194
|
+
_POOL = None
|
|
195
|
+
_POOL_OWNED = False
|
|
196
|
+
_POOL_FACTORY = asyncpg.create_pool
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = [
|
|
200
|
+
"ConnectionLike",
|
|
201
|
+
"DatabaseConfig",
|
|
202
|
+
"PoolLike",
|
|
203
|
+
"acquire_raw_connection",
|
|
204
|
+
"clear_database_configuration",
|
|
205
|
+
"close_database",
|
|
206
|
+
"configure_database",
|
|
207
|
+
"configure_pool",
|
|
208
|
+
"get_pool",
|
|
209
|
+
"normalize_asyncpg_dsn",
|
|
210
|
+
"open_database",
|
|
211
|
+
"ping_database",
|
|
212
|
+
]
|
python_mapper/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Public exception types raised by python-mapper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PyMapperError(RuntimeError):
|
|
6
|
+
"""Base class for python-mapper runtime errors."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TooManyResultsError(PyMapperError):
|
|
10
|
+
"""A ``single=true`` statement returned more than one database row."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PaginationError(PyMapperError):
|
|
14
|
+
"""A paginated query declaration or invocation is invalid."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PaginationConflictError(PaginationError):
|
|
18
|
+
"""Framework pagination conflicts with a manual SQL pagination clause."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"PaginationConflictError",
|
|
23
|
+
"PaginationError",
|
|
24
|
+
"PyMapperError",
|
|
25
|
+
"TooManyResultsError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Application bootstrap for mapper discovery, validation and pool lifecycle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import logging
|
|
6
|
+
import pkgutil
|
|
7
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from python_mapper.database import (
|
|
15
|
+
PoolLike,
|
|
16
|
+
close_database,
|
|
17
|
+
configure_database,
|
|
18
|
+
configure_pool,
|
|
19
|
+
open_database,
|
|
20
|
+
ping_database,
|
|
21
|
+
)
|
|
22
|
+
from python_mapper.plugins import StatementPlugin
|
|
23
|
+
from python_mapper.runtime import configure_mapper_paths, configure_plugins, load_all_mappers
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class MapperStartupState:
|
|
30
|
+
"""Observable result of one mapper application startup."""
|
|
31
|
+
|
|
32
|
+
statement_count: int
|
|
33
|
+
database_ready: bool
|
|
34
|
+
database_error: Exception | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _import_mapper_package(package_ref: str | ModuleType) -> tuple[str, ...]:
|
|
38
|
+
package = importlib.import_module(package_ref) if isinstance(package_ref, str) else package_ref
|
|
39
|
+
imported = [package.__name__]
|
|
40
|
+
package_paths = getattr(package, "__path__", None)
|
|
41
|
+
if package_paths is None:
|
|
42
|
+
return tuple(imported)
|
|
43
|
+
prefix = f"{package.__name__}."
|
|
44
|
+
module_names = sorted(
|
|
45
|
+
module_info.name
|
|
46
|
+
for module_info in pkgutil.walk_packages(package_paths, prefix=prefix)
|
|
47
|
+
)
|
|
48
|
+
for module_name in module_names:
|
|
49
|
+
importlib.import_module(module_name)
|
|
50
|
+
imported.extend(module_names)
|
|
51
|
+
return tuple(imported)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PyMapperExtension:
|
|
55
|
+
"""Configure python-mapper once and own its application lifecycle.
|
|
56
|
+
|
|
57
|
+
Construction only records configuration; it never performs network I/O.
|
|
58
|
+
``lifespan()`` imports every configured mapper package, validates all XML,
|
|
59
|
+
opens the asyncpg pool and closes the owned pool on exit.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
mapper_paths: Sequence[str | Path],
|
|
66
|
+
mapper_packages: Sequence[str | ModuleType] = (),
|
|
67
|
+
database_url: str | None = None,
|
|
68
|
+
pool: PoolLike | None = None,
|
|
69
|
+
min_pool_size: int = 1,
|
|
70
|
+
max_pool_size: int = 10,
|
|
71
|
+
command_timeout: float | None = None,
|
|
72
|
+
ssl: Any = None,
|
|
73
|
+
statement_cache_size: int = 100,
|
|
74
|
+
plugins: Sequence[StatementPlugin] = (),
|
|
75
|
+
) -> None:
|
|
76
|
+
if (database_url is None) == (pool is None):
|
|
77
|
+
raise ValueError("PyMapperExtension requires exactly one of database_url or pool")
|
|
78
|
+
self._mapper_packages = tuple(mapper_packages)
|
|
79
|
+
self._imported_modules: tuple[str, ...] = ()
|
|
80
|
+
if pool is not None:
|
|
81
|
+
configure_pool(pool)
|
|
82
|
+
else:
|
|
83
|
+
configure_database(
|
|
84
|
+
dsn=database_url or "",
|
|
85
|
+
min_size=min_pool_size,
|
|
86
|
+
max_size=max_pool_size,
|
|
87
|
+
command_timeout=command_timeout,
|
|
88
|
+
ssl=ssl,
|
|
89
|
+
statement_cache_size=statement_cache_size,
|
|
90
|
+
)
|
|
91
|
+
configure_mapper_paths(tuple(mapper_paths))
|
|
92
|
+
configure_plugins(tuple(plugins))
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def imported_modules(self) -> tuple[str, ...]:
|
|
96
|
+
return self._imported_modules
|
|
97
|
+
|
|
98
|
+
def load_mappers(self) -> int:
|
|
99
|
+
"""Import configured packages and validate every mapper/XML contract."""
|
|
100
|
+
imported: list[str] = []
|
|
101
|
+
for package_ref in self._mapper_packages:
|
|
102
|
+
imported.extend(_import_mapper_package(package_ref))
|
|
103
|
+
self._imported_modules = tuple(dict.fromkeys(imported))
|
|
104
|
+
return load_all_mappers()
|
|
105
|
+
|
|
106
|
+
async def startup(self, *, require_database: bool = True) -> MapperStartupState:
|
|
107
|
+
"""Load mappers and open the database pool.
|
|
108
|
+
|
|
109
|
+
``require_database=False`` is intended for applications whose health
|
|
110
|
+
endpoint must report an unavailable database while the HTTP process stays
|
|
111
|
+
alive. XML/import errors always fail startup because they are code defects.
|
|
112
|
+
"""
|
|
113
|
+
statement_count = self.load_mappers()
|
|
114
|
+
try:
|
|
115
|
+
await open_database()
|
|
116
|
+
database_ready = await ping_database()
|
|
117
|
+
return MapperStartupState(statement_count, database_ready)
|
|
118
|
+
except Exception as error:
|
|
119
|
+
logger.exception("python-mapper database startup failed")
|
|
120
|
+
if require_database:
|
|
121
|
+
raise
|
|
122
|
+
return MapperStartupState(statement_count, False, error)
|
|
123
|
+
|
|
124
|
+
async def shutdown(self) -> None:
|
|
125
|
+
await close_database()
|
|
126
|
+
|
|
127
|
+
@asynccontextmanager
|
|
128
|
+
async def lifespan(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
require_database: bool = True,
|
|
132
|
+
) -> AsyncGenerator[MapperStartupState]:
|
|
133
|
+
"""Framework-neutral lifespan usable by FastAPI and other ASGI hosts."""
|
|
134
|
+
try:
|
|
135
|
+
yield await self.startup(require_database=require_database)
|
|
136
|
+
finally:
|
|
137
|
+
await self.shutdown()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = ["MapperStartupState", "PyMapperExtension"]
|
python_mapper/mapping.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""MyBatis-style result declarations, validation, and row materialization."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import inspect
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from dataclasses import fields, is_dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, NamedTuple
|
|
10
|
+
|
|
11
|
+
from python_mapper.errors import TooManyResultsError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResultSpec(NamedTuple):
|
|
15
|
+
dotted: str
|
|
16
|
+
column_map: dict[str, str] | None
|
|
17
|
+
single: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# XML result declarations and imported model types are owned by this module.
|
|
21
|
+
_RESULT_SPEC: dict[str, ResultSpec] = {}
|
|
22
|
+
_RESULT_MAP_DEFS: dict[str, tuple[str, dict[str, str]]] = {}
|
|
23
|
+
_TYPE_CACHE: dict[str, type] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clear_result_mappings() -> None:
|
|
27
|
+
"""Clear all XML-derived result declarations and resolved model classes."""
|
|
28
|
+
_RESULT_SPEC.clear()
|
|
29
|
+
_RESULT_MAP_DEFS.clear()
|
|
30
|
+
_TYPE_CACHE.clear()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_result_map(namespace: str, elem: ET.Element, file: Path) -> None:
|
|
34
|
+
"""Register one explicit ``resultMap`` declaration from mapper XML."""
|
|
35
|
+
map_id = elem.attrib.get("id")
|
|
36
|
+
dotted = elem.attrib.get("type")
|
|
37
|
+
if not map_id or not dotted:
|
|
38
|
+
raise ValueError(f"<resultMap> 必须带 id 与 type: {file}")
|
|
39
|
+
key = f"{namespace}.{map_id}"
|
|
40
|
+
if key in _RESULT_MAP_DEFS:
|
|
41
|
+
raise ValueError(f"<resultMap> id 重复: {key}")
|
|
42
|
+
column_map: dict[str, str] = {}
|
|
43
|
+
for sub in elem:
|
|
44
|
+
if sub.tag not in ("result", "id"):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"<resultMap {map_id}> 含不支持的子元素 <{sub.tag}> "
|
|
47
|
+
f"({file.name}; 只支持 <result>/<id>)"
|
|
48
|
+
)
|
|
49
|
+
column, property_name = sub.attrib.get("column"), sub.attrib.get("property")
|
|
50
|
+
if not column or not property_name:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"<resultMap {map_id}> 的 <{sub.tag}> 必须带 column 与 property "
|
|
53
|
+
f"({file.name})"
|
|
54
|
+
)
|
|
55
|
+
column_map[column] = property_name
|
|
56
|
+
_RESULT_MAP_DEFS[key] = (dotted, column_map)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_result_spec(
|
|
60
|
+
namespace: str,
|
|
61
|
+
full_id: str,
|
|
62
|
+
elem: ET.Element,
|
|
63
|
+
file: Path,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Register ``resultType``/``resultMap`` and strict single-row semantics."""
|
|
66
|
+
result_map = elem.attrib.get("resultMap")
|
|
67
|
+
result_type = elem.attrib.get("resultType")
|
|
68
|
+
single = str(elem.attrib.get("single", "")).lower() in ("1", "true", "yes")
|
|
69
|
+
if result_map and result_type:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"条目 '{full_id}' ({file.name}) 不能同时给 resultMap 与 resultType"
|
|
72
|
+
)
|
|
73
|
+
if result_map:
|
|
74
|
+
key = f"{namespace}.{result_map}"
|
|
75
|
+
if key not in _RESULT_MAP_DEFS:
|
|
76
|
+
available = sorted(
|
|
77
|
+
result_key.split(".")[-1]
|
|
78
|
+
for result_key in _RESULT_MAP_DEFS
|
|
79
|
+
if result_key.startswith(namespace + ".")
|
|
80
|
+
)
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"条目 '{full_id}' ({file.name}) 的 resultMap=\"{result_map}\" 未声明 "
|
|
83
|
+
f"(可用: {available})"
|
|
84
|
+
)
|
|
85
|
+
dotted, column_map = _RESULT_MAP_DEFS[key]
|
|
86
|
+
_RESULT_SPEC[full_id] = ResultSpec(dotted, column_map, single)
|
|
87
|
+
elif result_type:
|
|
88
|
+
_RESULT_SPEC[full_id] = ResultSpec(result_type, None, single)
|
|
89
|
+
elif single:
|
|
90
|
+
_RESULT_SPEC[full_id] = ResultSpec("", None, True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_type(dotted: str, full_id: str) -> type:
|
|
94
|
+
cached = _TYPE_CACHE.get(dotted)
|
|
95
|
+
if cached is not None:
|
|
96
|
+
return cached
|
|
97
|
+
module_path, _, class_name = dotted.rpartition(".")
|
|
98
|
+
if not module_path:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"条目 '{full_id}' 的 resultType '{dotted}' 不是合法点路径(要 模块.类名)"
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
model_class = getattr(importlib.import_module(module_path), class_name)
|
|
104
|
+
except (ImportError, AttributeError) as exc:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"条目 '{full_id}' 的 resultType '{dotted}' 无法导入: {exc}"
|
|
107
|
+
) from exc
|
|
108
|
+
if not inspect.isclass(model_class):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"条目 '{full_id}' 的 resultType '{dotted}' 指向的不是类: "
|
|
111
|
+
f"{type(model_class).__name__}"
|
|
112
|
+
)
|
|
113
|
+
_TYPE_CACHE[dotted] = model_class
|
|
114
|
+
return model_class
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _model_init_fields(model_class: type) -> frozenset[str] | None:
|
|
118
|
+
"""Return constructor fields; ``None`` means arbitrary kwargs are accepted."""
|
|
119
|
+
if is_dataclass(model_class):
|
|
120
|
+
return frozenset(field.name for field in fields(model_class) if field.init)
|
|
121
|
+
|
|
122
|
+
pydantic_fields = getattr(model_class, "model_fields", None)
|
|
123
|
+
if isinstance(pydantic_fields, dict):
|
|
124
|
+
return frozenset(pydantic_fields)
|
|
125
|
+
|
|
126
|
+
annotations: dict[str, Any] = {}
|
|
127
|
+
for base_class in reversed(model_class.__mro__):
|
|
128
|
+
annotations.update(getattr(base_class, "__annotations__", {}))
|
|
129
|
+
if annotations:
|
|
130
|
+
return frozenset(annotations)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
parameters = inspect.signature(model_class).parameters.values()
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return frozenset()
|
|
136
|
+
if any(parameter.kind is parameter.VAR_KEYWORD for parameter in parameters):
|
|
137
|
+
return None
|
|
138
|
+
return frozenset(
|
|
139
|
+
parameter.name
|
|
140
|
+
for parameter in parameters
|
|
141
|
+
if parameter.kind
|
|
142
|
+
in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _validate_result_spec(full_id: str, spec: ResultSpec) -> None:
|
|
147
|
+
"""Validate type paths and properties explicitly forced by ``resultMap``."""
|
|
148
|
+
if not spec.dotted:
|
|
149
|
+
return
|
|
150
|
+
model_class = _resolve_type(spec.dotted, full_id)
|
|
151
|
+
if spec.column_map is None:
|
|
152
|
+
return
|
|
153
|
+
model_fields = _model_init_fields(model_class)
|
|
154
|
+
if model_fields is None:
|
|
155
|
+
return
|
|
156
|
+
unknown_properties = sorted(set(spec.column_map.values()) - model_fields)
|
|
157
|
+
if unknown_properties:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"条目 '{full_id}' 的 resultMap 显式映射了模型 {spec.dotted} 不存在的属性 "
|
|
160
|
+
f"{unknown_properties} (可用: {sorted(model_fields)})"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validate_result_types() -> None:
|
|
165
|
+
"""Validate every result type after all mapper XML files have loaded."""
|
|
166
|
+
for full_id, spec in list(_RESULT_SPEC.items()):
|
|
167
|
+
_validate_result_spec(full_id, spec)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def shape_rows(full_id: str, rows: list[Any]) -> Any:
|
|
171
|
+
"""Auto-map known fields, ignore extra columns, and enforce result cardinality."""
|
|
172
|
+
spec = _RESULT_SPEC.get(full_id)
|
|
173
|
+
if spec is None:
|
|
174
|
+
return rows
|
|
175
|
+
if spec.dotted:
|
|
176
|
+
model_class = _resolve_type(spec.dotted, full_id)
|
|
177
|
+
_validate_result_spec(full_id, spec)
|
|
178
|
+
model_fields = _model_init_fields(model_class)
|
|
179
|
+
built = []
|
|
180
|
+
for row in rows:
|
|
181
|
+
row_data: dict[str, Any] = dict(row)
|
|
182
|
+
if spec.column_map is not None:
|
|
183
|
+
row_data = {
|
|
184
|
+
spec.column_map.get(column, column): value
|
|
185
|
+
for column, value in row_data.items()
|
|
186
|
+
}
|
|
187
|
+
if model_fields is not None:
|
|
188
|
+
row_data = {
|
|
189
|
+
field_name: value
|
|
190
|
+
for field_name, value in row_data.items()
|
|
191
|
+
if field_name in model_fields
|
|
192
|
+
}
|
|
193
|
+
try:
|
|
194
|
+
built.append(model_class(**row_data))
|
|
195
|
+
except TypeError as exc:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
f"条目 '{full_id}' 结果映射到 {spec.dotted} 失败: {exc}; "
|
|
198
|
+
f"实际填充字段={sorted(row_data)}"
|
|
199
|
+
) from exc
|
|
200
|
+
rows = built
|
|
201
|
+
if spec.single:
|
|
202
|
+
if len(rows) > 1:
|
|
203
|
+
raise TooManyResultsError(
|
|
204
|
+
f"条目 '{full_id}' 声明 single=true,但查询返回了 {len(rows)} 行"
|
|
205
|
+
)
|
|
206
|
+
return rows[0] if rows else None
|
|
207
|
+
return rows
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
__all__ = ["ResultSpec", "clear_result_mappings", "load_result_map", "load_result_spec", "shape_rows", "validate_result_types"]
|