pgtern 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.
- pgtern/__init__.py +46 -0
- pgtern/_binary.py +226 -0
- pgtern/_composite.py +103 -0
- pgtern/_conn.py +907 -0
- pgtern/_copy.py +186 -0
- pgtern/_cursor.py +261 -0
- pgtern/_dsn.py +182 -0
- pgtern/_protocol.py +258 -0
- pgtern/_query.py +69 -0
- pgtern/_scram.py +101 -0
- pgtern/_types.py +442 -0
- pgtern/adapters.py +85 -0
- pgtern/aio.py +1287 -0
- pgtern/errors.py +248 -0
- pgtern/hooks.py +53 -0
- pgtern/pipeline.py +202 -0
- pgtern/pool.py +309 -0
- pgtern/py.typed +0 -0
- pgtern/rows.py +31 -0
- pgtern/sql.py +200 -0
- pgtern/types/__init__.py +30 -0
- pgtern/types/json.py +5 -0
- pgtern-0.3.0.dist-info/METADATA +265 -0
- pgtern-0.3.0.dist-info/RECORD +27 -0
- pgtern-0.3.0.dist-info/WHEEL +5 -0
- pgtern-0.3.0.dist-info/licenses/LICENSE +202 -0
- pgtern-0.3.0.dist-info/top_level.txt +1 -0
pgtern/pool.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""A synchronous, thread-safe connection pool. Built in: no separate package to install."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Any, Callable, Iterator
|
|
11
|
+
|
|
12
|
+
from . import errors
|
|
13
|
+
from ._conn import Connection
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("pgtern.pool")
|
|
16
|
+
|
|
17
|
+
__all__ = ["ConnectionPool", "PoolClosed", "PoolTimeout"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PoolClosed(errors.OperationalError):
|
|
21
|
+
"""The pool was closed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PoolTimeout(errors.OperationalError):
|
|
25
|
+
"""No connection became available in time."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Idle:
|
|
29
|
+
__slots__ = ("conn", "returned")
|
|
30
|
+
|
|
31
|
+
def __init__(self, conn: Connection, returned: float) -> None:
|
|
32
|
+
self.conn = conn
|
|
33
|
+
self.returned = returned
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConnectionPool:
|
|
37
|
+
"""Pool of :class:`Connection`.
|
|
38
|
+
|
|
39
|
+
``min_size`` connections are kept open by a background thread (which also retires
|
|
40
|
+
connections older than ``max_lifetime`` and surplus ones idle longer than ``max_idle``);
|
|
41
|
+
up to ``max_size`` are opened on demand. A connection is checked for liveness
|
|
42
|
+
(no round trip) every time it is handed out, a failed connection is replaced, and
|
|
43
|
+
a connection returned mid-transaction is rolled back.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, conninfo: str = "", *, kwargs: dict[str, Any] | None = None, min_size: int = 4,
|
|
47
|
+
max_size: int | None = None, open: bool = True, timeout: float = 30.0,
|
|
48
|
+
max_lifetime: float = 3600.0, max_idle: float = 600.0,
|
|
49
|
+
check: Callable[[Connection], None] | None = None, name: str | None = None,
|
|
50
|
+
connection_class: type[Connection] = Connection) -> None:
|
|
51
|
+
max_size = min_size if max_size is None else max_size
|
|
52
|
+
if min_size < 0 or max_size < 1 or max_size < min_size:
|
|
53
|
+
raise ValueError(f"bad pool sizes: min_size={min_size}, max_size={max_size}")
|
|
54
|
+
self.conninfo = conninfo
|
|
55
|
+
self.kwargs = dict(kwargs or {})
|
|
56
|
+
self.min_size, self.max_size = min_size, max_size
|
|
57
|
+
self.timeout, self.max_lifetime, self.max_idle = timeout, max_lifetime, max_idle
|
|
58
|
+
self.name = name or f"pool-{id(self):x}"
|
|
59
|
+
self._check = check
|
|
60
|
+
self._connection_class = connection_class
|
|
61
|
+
self._default_autocommit = bool(self.kwargs.get("autocommit", False))
|
|
62
|
+
self._cond = threading.Condition()
|
|
63
|
+
self._idle: collections.deque[_Idle] = collections.deque()
|
|
64
|
+
self._n = 0 # open + being opened
|
|
65
|
+
self._connecting = 0
|
|
66
|
+
self._waiting = 0
|
|
67
|
+
self._closed = True
|
|
68
|
+
self._thread: threading.Thread | None = None
|
|
69
|
+
self._wake = threading.Event()
|
|
70
|
+
if open:
|
|
71
|
+
self.open()
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- lifecycle
|
|
74
|
+
def open(self, wait: bool = False, timeout: float = 30.0) -> None:
|
|
75
|
+
with self._cond:
|
|
76
|
+
if not self._closed:
|
|
77
|
+
return
|
|
78
|
+
self._closed = False
|
|
79
|
+
self._wake.clear()
|
|
80
|
+
self._thread = threading.Thread(target=self._maintain, name=f"pgtern-{self.name}", daemon=True)
|
|
81
|
+
self._thread.start()
|
|
82
|
+
if wait:
|
|
83
|
+
self.wait(timeout)
|
|
84
|
+
|
|
85
|
+
def wait(self, timeout: float = 30.0) -> None:
|
|
86
|
+
"""Block until ``min_size`` connections are open."""
|
|
87
|
+
deadline = time.monotonic() + timeout
|
|
88
|
+
with self._cond:
|
|
89
|
+
while self._n - self._connecting < self.min_size:
|
|
90
|
+
if self._closed:
|
|
91
|
+
raise PoolClosed("the pool is closed")
|
|
92
|
+
remaining = deadline - time.monotonic()
|
|
93
|
+
if remaining <= 0:
|
|
94
|
+
raise PoolTimeout(f"pool initialization incomplete after {timeout:.2f} sec")
|
|
95
|
+
self._cond.wait(min(remaining, 0.2))
|
|
96
|
+
|
|
97
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
98
|
+
with self._cond:
|
|
99
|
+
if self._closed:
|
|
100
|
+
return
|
|
101
|
+
self._closed = True
|
|
102
|
+
idle = list(self._idle)
|
|
103
|
+
self._idle.clear()
|
|
104
|
+
self._n -= len(idle)
|
|
105
|
+
self._cond.notify_all()
|
|
106
|
+
self._wake.set()
|
|
107
|
+
for item in idle:
|
|
108
|
+
item.conn.close()
|
|
109
|
+
thread = self._thread
|
|
110
|
+
if thread is not None and thread is not threading.current_thread():
|
|
111
|
+
thread.join(timeout)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def closed(self) -> bool:
|
|
115
|
+
return self._closed
|
|
116
|
+
|
|
117
|
+
def __enter__(self) -> "ConnectionPool":
|
|
118
|
+
self.open()
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc: Any) -> None:
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
def get_stats(self) -> dict[str, int]:
|
|
125
|
+
with self._cond:
|
|
126
|
+
return {
|
|
127
|
+
"pool_min": self.min_size, "pool_max": self.max_size,
|
|
128
|
+
"pool_size": self._n - self._connecting, "pool_available": len(self._idle),
|
|
129
|
+
"requests_waiting": self._waiting,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- internals
|
|
133
|
+
def _connect(self) -> Connection:
|
|
134
|
+
conn = self._connection_class.connect(self.conninfo, **self.kwargs)
|
|
135
|
+
conn._pool_created = time.monotonic() # type: ignore[attr-defined]
|
|
136
|
+
return conn
|
|
137
|
+
|
|
138
|
+
def _reap(self) -> list[Connection]:
|
|
139
|
+
"""Pick idle connections to retire. Caller holds the lock and closes the result."""
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
keep: collections.deque[_Idle] = collections.deque()
|
|
142
|
+
retire: list[Connection] = []
|
|
143
|
+
for item in self._idle:
|
|
144
|
+
conn = item.conn
|
|
145
|
+
too_old = now - conn._pool_created > self.max_lifetime # type: ignore[attr-defined]
|
|
146
|
+
surplus_idle = now - item.returned > self.max_idle and self._n - len(retire) > self.min_size
|
|
147
|
+
if conn.closed or too_old or surplus_idle:
|
|
148
|
+
retire.append(conn)
|
|
149
|
+
else:
|
|
150
|
+
keep.append(item)
|
|
151
|
+
self._idle = keep
|
|
152
|
+
self._n -= len(retire)
|
|
153
|
+
return retire
|
|
154
|
+
|
|
155
|
+
def _maintain(self) -> None:
|
|
156
|
+
backoff = 0.5
|
|
157
|
+
while True:
|
|
158
|
+
create = False
|
|
159
|
+
with self._cond:
|
|
160
|
+
if self._closed:
|
|
161
|
+
return
|
|
162
|
+
retire = self._reap()
|
|
163
|
+
if self._n < self.min_size:
|
|
164
|
+
self._n += 1
|
|
165
|
+
self._connecting += 1
|
|
166
|
+
create = True
|
|
167
|
+
for conn in retire:
|
|
168
|
+
conn.close()
|
|
169
|
+
if create:
|
|
170
|
+
try:
|
|
171
|
+
conn = self._connect()
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
with self._cond:
|
|
174
|
+
self._n -= 1
|
|
175
|
+
self._connecting -= 1
|
|
176
|
+
log.warning("pool %s: couldn't open a connection: %s", self.name, exc)
|
|
177
|
+
self._wake.wait(backoff)
|
|
178
|
+
self._wake.clear()
|
|
179
|
+
backoff = min(backoff * 2, 30.0)
|
|
180
|
+
continue
|
|
181
|
+
backoff = 0.5
|
|
182
|
+
with self._cond:
|
|
183
|
+
self._connecting -= 1
|
|
184
|
+
stored = not self._closed
|
|
185
|
+
if stored:
|
|
186
|
+
self._idle.append(_Idle(conn, time.monotonic()))
|
|
187
|
+
else:
|
|
188
|
+
self._n -= 1
|
|
189
|
+
self._cond.notify_all()
|
|
190
|
+
if not stored:
|
|
191
|
+
conn.close()
|
|
192
|
+
continue
|
|
193
|
+
self._wake.wait(5.0)
|
|
194
|
+
self._wake.clear()
|
|
195
|
+
|
|
196
|
+
def _usable(self, conn: Connection) -> bool:
|
|
197
|
+
if conn.closed or time.monotonic() - conn._pool_created > self.max_lifetime: # type: ignore[attr-defined]
|
|
198
|
+
return False
|
|
199
|
+
if not conn._check_alive():
|
|
200
|
+
return False
|
|
201
|
+
if self._check is not None:
|
|
202
|
+
try:
|
|
203
|
+
self._check(conn)
|
|
204
|
+
except Exception:
|
|
205
|
+
return False
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def _discard(self, conn: Connection) -> None:
|
|
209
|
+
with self._cond:
|
|
210
|
+
self._n -= 1
|
|
211
|
+
self._cond.notify_all()
|
|
212
|
+
self._wake.set()
|
|
213
|
+
conn.close()
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------- public API
|
|
216
|
+
def getconn(self, timeout: float | None = None) -> Connection:
|
|
217
|
+
timeout = self.timeout if timeout is None else timeout
|
|
218
|
+
deadline = time.monotonic() + timeout
|
|
219
|
+
backoff = 0.1
|
|
220
|
+
while True:
|
|
221
|
+
candidate: _Idle | None = None
|
|
222
|
+
create = False
|
|
223
|
+
with self._cond:
|
|
224
|
+
if self._closed:
|
|
225
|
+
raise PoolClosed("the pool is closed")
|
|
226
|
+
if self._idle:
|
|
227
|
+
candidate = self._idle.pop()
|
|
228
|
+
elif self._n < self.max_size:
|
|
229
|
+
self._n += 1
|
|
230
|
+
self._connecting += 1
|
|
231
|
+
create = True
|
|
232
|
+
else:
|
|
233
|
+
remaining = deadline - time.monotonic()
|
|
234
|
+
if remaining <= 0:
|
|
235
|
+
raise PoolTimeout(f"couldn't get a connection after {timeout:.2f} sec")
|
|
236
|
+
self._waiting += 1
|
|
237
|
+
try:
|
|
238
|
+
self._cond.wait(remaining)
|
|
239
|
+
finally:
|
|
240
|
+
self._waiting -= 1
|
|
241
|
+
continue
|
|
242
|
+
if candidate is not None:
|
|
243
|
+
if self._usable(candidate.conn):
|
|
244
|
+
return candidate.conn
|
|
245
|
+
self._discard(candidate.conn)
|
|
246
|
+
continue
|
|
247
|
+
assert create
|
|
248
|
+
try:
|
|
249
|
+
conn = self._connect()
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
with self._cond:
|
|
252
|
+
self._n -= 1
|
|
253
|
+
self._connecting -= 1
|
|
254
|
+
self._cond.notify_all()
|
|
255
|
+
remaining = deadline - time.monotonic()
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
raise PoolTimeout(f"couldn't get a connection after {timeout:.2f} sec: {exc}") from exc
|
|
258
|
+
time.sleep(min(backoff, remaining))
|
|
259
|
+
backoff = min(backoff * 2, 2.0)
|
|
260
|
+
continue
|
|
261
|
+
with self._cond:
|
|
262
|
+
self._connecting -= 1
|
|
263
|
+
closed = self._closed
|
|
264
|
+
if closed:
|
|
265
|
+
self._n -= 1
|
|
266
|
+
if closed:
|
|
267
|
+
conn.close()
|
|
268
|
+
raise PoolClosed("the pool is closed")
|
|
269
|
+
return conn
|
|
270
|
+
|
|
271
|
+
def putconn(self, conn: Connection) -> None:
|
|
272
|
+
ok = not conn.closed
|
|
273
|
+
if ok:
|
|
274
|
+
try:
|
|
275
|
+
conn._reset_for_pool()
|
|
276
|
+
if conn.autocommit != self._default_autocommit:
|
|
277
|
+
conn.autocommit = self._default_autocommit
|
|
278
|
+
except Exception:
|
|
279
|
+
ok = False
|
|
280
|
+
with self._cond:
|
|
281
|
+
fresh = time.monotonic() - conn._pool_created <= self.max_lifetime # type: ignore[attr-defined]
|
|
282
|
+
if ok and fresh and not self._closed:
|
|
283
|
+
self._idle.append(_Idle(conn, time.monotonic()))
|
|
284
|
+
self._cond.notify_all()
|
|
285
|
+
return
|
|
286
|
+
self._n -= 1
|
|
287
|
+
self._cond.notify_all()
|
|
288
|
+
self._wake.set()
|
|
289
|
+
conn.close()
|
|
290
|
+
|
|
291
|
+
@contextmanager
|
|
292
|
+
def connection(self, timeout: float | None = None) -> Iterator[Connection]:
|
|
293
|
+
"""Borrow a connection: commit on success, roll back on error, return it to the pool."""
|
|
294
|
+
conn = self.getconn(timeout)
|
|
295
|
+
try:
|
|
296
|
+
try:
|
|
297
|
+
yield conn
|
|
298
|
+
except BaseException:
|
|
299
|
+
try:
|
|
300
|
+
if not conn.closed:
|
|
301
|
+
conn._reset_for_pool()
|
|
302
|
+
except Exception:
|
|
303
|
+
log.warning("rollback failed while returning a connection", exc_info=True)
|
|
304
|
+
raise
|
|
305
|
+
else:
|
|
306
|
+
if not conn.closed:
|
|
307
|
+
conn.commit()
|
|
308
|
+
finally:
|
|
309
|
+
self.putconn(conn)
|
pgtern/py.typed
ADDED
|
File without changes
|
pgtern/rows.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Row factories: callables taking a cursor and returning a ``make_row(values)`` function."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
__all__ = ["tuple_row", "dict_row", "namedtuple_row"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _identity(values: tuple) -> tuple:
|
|
12
|
+
return values
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tuple_row(cursor: Any) -> Callable[[tuple], tuple]:
|
|
16
|
+
"""Rows as plain tuples (the default)."""
|
|
17
|
+
return _identity
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dict_row(cursor: Any) -> Callable[[tuple], dict[str, Any]]:
|
|
21
|
+
"""Rows as ``dict`` keyed by column name (a repeated name keeps the last value)."""
|
|
22
|
+
names = [c.name for c in (cursor.description or ())]
|
|
23
|
+
return lambda values: dict(zip(names, values))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def namedtuple_row(cursor: Any) -> Callable[[tuple], Any]:
|
|
27
|
+
"""Rows as ``namedtuple`` instances (unusable names are renamed ``_0``, ``_1``, ...)."""
|
|
28
|
+
names = [c.name for c in (cursor.description or ())]
|
|
29
|
+
factory: Any = namedtuple # typed as Any: the field list is only known at run time
|
|
30
|
+
row_type = factory("Row", names, rename=True)
|
|
31
|
+
return row_type._make
|
pgtern/sql.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Safe composition of SQL text: ``SQL``, ``Identifier``, ``Literal``, ``Placeholder``.
|
|
3
|
+
|
|
4
|
+
Use this for statements that cannot take bind parameters (``CREATE ROLE ... PASSWORD``,
|
|
5
|
+
``GRANT ... TO <role>``). Everything else should use ``%s`` parameters.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import binascii
|
|
10
|
+
import string
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import date, datetime, time, timedelta
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Iterable, Iterator
|
|
15
|
+
|
|
16
|
+
from . import errors
|
|
17
|
+
from .types import Json, Jsonb
|
|
18
|
+
|
|
19
|
+
__all__ = ["Composable", "Composed", "SQL", "Identifier", "Literal", "Placeholder",
|
|
20
|
+
"quote_ident", "quote_literal"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def quote_ident(name: str) -> str:
|
|
24
|
+
if "\x00" in name:
|
|
25
|
+
raise errors.DataError("PostgreSQL identifiers cannot contain NUL (0x00) bytes")
|
|
26
|
+
return '"' + name.replace('"', '""') + '"'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def quote_literal(text: str) -> str:
|
|
30
|
+
"""Quote a string as a PostgreSQL literal, independent of ``standard_conforming_strings``.
|
|
31
|
+
|
|
32
|
+
Backslashes force the ``E'...'`` form with the backslash doubled, so the result
|
|
33
|
+
means the same thing whichever way the server setting is configured.
|
|
34
|
+
"""
|
|
35
|
+
if "\x00" in text:
|
|
36
|
+
raise errors.DataError("PostgreSQL text fields cannot contain NUL (0x00) bytes")
|
|
37
|
+
body = text.replace("'", "''")
|
|
38
|
+
if "\\" in body:
|
|
39
|
+
return " E'" + body.replace("\\", "\\\\") + "'"
|
|
40
|
+
return "'" + body + "'"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Composable:
|
|
44
|
+
__slots__ = ("_obj",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, obj: Any) -> None:
|
|
47
|
+
self._obj = obj
|
|
48
|
+
|
|
49
|
+
def as_string(self, context: Any = None) -> str:
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
def as_redacted(self) -> str:
|
|
53
|
+
"""The statement with every Literal hidden: safe to log or put on a trace span."""
|
|
54
|
+
return self.as_string()
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"{type(self).__name__}({self._obj!r})"
|
|
58
|
+
|
|
59
|
+
def __add__(self, other: Any) -> "Composed":
|
|
60
|
+
if isinstance(other, Composed):
|
|
61
|
+
return Composed([self, *other._obj])
|
|
62
|
+
if isinstance(other, Composable):
|
|
63
|
+
return Composed([self, other])
|
|
64
|
+
return NotImplemented
|
|
65
|
+
|
|
66
|
+
def __mul__(self, n: int) -> "Composed":
|
|
67
|
+
return Composed([self] * n)
|
|
68
|
+
|
|
69
|
+
def __eq__(self, other: object) -> bool:
|
|
70
|
+
return type(self) is type(other) and self._obj == other._obj # type: ignore[attr-defined]
|
|
71
|
+
|
|
72
|
+
def __hash__(self) -> int:
|
|
73
|
+
return hash((type(self), repr(self._obj)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Composed(Composable):
|
|
77
|
+
def __init__(self, seq: Iterable[Composable]) -> None:
|
|
78
|
+
items = list(seq)
|
|
79
|
+
for item in items:
|
|
80
|
+
if not isinstance(item, Composable):
|
|
81
|
+
raise TypeError(f"Composed elements must be Composable, got {item!r}")
|
|
82
|
+
super().__init__(items)
|
|
83
|
+
|
|
84
|
+
def __iter__(self) -> Iterator[Composable]:
|
|
85
|
+
return iter(self._obj)
|
|
86
|
+
|
|
87
|
+
def as_string(self, context: Any = None) -> str:
|
|
88
|
+
return "".join(item.as_string(context) for item in self._obj)
|
|
89
|
+
|
|
90
|
+
def as_redacted(self) -> str:
|
|
91
|
+
return "".join(item.as_redacted() for item in self._obj)
|
|
92
|
+
|
|
93
|
+
def join(self, joiner: "str | SQL") -> "Composed":
|
|
94
|
+
sep = SQL(joiner) if isinstance(joiner, str) else joiner
|
|
95
|
+
out: list[Composable] = []
|
|
96
|
+
for index, item in enumerate(self._obj):
|
|
97
|
+
if index:
|
|
98
|
+
out.append(sep)
|
|
99
|
+
out.append(item)
|
|
100
|
+
return Composed(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SQL(Composable):
|
|
104
|
+
def __init__(self, text: str) -> None:
|
|
105
|
+
if not isinstance(text, str):
|
|
106
|
+
raise TypeError(f"SQL values must be strings, got {text!r}")
|
|
107
|
+
super().__init__(text)
|
|
108
|
+
|
|
109
|
+
def as_string(self, context: Any = None) -> str:
|
|
110
|
+
return self._obj
|
|
111
|
+
|
|
112
|
+
def format(self, *args: Composable, **kwargs: Composable) -> Composed:
|
|
113
|
+
parts: list[Composable] = []
|
|
114
|
+
auto = 0
|
|
115
|
+
for literal, field, spec, conversion in string.Formatter().parse(self._obj):
|
|
116
|
+
if spec or conversion:
|
|
117
|
+
raise ValueError("no format specification supported by SQL")
|
|
118
|
+
if literal:
|
|
119
|
+
parts.append(SQL(literal))
|
|
120
|
+
if field is None:
|
|
121
|
+
continue
|
|
122
|
+
if field == "":
|
|
123
|
+
value = args[auto]
|
|
124
|
+
auto += 1
|
|
125
|
+
elif field.isdigit():
|
|
126
|
+
value = args[int(field)]
|
|
127
|
+
else:
|
|
128
|
+
value = kwargs[field]
|
|
129
|
+
if not isinstance(value, Composable):
|
|
130
|
+
raise TypeError(f"SQL.format() arguments must be Composable, got {value!r}")
|
|
131
|
+
parts.append(value)
|
|
132
|
+
return Composed(parts)
|
|
133
|
+
|
|
134
|
+
def join(self, seq: Iterable[Composable]) -> Composed:
|
|
135
|
+
return Composed(list(seq)).join(self)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Identifier(Composable):
|
|
139
|
+
def __init__(self, *strings: str) -> None:
|
|
140
|
+
if not strings:
|
|
141
|
+
raise TypeError("Identifier requires at least one string")
|
|
142
|
+
for s in strings:
|
|
143
|
+
if not isinstance(s, str):
|
|
144
|
+
raise TypeError(f"SQL identifier parts must be strings, got {s!r}")
|
|
145
|
+
super().__init__(strings)
|
|
146
|
+
|
|
147
|
+
def as_string(self, context: Any = None) -> str:
|
|
148
|
+
return ".".join(quote_ident(s) for s in self._obj)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Literal(Composable):
|
|
152
|
+
def as_string(self, context: Any = None) -> str:
|
|
153
|
+
return _literal(self._obj)
|
|
154
|
+
|
|
155
|
+
def as_redacted(self) -> str:
|
|
156
|
+
return "?"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Placeholder(Composable):
|
|
160
|
+
def __init__(self, name: str = "") -> None:
|
|
161
|
+
super().__init__(name)
|
|
162
|
+
|
|
163
|
+
def as_string(self, context: Any = None) -> str:
|
|
164
|
+
return f"%({self._obj})s" if self._obj else "%s"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _literal(value: Any) -> str:
|
|
168
|
+
if value is None:
|
|
169
|
+
return "NULL"
|
|
170
|
+
if value is True or value is False:
|
|
171
|
+
return "true" if value else "false"
|
|
172
|
+
if isinstance(value, int):
|
|
173
|
+
return f" {value}" if value < 0 else str(value)
|
|
174
|
+
if isinstance(value, float):
|
|
175
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
176
|
+
return quote_literal(str(value).replace("inf", "Infinity").replace("nan", "NaN")) + "::float8"
|
|
177
|
+
return f" {value!r}" if value < 0 else repr(value)
|
|
178
|
+
if isinstance(value, Decimal):
|
|
179
|
+
if not value.is_finite():
|
|
180
|
+
return quote_literal(str(value)) + "::numeric"
|
|
181
|
+
return f" {value}" if value < 0 else str(value)
|
|
182
|
+
if isinstance(value, str):
|
|
183
|
+
return quote_literal(value)
|
|
184
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
185
|
+
return "'\\x" + binascii.hexlify(bytes(value)).decode() + "'::bytea"
|
|
186
|
+
if isinstance(value, datetime):
|
|
187
|
+
return quote_literal(value.isoformat(" ")) + ("::timestamptz" if value.tzinfo else "::timestamp")
|
|
188
|
+
if isinstance(value, date):
|
|
189
|
+
return quote_literal(value.isoformat()) + "::date"
|
|
190
|
+
if isinstance(value, time):
|
|
191
|
+
return quote_literal(value.isoformat()) + ("::timetz" if value.tzinfo else "::time")
|
|
192
|
+
if isinstance(value, timedelta):
|
|
193
|
+
return quote_literal(f"{value.days} days {value.seconds} seconds {value.microseconds} microseconds") + "::interval"
|
|
194
|
+
if isinstance(value, uuid.UUID):
|
|
195
|
+
return quote_literal(str(value)) + "::uuid"
|
|
196
|
+
if isinstance(value, Json):
|
|
197
|
+
return quote_literal(value.encode()) + ("::jsonb" if isinstance(value, Jsonb) else "::json")
|
|
198
|
+
if isinstance(value, list):
|
|
199
|
+
return "ARRAY[" + ",".join(_literal(v) for v in value) + "]" if value else "'{}'"
|
|
200
|
+
raise errors.ProgrammingError(f"cannot make a SQL literal from {type(value).__name__!r}")
|
pgtern/types/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Wrapper types that tell pgtern how to send a Python value to PostgreSQL."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
__all__ = ["Json", "Jsonb"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Json:
|
|
12
|
+
"""Send a Python object as a PostgreSQL ``json`` value."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ("obj", "dumps")
|
|
15
|
+
|
|
16
|
+
def __init__(self, obj: Any, dumps: Callable[[Any], str] | None = None) -> None:
|
|
17
|
+
self.obj = obj
|
|
18
|
+
self.dumps = dumps or _json.dumps
|
|
19
|
+
|
|
20
|
+
def encode(self) -> str:
|
|
21
|
+
return self.dumps(self.obj)
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"{type(self).__name__}({self.obj!r})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Jsonb(Json):
|
|
28
|
+
"""Send a Python object as a PostgreSQL ``jsonb`` value."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ()
|