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,139 @@
|
|
|
1
|
+
"""PostgreSQL XML mapper with an asyncpg pool and implicit transactions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from python_mapper import runtime
|
|
8
|
+
from python_mapper.base import (
|
|
9
|
+
MapperBase,
|
|
10
|
+
bind_connection,
|
|
11
|
+
current_connection,
|
|
12
|
+
require_connection,
|
|
13
|
+
transactional,
|
|
14
|
+
transactional_scope,
|
|
15
|
+
)
|
|
16
|
+
from python_mapper.database import (
|
|
17
|
+
ConnectionLike,
|
|
18
|
+
PoolLike,
|
|
19
|
+
acquire_raw_connection,
|
|
20
|
+
close_database,
|
|
21
|
+
configure_database,
|
|
22
|
+
configure_pool,
|
|
23
|
+
get_pool,
|
|
24
|
+
open_database,
|
|
25
|
+
ping_database,
|
|
26
|
+
)
|
|
27
|
+
from python_mapper.errors import (
|
|
28
|
+
PaginationConflictError,
|
|
29
|
+
PaginationError,
|
|
30
|
+
PyMapperError,
|
|
31
|
+
TooManyResultsError,
|
|
32
|
+
)
|
|
33
|
+
from python_mapper.extension import MapperStartupState, PyMapperExtension
|
|
34
|
+
from python_mapper.observability import SqlLoggingPlugin
|
|
35
|
+
from python_mapper.pagination import (
|
|
36
|
+
DEFAULT_PAGE_SIZE,
|
|
37
|
+
MAX_PAGE_SIZE,
|
|
38
|
+
Page,
|
|
39
|
+
PageMetadata,
|
|
40
|
+
PaginationOptions,
|
|
41
|
+
QueryResult,
|
|
42
|
+
)
|
|
43
|
+
from python_mapper.plugins import (
|
|
44
|
+
StatementContext,
|
|
45
|
+
StatementExecutor,
|
|
46
|
+
StatementPlugin,
|
|
47
|
+
StatementResult,
|
|
48
|
+
)
|
|
49
|
+
from python_mapper.runtime import (
|
|
50
|
+
AMapper,
|
|
51
|
+
amapper,
|
|
52
|
+
configure_mapper_paths,
|
|
53
|
+
configure_plugins,
|
|
54
|
+
load_all_mappers,
|
|
55
|
+
load_mapper,
|
|
56
|
+
query,
|
|
57
|
+
render_sql,
|
|
58
|
+
reset_state,
|
|
59
|
+
scalar,
|
|
60
|
+
validate_result_types,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__version__ = "0.3.0"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def configure(
|
|
67
|
+
*,
|
|
68
|
+
mapper_paths: Sequence[str | Path],
|
|
69
|
+
database_url: str | None = None,
|
|
70
|
+
pool: PoolLike | None = None,
|
|
71
|
+
min_pool_size: int = 1,
|
|
72
|
+
max_pool_size: int = 10,
|
|
73
|
+
command_timeout: float | None = None,
|
|
74
|
+
ssl=None,
|
|
75
|
+
statement_cache_size: int = 100,
|
|
76
|
+
plugins: Sequence[StatementPlugin] = (),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Bind one database backend and the application's XML roots."""
|
|
79
|
+
if (database_url is None) == (pool is None):
|
|
80
|
+
raise ValueError("configure requires exactly one of database_url or pool")
|
|
81
|
+
if pool is not None:
|
|
82
|
+
configure_pool(pool)
|
|
83
|
+
else:
|
|
84
|
+
configure_database(
|
|
85
|
+
dsn=database_url or "",
|
|
86
|
+
min_size=min_pool_size,
|
|
87
|
+
max_size=max_pool_size,
|
|
88
|
+
command_timeout=command_timeout,
|
|
89
|
+
ssl=ssl,
|
|
90
|
+
statement_cache_size=statement_cache_size,
|
|
91
|
+
)
|
|
92
|
+
configure_mapper_paths(tuple(mapper_paths))
|
|
93
|
+
configure_plugins(tuple(plugins))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = [
|
|
97
|
+
"AMapper",
|
|
98
|
+
"ConnectionLike",
|
|
99
|
+
"DEFAULT_PAGE_SIZE",
|
|
100
|
+
"MAX_PAGE_SIZE",
|
|
101
|
+
"MapperBase",
|
|
102
|
+
"MapperStartupState",
|
|
103
|
+
"Page",
|
|
104
|
+
"PageMetadata",
|
|
105
|
+
"PaginationConflictError",
|
|
106
|
+
"PaginationError",
|
|
107
|
+
"PaginationOptions",
|
|
108
|
+
"PoolLike",
|
|
109
|
+
"PyMapperError",
|
|
110
|
+
"PyMapperExtension",
|
|
111
|
+
"QueryResult",
|
|
112
|
+
"SqlLoggingPlugin",
|
|
113
|
+
"StatementContext",
|
|
114
|
+
"StatementExecutor",
|
|
115
|
+
"StatementPlugin",
|
|
116
|
+
"StatementResult",
|
|
117
|
+
"TooManyResultsError",
|
|
118
|
+
"__version__",
|
|
119
|
+
"amapper",
|
|
120
|
+
"acquire_raw_connection",
|
|
121
|
+
"bind_connection",
|
|
122
|
+
"close_database",
|
|
123
|
+
"configure",
|
|
124
|
+
"current_connection",
|
|
125
|
+
"get_pool",
|
|
126
|
+
"load_all_mappers",
|
|
127
|
+
"load_mapper",
|
|
128
|
+
"render_sql",
|
|
129
|
+
"open_database",
|
|
130
|
+
"ping_database",
|
|
131
|
+
"query",
|
|
132
|
+
"require_connection",
|
|
133
|
+
"reset_state",
|
|
134
|
+
"runtime",
|
|
135
|
+
"scalar",
|
|
136
|
+
"transactional",
|
|
137
|
+
"transactional_scope",
|
|
138
|
+
"validate_result_types",
|
|
139
|
+
]
|
python_mapper/base.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Implicit asyncpg connection and explicit transaction boundaries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from contextvars import ContextVar, Token
|
|
9
|
+
from typing import ParamSpec, TypeVar
|
|
10
|
+
|
|
11
|
+
from python_mapper.database import ConnectionLike, acquire_raw_connection
|
|
12
|
+
|
|
13
|
+
CallableParameters = ParamSpec("CallableParameters")
|
|
14
|
+
ReturnValue = TypeVar("ReturnValue")
|
|
15
|
+
|
|
16
|
+
_ACTIVE_CONNECTION: ContextVar[ConnectionLike | None] = ContextVar(
|
|
17
|
+
"mapper_active_connection", default=None
|
|
18
|
+
)
|
|
19
|
+
_ACTIVE_TRANSACTION_OPTIONS: ContextVar[tuple[str | None, bool] | None] = ContextVar(
|
|
20
|
+
"mapper_active_transaction_options", default=None
|
|
21
|
+
)
|
|
22
|
+
_VALID_ISOLATION_LEVELS = {"READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_isolation_level(value: str | None) -> str | None:
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
normalized = value.strip().upper().replace("_", " ")
|
|
29
|
+
if normalized not in _VALID_ISOLATION_LEVELS:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"isolation_level must be READ COMMITTED, REPEATABLE READ or SERIALIZABLE"
|
|
32
|
+
)
|
|
33
|
+
return normalized
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _asyncpg_isolation(value: str | None) -> str | None:
|
|
37
|
+
return value.lower().replace(" ", "_") if value is not None else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MapperBase:
|
|
41
|
+
"""Base contract for mappers that consume an implicit asyncpg connection."""
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def acquire_connection(cls) -> AsyncGenerator[ConnectionLike]:
|
|
46
|
+
"""Reuse an active transaction or borrow a connection for one statement."""
|
|
47
|
+
current = _ACTIVE_CONNECTION.get()
|
|
48
|
+
if current is not None:
|
|
49
|
+
yield current
|
|
50
|
+
return
|
|
51
|
+
async with acquire_raw_connection() as connection:
|
|
52
|
+
yield connection
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def transaction_scope(
|
|
57
|
+
cls,
|
|
58
|
+
*,
|
|
59
|
+
requires_new: bool = False,
|
|
60
|
+
isolation_level: str | None = None,
|
|
61
|
+
read_only: bool = False,
|
|
62
|
+
) -> AsyncGenerator[ConnectionLike]:
|
|
63
|
+
"""Open one transaction, reusing the current one for REQUIRED semantics."""
|
|
64
|
+
normalized_isolation = _normalize_isolation_level(isolation_level)
|
|
65
|
+
current = _ACTIVE_CONNECTION.get()
|
|
66
|
+
if current is not None and not requires_new:
|
|
67
|
+
active_options = _ACTIVE_TRANSACTION_OPTIONS.get()
|
|
68
|
+
if (
|
|
69
|
+
normalized_isolation is not None
|
|
70
|
+
and active_options is not None
|
|
71
|
+
and active_options[0] != normalized_isolation
|
|
72
|
+
):
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
"cannot change transaction isolation while joining an active transaction"
|
|
75
|
+
)
|
|
76
|
+
if read_only and active_options is not None and not active_options[1]:
|
|
77
|
+
raise RuntimeError("cannot join a read-write transaction as read-only")
|
|
78
|
+
yield current
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
async with acquire_raw_connection() as connection:
|
|
82
|
+
connection_token = _ACTIVE_CONNECTION.set(connection)
|
|
83
|
+
options_token = _ACTIVE_TRANSACTION_OPTIONS.set(
|
|
84
|
+
(normalized_isolation, read_only)
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
async with connection.transaction(
|
|
88
|
+
isolation=_asyncpg_isolation(normalized_isolation),
|
|
89
|
+
readonly=read_only,
|
|
90
|
+
):
|
|
91
|
+
yield connection
|
|
92
|
+
finally:
|
|
93
|
+
_ACTIVE_TRANSACTION_OPTIONS.reset(options_token)
|
|
94
|
+
_ACTIVE_CONNECTION.reset(connection_token)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def current_connection() -> ConnectionLike | None:
|
|
98
|
+
return _ACTIVE_CONNECTION.get()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def require_connection() -> ConnectionLike:
|
|
102
|
+
connection = current_connection()
|
|
103
|
+
if connection is None:
|
|
104
|
+
raise RuntimeError("当前调用不在 pymapper 事务上下文中")
|
|
105
|
+
return connection
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@asynccontextmanager
|
|
109
|
+
async def bind_connection(connection: ConnectionLike) -> AsyncGenerator[ConnectionLike]:
|
|
110
|
+
"""Borrow an externally managed connection without commit, rollback or close."""
|
|
111
|
+
connection_token: Token[ConnectionLike | None] = _ACTIVE_CONNECTION.set(connection)
|
|
112
|
+
options_token = _ACTIVE_TRANSACTION_OPTIONS.set((None, False))
|
|
113
|
+
try:
|
|
114
|
+
yield connection
|
|
115
|
+
finally:
|
|
116
|
+
_ACTIVE_TRANSACTION_OPTIONS.reset(options_token)
|
|
117
|
+
_ACTIVE_CONNECTION.reset(connection_token)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def transactional(
|
|
121
|
+
*,
|
|
122
|
+
propagation: str = "REQUIRED",
|
|
123
|
+
isolation_level: str | None = None,
|
|
124
|
+
read_only: bool = False,
|
|
125
|
+
):
|
|
126
|
+
"""Wrap an async service method in an asyncpg transaction.
|
|
127
|
+
|
|
128
|
+
``REQUIRED`` joins the task-local transaction. ``REQUIRES_NEW`` suspends it
|
|
129
|
+
and borrows another connection for an independent transaction.
|
|
130
|
+
|
|
131
|
+
Do not create child tasks that call mappers inside this boundary. ContextVars
|
|
132
|
+
are copied into child tasks, which would make concurrent tasks share one
|
|
133
|
+
asyncpg connection and fail with an InterfaceError or use it after release.
|
|
134
|
+
"""
|
|
135
|
+
normalized = propagation.strip().upper()
|
|
136
|
+
if normalized not in {"REQUIRED", "REQUIRES_NEW"}:
|
|
137
|
+
raise ValueError("propagation must be REQUIRED or REQUIRES_NEW")
|
|
138
|
+
normalized_isolation = _normalize_isolation_level(isolation_level)
|
|
139
|
+
|
|
140
|
+
def decorator(
|
|
141
|
+
func: Callable[CallableParameters, Awaitable[ReturnValue]],
|
|
142
|
+
) -> Callable[CallableParameters, Awaitable[ReturnValue]]:
|
|
143
|
+
if not inspect.iscoroutinefunction(func):
|
|
144
|
+
raise TypeError(
|
|
145
|
+
f"transactional 只能装饰 async 函数, {getattr(func, '__qualname__', func)!r} "
|
|
146
|
+
"是同步的"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@functools.wraps(func)
|
|
150
|
+
async def wrapper(
|
|
151
|
+
*args: CallableParameters.args,
|
|
152
|
+
**kwargs: CallableParameters.kwargs,
|
|
153
|
+
) -> ReturnValue:
|
|
154
|
+
async with MapperBase.transaction_scope(
|
|
155
|
+
requires_new=normalized == "REQUIRES_NEW",
|
|
156
|
+
isolation_level=normalized_isolation,
|
|
157
|
+
read_only=read_only,
|
|
158
|
+
):
|
|
159
|
+
return await func(*args, **kwargs)
|
|
160
|
+
|
|
161
|
+
return wrapper
|
|
162
|
+
|
|
163
|
+
return decorator
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
transactional_scope = MapperBase.transaction_scope
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"MapperBase",
|
|
171
|
+
"bind_connection",
|
|
172
|
+
"current_connection",
|
|
173
|
+
"require_connection",
|
|
174
|
+
"transactional",
|
|
175
|
+
"transactional_scope",
|
|
176
|
+
]
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Compile named XML parameters into asyncpg positional parameters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CompiledQuery:
|
|
10
|
+
sql: str
|
|
11
|
+
args: tuple[Any, ...]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_identifier_start(character: str) -> bool:
|
|
15
|
+
return character == "_" or character.isalpha()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_identifier_part(character: str) -> bool:
|
|
19
|
+
return character == "_" or character.isalnum()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_postgresql_identifier_part(character: str) -> bool:
|
|
23
|
+
"""PostgreSQL permits dollar signs after the first identifier character."""
|
|
24
|
+
return character == "$" or _is_identifier_part(character)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_collection_parameter(value: Any) -> bool:
|
|
28
|
+
return isinstance(value, (list, tuple, set, frozenset))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dollar_quote_delimiter(sql: str, offset: int) -> str | None:
|
|
32
|
+
if sql[offset] != "$":
|
|
33
|
+
return None
|
|
34
|
+
if offset > 0 and _is_postgresql_identifier_part(sql[offset - 1]):
|
|
35
|
+
return None
|
|
36
|
+
end = sql.find("$", offset + 1)
|
|
37
|
+
if end < 0:
|
|
38
|
+
return None
|
|
39
|
+
tag = sql[offset + 1:end]
|
|
40
|
+
if tag and (not _is_identifier_start(tag[0]) or not all(_is_identifier_part(c) for c in tag)):
|
|
41
|
+
return None
|
|
42
|
+
return sql[offset:end + 1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _skip_single_quoted_string(sql: str, quote_offset: int) -> int:
|
|
46
|
+
"""Return the first offset after a regular or PostgreSQL E-string."""
|
|
47
|
+
prefix_offset = quote_offset - 1
|
|
48
|
+
uses_backslash_escapes = (
|
|
49
|
+
prefix_offset >= 0
|
|
50
|
+
and sql[prefix_offset] in {"E", "e"}
|
|
51
|
+
and (
|
|
52
|
+
prefix_offset == 0
|
|
53
|
+
or not _is_postgresql_identifier_part(sql[prefix_offset - 1])
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
index = quote_offset + 1
|
|
57
|
+
length = len(sql)
|
|
58
|
+
while index < length:
|
|
59
|
+
if uses_backslash_escapes and sql[index] == "\\":
|
|
60
|
+
index = min(length, index + 2)
|
|
61
|
+
continue
|
|
62
|
+
if sql[index] == "'":
|
|
63
|
+
if index + 1 < length and sql[index + 1] == "'":
|
|
64
|
+
index += 2
|
|
65
|
+
continue
|
|
66
|
+
return index + 1
|
|
67
|
+
index += 1
|
|
68
|
+
return length
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _scan_parameters(
|
|
72
|
+
sql: str,
|
|
73
|
+
) -> tuple[list[tuple[int, int, str]], list[tuple[int, int, int]]]:
|
|
74
|
+
"""Find named and native positional binds outside non-executable regions."""
|
|
75
|
+
named: list[tuple[int, int, str]] = []
|
|
76
|
+
positional: list[tuple[int, int, int]] = []
|
|
77
|
+
index = 0
|
|
78
|
+
length = len(sql)
|
|
79
|
+
while index < length:
|
|
80
|
+
character = sql[index]
|
|
81
|
+
if character == "'":
|
|
82
|
+
index = _skip_single_quoted_string(sql, index)
|
|
83
|
+
continue
|
|
84
|
+
if character == '"':
|
|
85
|
+
index += 1
|
|
86
|
+
while index < length:
|
|
87
|
+
if sql[index] == '"':
|
|
88
|
+
if index + 1 < length and sql[index + 1] == '"':
|
|
89
|
+
index += 2
|
|
90
|
+
continue
|
|
91
|
+
index += 1
|
|
92
|
+
break
|
|
93
|
+
index += 1
|
|
94
|
+
continue
|
|
95
|
+
if sql.startswith("--", index):
|
|
96
|
+
newline = sql.find("\n", index + 2)
|
|
97
|
+
index = length if newline < 0 else newline + 1
|
|
98
|
+
continue
|
|
99
|
+
if sql.startswith("/*", index):
|
|
100
|
+
depth = 1
|
|
101
|
+
index += 2
|
|
102
|
+
while index < length and depth:
|
|
103
|
+
if sql.startswith("/*", index):
|
|
104
|
+
depth += 1
|
|
105
|
+
index += 2
|
|
106
|
+
elif sql.startswith("*/", index):
|
|
107
|
+
depth -= 1
|
|
108
|
+
index += 2
|
|
109
|
+
else:
|
|
110
|
+
index += 1
|
|
111
|
+
continue
|
|
112
|
+
delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
|
|
113
|
+
if delimiter is not None:
|
|
114
|
+
end = sql.find(delimiter, index + len(delimiter))
|
|
115
|
+
index = length if end < 0 else end + len(delimiter)
|
|
116
|
+
continue
|
|
117
|
+
if character == "$" and index + 1 < length and sql[index + 1].isdigit():
|
|
118
|
+
position_end = index + 2
|
|
119
|
+
while position_end < length and sql[position_end].isdigit():
|
|
120
|
+
position_end += 1
|
|
121
|
+
positional.append((index, position_end, int(sql[index + 1:position_end])))
|
|
122
|
+
index = position_end
|
|
123
|
+
continue
|
|
124
|
+
if (
|
|
125
|
+
character == ":"
|
|
126
|
+
and not sql.startswith("::", index)
|
|
127
|
+
and (index == 0 or sql[index - 1] != ":")
|
|
128
|
+
):
|
|
129
|
+
name_start = index + 1
|
|
130
|
+
if name_start < length and _is_identifier_start(sql[name_start]):
|
|
131
|
+
name_end = name_start + 1
|
|
132
|
+
while name_end < length and _is_identifier_part(sql[name_end]):
|
|
133
|
+
name_end += 1
|
|
134
|
+
named.append((index, name_end, sql[name_start:name_end]))
|
|
135
|
+
index = name_end
|
|
136
|
+
continue
|
|
137
|
+
index += 1
|
|
138
|
+
return named, positional
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scan_named_parameters(sql: str) -> list[tuple[int, int, str]]:
|
|
142
|
+
return _scan_parameters(sql)[0]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def named_parameter_names(sql: str) -> set[str]:
|
|
146
|
+
return {name for _, _, name in _scan_named_parameters(sql)}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def positional_parameter_numbers(sql: str) -> set[int]:
|
|
150
|
+
"""Return native asyncpg ``$n`` binds outside literals and comments."""
|
|
151
|
+
return {number for _, _, number in _scan_parameters(sql)[1]}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def sql_token_parenthesis_depths(sql: str, token: str) -> tuple[int, ...]:
|
|
155
|
+
"""Return parenthesis depths for an exact token in executable SQL regions."""
|
|
156
|
+
if not token:
|
|
157
|
+
raise ValueError("token must not be empty")
|
|
158
|
+
depths: list[int] = []
|
|
159
|
+
index = 0
|
|
160
|
+
depth = 0
|
|
161
|
+
length = len(sql)
|
|
162
|
+
while index < length:
|
|
163
|
+
if sql.startswith(token, index):
|
|
164
|
+
depths.append(depth)
|
|
165
|
+
index += len(token)
|
|
166
|
+
continue
|
|
167
|
+
character = sql[index]
|
|
168
|
+
if character == "'":
|
|
169
|
+
index = _skip_single_quoted_string(sql, index)
|
|
170
|
+
continue
|
|
171
|
+
if character == '"':
|
|
172
|
+
index += 1
|
|
173
|
+
while index < length:
|
|
174
|
+
if sql[index] == '"':
|
|
175
|
+
if index + 1 < length and sql[index + 1] == '"':
|
|
176
|
+
index += 2
|
|
177
|
+
continue
|
|
178
|
+
index += 1
|
|
179
|
+
break
|
|
180
|
+
index += 1
|
|
181
|
+
continue
|
|
182
|
+
if sql.startswith("--", index):
|
|
183
|
+
newline = sql.find("\n", index + 2)
|
|
184
|
+
index = length if newline < 0 else newline + 1
|
|
185
|
+
continue
|
|
186
|
+
if sql.startswith("/*", index):
|
|
187
|
+
comment_depth = 1
|
|
188
|
+
index += 2
|
|
189
|
+
while index < length and comment_depth:
|
|
190
|
+
if sql.startswith("/*", index):
|
|
191
|
+
comment_depth += 1
|
|
192
|
+
index += 2
|
|
193
|
+
elif sql.startswith("*/", index):
|
|
194
|
+
comment_depth -= 1
|
|
195
|
+
index += 2
|
|
196
|
+
else:
|
|
197
|
+
index += 1
|
|
198
|
+
continue
|
|
199
|
+
delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
|
|
200
|
+
if delimiter is not None:
|
|
201
|
+
end = sql.find(delimiter, index + len(delimiter))
|
|
202
|
+
index = length if end < 0 else end + len(delimiter)
|
|
203
|
+
continue
|
|
204
|
+
if character == "(":
|
|
205
|
+
depth += 1
|
|
206
|
+
elif character == ")" and depth:
|
|
207
|
+
depth -= 1
|
|
208
|
+
index += 1
|
|
209
|
+
return tuple(depths)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def compile_query(sql: str, parameters: dict[str, Any]) -> CompiledQuery:
|
|
213
|
+
"""Compile ``:name`` parameters and SQLAlchemy-style collection expansion."""
|
|
214
|
+
occurrences = _scan_named_parameters(sql)
|
|
215
|
+
missing = sorted({name for _, _, name in occurrences if name not in parameters})
|
|
216
|
+
if missing:
|
|
217
|
+
raise ValueError(f"SQL 缺少绑定参数: {missing}")
|
|
218
|
+
|
|
219
|
+
args: list[Any] = []
|
|
220
|
+
placeholders: dict[str, str] = {}
|
|
221
|
+
output: list[str] = []
|
|
222
|
+
cursor = 0
|
|
223
|
+
for start, end, name in occurrences:
|
|
224
|
+
output.append(sql[cursor:start])
|
|
225
|
+
placeholder = placeholders.get(name)
|
|
226
|
+
if placeholder is None:
|
|
227
|
+
value = parameters[name]
|
|
228
|
+
if _is_collection_parameter(value):
|
|
229
|
+
values = list(value)
|
|
230
|
+
if values:
|
|
231
|
+
positions = []
|
|
232
|
+
for item in values:
|
|
233
|
+
args.append(item)
|
|
234
|
+
positions.append(f"${len(args)}")
|
|
235
|
+
placeholder = f"({', '.join(positions)})"
|
|
236
|
+
else:
|
|
237
|
+
placeholder = "(SELECT NULL WHERE FALSE)"
|
|
238
|
+
else:
|
|
239
|
+
args.append(value)
|
|
240
|
+
placeholder = f"${len(args)}"
|
|
241
|
+
placeholders[name] = placeholder
|
|
242
|
+
output.append(placeholder)
|
|
243
|
+
cursor = end
|
|
244
|
+
output.append(sql[cursor:])
|
|
245
|
+
return CompiledQuery("".join(output), tuple(args))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def contains_sql_keyword(sql: str, keyword: str) -> bool:
|
|
249
|
+
"""Check a keyword outside SQL literals and comments."""
|
|
250
|
+
import re
|
|
251
|
+
|
|
252
|
+
executable = []
|
|
253
|
+
cursor = 0
|
|
254
|
+
for start, end, _ in _scan_named_parameters(sql):
|
|
255
|
+
executable.append(sql[cursor:start])
|
|
256
|
+
executable.append(" ")
|
|
257
|
+
cursor = end
|
|
258
|
+
executable.append(sql[cursor:])
|
|
259
|
+
text = "".join(executable)
|
|
260
|
+
text = re.sub(r"'(?:''|[^'])*'", " ", text)
|
|
261
|
+
text = re.sub(r'"(?:""|[^"])*"', " ", text)
|
|
262
|
+
text = re.sub(r"--[^\n]*", " ", text)
|
|
263
|
+
text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
|
|
264
|
+
return re.search(rf"\b{re.escape(keyword)}\b", text, re.IGNORECASE) is not None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def top_level_sql_word_positions(sql: str) -> tuple[tuple[str, int], ...]:
|
|
268
|
+
"""Return executable words and offsets at parenthesis depth zero.
|
|
269
|
+
|
|
270
|
+
Strings, quoted identifiers, comments and PostgreSQL dollar-quoted bodies are
|
|
271
|
+
skipped. This is deliberately a lexer, not a SQL parser; it is sufficient for
|
|
272
|
+
detecting outer LIMIT/OFFSET/ORDER BY without rejecting limits in subqueries.
|
|
273
|
+
"""
|
|
274
|
+
words: list[tuple[str, int]] = []
|
|
275
|
+
index = 0
|
|
276
|
+
depth = 0
|
|
277
|
+
length = len(sql)
|
|
278
|
+
while index < length:
|
|
279
|
+
character = sql[index]
|
|
280
|
+
if character == "'":
|
|
281
|
+
index = _skip_single_quoted_string(sql, index)
|
|
282
|
+
continue
|
|
283
|
+
if character == '"':
|
|
284
|
+
index += 1
|
|
285
|
+
while index < length:
|
|
286
|
+
if sql[index] == '"':
|
|
287
|
+
if index + 1 < length and sql[index + 1] == '"':
|
|
288
|
+
index += 2
|
|
289
|
+
continue
|
|
290
|
+
index += 1
|
|
291
|
+
break
|
|
292
|
+
index += 1
|
|
293
|
+
continue
|
|
294
|
+
if sql.startswith("--", index):
|
|
295
|
+
newline = sql.find("\n", index + 2)
|
|
296
|
+
index = length if newline < 0 else newline + 1
|
|
297
|
+
continue
|
|
298
|
+
if sql.startswith("/*", index):
|
|
299
|
+
comment_depth = 1
|
|
300
|
+
index += 2
|
|
301
|
+
while index < length and comment_depth:
|
|
302
|
+
if sql.startswith("/*", index):
|
|
303
|
+
comment_depth += 1
|
|
304
|
+
index += 2
|
|
305
|
+
elif sql.startswith("*/", index):
|
|
306
|
+
comment_depth -= 1
|
|
307
|
+
index += 2
|
|
308
|
+
else:
|
|
309
|
+
index += 1
|
|
310
|
+
continue
|
|
311
|
+
delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
|
|
312
|
+
if delimiter is not None:
|
|
313
|
+
end = sql.find(delimiter, index + len(delimiter))
|
|
314
|
+
index = length if end < 0 else end + len(delimiter)
|
|
315
|
+
continue
|
|
316
|
+
if character == "(":
|
|
317
|
+
depth += 1
|
|
318
|
+
index += 1
|
|
319
|
+
continue
|
|
320
|
+
if character == ")":
|
|
321
|
+
depth = max(0, depth - 1)
|
|
322
|
+
index += 1
|
|
323
|
+
continue
|
|
324
|
+
if _is_identifier_start(character):
|
|
325
|
+
end = index + 1
|
|
326
|
+
while end < length and _is_postgresql_identifier_part(sql[end]):
|
|
327
|
+
end += 1
|
|
328
|
+
if depth == 0:
|
|
329
|
+
words.append((sql[index:end].upper(), index))
|
|
330
|
+
index = end
|
|
331
|
+
continue
|
|
332
|
+
index += 1
|
|
333
|
+
return tuple(words)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def top_level_sql_words(sql: str) -> tuple[str, ...]:
|
|
337
|
+
"""Return executable words at parenthesis depth zero."""
|
|
338
|
+
return tuple(word for word, _ in top_level_sql_word_positions(sql))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def contains_top_level_keyword(sql: str, keyword: str) -> bool:
|
|
342
|
+
return keyword.upper() in top_level_sql_words(sql)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def contains_top_level_sequence(sql: str, *keywords: str) -> bool:
|
|
346
|
+
expected = tuple(keyword.upper() for keyword in keywords)
|
|
347
|
+
if not expected:
|
|
348
|
+
return False
|
|
349
|
+
words = top_level_sql_words(sql)
|
|
350
|
+
width = len(expected)
|
|
351
|
+
return any(words[index:index + width] == expected for index in range(len(words) - width + 1))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
__all__ = [
|
|
355
|
+
"CompiledQuery",
|
|
356
|
+
"compile_query",
|
|
357
|
+
"contains_sql_keyword",
|
|
358
|
+
"contains_top_level_keyword",
|
|
359
|
+
"contains_top_level_sequence",
|
|
360
|
+
"named_parameter_names",
|
|
361
|
+
"positional_parameter_numbers",
|
|
362
|
+
"sql_token_parenthesis_depths",
|
|
363
|
+
"top_level_sql_word_positions",
|
|
364
|
+
"top_level_sql_words",
|
|
365
|
+
]
|