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/_types.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Python <-> PostgreSQL text-format codecs.
|
|
3
|
+
|
|
4
|
+
Parameters are sent in text format. Strings go out with the "unknown" type OID
|
|
5
|
+
(0) so the server infers the type from context (``%s::int``, ``col = %s``); ints
|
|
6
|
+
are sent as int2/int4/int8/numeric by size, so ``f(smallint)`` calls
|
|
7
|
+
still resolve. Results are decoded from text format; types without a decoder
|
|
8
|
+
come back as ``str``.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import binascii
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from typing import Any, Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from . import errors
|
|
21
|
+
from .types import Json, Jsonb
|
|
22
|
+
|
|
23
|
+
BOOL, BYTEA, CHAR, NAME, INT8, INT2, INT4, TEXT, OID = 16, 17, 18, 19, 20, 21, 23, 25, 26
|
|
24
|
+
JSON, FLOAT4, FLOAT8, BPCHAR, VARCHAR = 114, 700, 701, 1042, 1043
|
|
25
|
+
DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, TIMETZ = 1082, 1083, 1114, 1184, 1186, 1266
|
|
26
|
+
NUMERIC, UUID, JSONB = 1700, 2950, 3802
|
|
27
|
+
|
|
28
|
+
ARRAY_OF = {
|
|
29
|
+
BOOL: 1000, BYTEA: 1001, CHAR: 1002, NAME: 1003, INT2: 1005, INT4: 1007, TEXT: 1009,
|
|
30
|
+
OID: 1028, VARCHAR: 1015, INT8: 1016, FLOAT4: 1021, FLOAT8: 1022, BPCHAR: 1014, JSON: 199,
|
|
31
|
+
DATE: 1182, TIME: 1183, TIMESTAMP: 1115, TIMESTAMPTZ: 1185, INTERVAL: 1187, TIMETZ: 1270,
|
|
32
|
+
NUMERIC: 1231, UUID: 2951, JSONB: 3807,
|
|
33
|
+
}
|
|
34
|
+
ELEMENT_OF = {array: elem for elem, array in ARRAY_OF.items()}
|
|
35
|
+
|
|
36
|
+
_UNQUOTED_IN_ARRAYS = {BOOL, INT2, INT4, INT8, FLOAT8, NUMERIC}
|
|
37
|
+
_INT_ORDER = (INT2, INT4, INT8, NUMERIC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------- dumping
|
|
41
|
+
|
|
42
|
+
def render_literal(value: Any) -> str:
|
|
43
|
+
"""A value as an SQL literal (for client-side binding)."""
|
|
44
|
+
from .sql import quote_literal
|
|
45
|
+
_oid, data = dump(value)
|
|
46
|
+
return "NULL" if data is None else quote_literal(data.decode("utf-8"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dump(value: Any) -> tuple[int, bytes | None]:
|
|
50
|
+
"""Return ``(type_oid, text_bytes)`` for a parameter; ``(0, None)`` is SQL NULL."""
|
|
51
|
+
if value is None:
|
|
52
|
+
return 0, None
|
|
53
|
+
if _DUMPERS:
|
|
54
|
+
fn = _dumper_for(type(value))
|
|
55
|
+
if fn is not None: # user dumpers win; the text goes out untyped so the server infers the type
|
|
56
|
+
text = fn(value)
|
|
57
|
+
return (0, None) if text is None else (0, text.encode("utf-8"))
|
|
58
|
+
if value is True or value is False:
|
|
59
|
+
return BOOL, b"t" if value else b"f"
|
|
60
|
+
if isinstance(value, int):
|
|
61
|
+
if -32768 <= value <= 32767:
|
|
62
|
+
oid = INT2
|
|
63
|
+
elif -2147483648 <= value <= 2147483647:
|
|
64
|
+
oid = INT4
|
|
65
|
+
elif -9223372036854775808 <= value <= 9223372036854775807:
|
|
66
|
+
oid = INT8
|
|
67
|
+
else:
|
|
68
|
+
oid = NUMERIC
|
|
69
|
+
return oid, str(int(value)).encode()
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
if "\x00" in value:
|
|
72
|
+
raise errors.DataError("PostgreSQL text fields cannot contain NUL (0x00) bytes")
|
|
73
|
+
return 0, str.encode(value, "utf-8")
|
|
74
|
+
if isinstance(value, float):
|
|
75
|
+
if value != value:
|
|
76
|
+
return FLOAT8, b"NaN"
|
|
77
|
+
if value in (float("inf"), float("-inf")):
|
|
78
|
+
return FLOAT8, b"Infinity" if value > 0 else b"-Infinity"
|
|
79
|
+
return FLOAT8, repr(value).encode()
|
|
80
|
+
if isinstance(value, Decimal):
|
|
81
|
+
return NUMERIC, str(value).encode()
|
|
82
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
83
|
+
return BYTEA, b"\\x" + binascii.hexlify(bytes(value))
|
|
84
|
+
if isinstance(value, datetime):
|
|
85
|
+
return (TIMESTAMPTZ if value.tzinfo is not None else TIMESTAMP), value.isoformat(" ").encode()
|
|
86
|
+
if isinstance(value, date):
|
|
87
|
+
return DATE, value.isoformat().encode()
|
|
88
|
+
if isinstance(value, time):
|
|
89
|
+
return (TIMETZ if value.tzinfo is not None else TIME), value.isoformat().encode()
|
|
90
|
+
if isinstance(value, timedelta):
|
|
91
|
+
return INTERVAL, f"{value.days} days {value.seconds} seconds {value.microseconds} microseconds".encode()
|
|
92
|
+
if isinstance(value, uuid.UUID):
|
|
93
|
+
return UUID, str(value).encode()
|
|
94
|
+
if isinstance(value, Json):
|
|
95
|
+
return (JSONB if isinstance(value, Jsonb) else JSON), value.encode().encode()
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
return _dump_list(value)
|
|
98
|
+
raise errors.ProgrammingError(
|
|
99
|
+
f"cannot adapt type {type(value).__name__!r}; wrap dicts in Jsonb() and use lists for arrays")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unify(oids: set[int]) -> int:
|
|
103
|
+
if not oids:
|
|
104
|
+
return 0
|
|
105
|
+
if len(oids) == 1:
|
|
106
|
+
return next(iter(oids))
|
|
107
|
+
if oids <= set(_INT_ORDER):
|
|
108
|
+
return max(oids, key=_INT_ORDER.index)
|
|
109
|
+
raise errors.ProgrammingError(f"cannot dump a list of mixed types (type OIDs {sorted(oids)})")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _dump_list(items: list) -> tuple[int, bytes]:
|
|
113
|
+
leaves: list[tuple[int, bytes | None]] = []
|
|
114
|
+
|
|
115
|
+
def walk(node: Any) -> Any:
|
|
116
|
+
if isinstance(node, list):
|
|
117
|
+
return [walk(child) for child in node]
|
|
118
|
+
if node is None:
|
|
119
|
+
return None
|
|
120
|
+
pair = dump(node)
|
|
121
|
+
leaves.append(pair)
|
|
122
|
+
return pair
|
|
123
|
+
|
|
124
|
+
tree = walk(items)
|
|
125
|
+
elem = _unify({oid for oid, _ in leaves})
|
|
126
|
+
|
|
127
|
+
def render(node: Any) -> str:
|
|
128
|
+
if node is None:
|
|
129
|
+
return "NULL"
|
|
130
|
+
if isinstance(node, list):
|
|
131
|
+
return "{" + ",".join(render(child) for child in node) + "}"
|
|
132
|
+
oid, data = node
|
|
133
|
+
text = data.decode()
|
|
134
|
+
if oid in _UNQUOTED_IN_ARRAYS:
|
|
135
|
+
return text
|
|
136
|
+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
137
|
+
|
|
138
|
+
return (ARRAY_OF.get(elem, 0) if elem else 0), render(tree).encode()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------- decoding
|
|
142
|
+
|
|
143
|
+
_DATE_RE = re.compile(r"^(\d{4,})-(\d\d)-(\d\d)( BC)?$")
|
|
144
|
+
_TIMESTAMP_RE = re.compile(
|
|
145
|
+
r"^(\d{4,})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?"
|
|
146
|
+
r"(?:([+-])(\d\d)(?::?(\d\d))?(?::?(\d\d))?)?( BC)?$")
|
|
147
|
+
_TIME_RE = re.compile(
|
|
148
|
+
r"^(\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))?(?:([+-])(\d\d)(?::?(\d\d))?(?::?(\d\d))?)?$")
|
|
149
|
+
_INTERVAL_RE = re.compile(
|
|
150
|
+
r"^(?:([+-]?\d+) years?)?\s*(?:([+-]?\d+) mons?)?\s*(?:([+-]?\d+) days?)?\s*"
|
|
151
|
+
r"(?:([+-])?(\d+):(\d\d):(\d\d)(?:\.(\d{1,6}))?)?$")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tz(sign: str | None, hh: str | None, mm: str | None, ss: str | None) -> timezone | None:
|
|
155
|
+
if sign is None:
|
|
156
|
+
return None
|
|
157
|
+
delta = timedelta(hours=int(hh or 0), minutes=int(mm or 0), seconds=int(ss or 0))
|
|
158
|
+
if sign == "-":
|
|
159
|
+
delta = -delta
|
|
160
|
+
return timezone.utc if not delta else timezone(delta)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _dec_bool(s: str) -> bool:
|
|
164
|
+
return s == "t"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _dec_bytea(s: str) -> bytes:
|
|
168
|
+
if s.startswith("\\x"):
|
|
169
|
+
return binascii.unhexlify(s[2:])
|
|
170
|
+
out = bytearray() # legacy "escape" output format
|
|
171
|
+
i = 0
|
|
172
|
+
while i < len(s):
|
|
173
|
+
if s[i] != "\\":
|
|
174
|
+
out.append(ord(s[i]))
|
|
175
|
+
i += 1
|
|
176
|
+
elif s[i + 1:i + 2] == "\\":
|
|
177
|
+
out.append(0x5C)
|
|
178
|
+
i += 2
|
|
179
|
+
else:
|
|
180
|
+
out.append(int(s[i + 1:i + 4], 8))
|
|
181
|
+
i += 4
|
|
182
|
+
return bytes(out)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _dec_date(s: str) -> date:
|
|
186
|
+
m = _DATE_RE.match(s)
|
|
187
|
+
if not m or m.group(4) or int(m.group(1)) > 9999:
|
|
188
|
+
raise errors.DataError(f"can't decode date {s!r}: BC dates, years above 9999 and infinity are not supported")
|
|
189
|
+
try:
|
|
190
|
+
return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
191
|
+
except ValueError as exc:
|
|
192
|
+
raise errors.DataError(f"can't decode date {s!r}: {exc}") from None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _make_timestamp(s: str, aware: bool) -> datetime:
|
|
196
|
+
m = _TIMESTAMP_RE.match(s)
|
|
197
|
+
if not m or m.group(12) or int(m.group(1)) > 9999:
|
|
198
|
+
raise errors.DataError(
|
|
199
|
+
f"can't decode timestamp {s!r}: BC dates, years above 9999 and infinity are not supported")
|
|
200
|
+
year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
|
|
201
|
+
micro = int((m.group(7) or "").ljust(6, "0"))
|
|
202
|
+
tz = _tz(m.group(8), m.group(9), m.group(10), m.group(11)) if aware else None
|
|
203
|
+
try:
|
|
204
|
+
return datetime(year, month, day, hour, minute, second, micro, tzinfo=tz)
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
raise errors.DataError(f"can't decode timestamp {s!r}: {exc}") from None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _dec_timestamp(s: str) -> datetime:
|
|
210
|
+
return _make_timestamp(s, False)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _dec_timestamptz(s: str) -> datetime:
|
|
214
|
+
return _make_timestamp(s, True)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _make_time(s: str, aware: bool) -> time:
|
|
218
|
+
m = _TIME_RE.match(s)
|
|
219
|
+
if not m:
|
|
220
|
+
raise errors.DataError(f"can't decode time {s!r}")
|
|
221
|
+
micro = int((m.group(4) or "").ljust(6, "0"))
|
|
222
|
+
tz = _tz(m.group(5), m.group(6), m.group(7), m.group(8)) if aware else None
|
|
223
|
+
try:
|
|
224
|
+
return time(int(m.group(1)), int(m.group(2)), int(m.group(3)), micro, tzinfo=tz)
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
raise errors.DataError(f"can't decode time {s!r}: {exc}") from None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _dec_time(s: str) -> time:
|
|
230
|
+
return _make_time(s, False)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _dec_timetz(s: str) -> time:
|
|
234
|
+
return _make_time(s, True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _dec_interval(s: str) -> timedelta:
|
|
238
|
+
"""PostgreSQL ``postgres`` IntervalStyle. A year counts as 365 days and a month as 30."""
|
|
239
|
+
m = _INTERVAL_RE.match(s.strip())
|
|
240
|
+
if not m:
|
|
241
|
+
raise errors.NotSupportedError(f"can't decode interval {s!r}: only IntervalStyle 'postgres' is supported")
|
|
242
|
+
years, months, days = (int(m.group(i) or 0) for i in (1, 2, 3))
|
|
243
|
+
delta = timedelta(days=years * 365 + months * 30 + days)
|
|
244
|
+
if m.group(5) is not None:
|
|
245
|
+
clock = timedelta(hours=int(m.group(5)), minutes=int(m.group(6)), seconds=int(m.group(7)),
|
|
246
|
+
microseconds=int((m.group(8) or "").ljust(6, "0")))
|
|
247
|
+
delta += -clock if m.group(4) == "-" else clock
|
|
248
|
+
return delta
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_SCALAR_DECODERS: dict[int, Callable[[str], Any] | None] = {
|
|
252
|
+
BOOL: _dec_bool, BYTEA: _dec_bytea, INT2: int, INT4: int, INT8: int, OID: int,
|
|
253
|
+
FLOAT4: float, FLOAT8: float, NUMERIC: Decimal, UUID: uuid.UUID,
|
|
254
|
+
JSON: json.loads, JSONB: json.loads,
|
|
255
|
+
DATE: _dec_date, TIME: _dec_time, TIMETZ: _dec_timetz, TIMESTAMP: _dec_timestamp,
|
|
256
|
+
TIMESTAMPTZ: _dec_timestamptz, INTERVAL: _dec_interval,
|
|
257
|
+
TEXT: None, VARCHAR: None, BPCHAR: None, NAME: None, CHAR: None,
|
|
258
|
+
}
|
|
259
|
+
_DATE_OIDS = {DATE, TIME, TIMETZ, TIMESTAMP, TIMESTAMPTZ, INTERVAL}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_array(text: str, element: Callable[[str], Any] | None) -> list:
|
|
263
|
+
"""Parse a PostgreSQL array literal (any number of dimensions) with ``element`` per item."""
|
|
264
|
+
if text.startswith("["): # explicit bounds, e.g. "[0:1]={a,b}"
|
|
265
|
+
text = text[text.index("=") + 1:]
|
|
266
|
+
root: list = []
|
|
267
|
+
stack: list[list] = []
|
|
268
|
+
i, n = 0, len(text)
|
|
269
|
+
while i < n:
|
|
270
|
+
c = text[i]
|
|
271
|
+
if c == "{":
|
|
272
|
+
node: list = []
|
|
273
|
+
(stack[-1] if stack else _Holder(root)).append(node)
|
|
274
|
+
stack.append(node)
|
|
275
|
+
i += 1
|
|
276
|
+
elif c == "}":
|
|
277
|
+
stack.pop()
|
|
278
|
+
i += 1
|
|
279
|
+
elif c == ",":
|
|
280
|
+
i += 1
|
|
281
|
+
elif c == '"':
|
|
282
|
+
i += 1
|
|
283
|
+
buf: list[str] = []
|
|
284
|
+
while text[i] != '"':
|
|
285
|
+
if text[i] == "\\":
|
|
286
|
+
i += 1
|
|
287
|
+
buf.append(text[i])
|
|
288
|
+
i += 1
|
|
289
|
+
i += 1
|
|
290
|
+
token = "".join(buf)
|
|
291
|
+
stack[-1].append(token if element is None else element(token))
|
|
292
|
+
else:
|
|
293
|
+
j = i
|
|
294
|
+
while j < n and text[j] not in ",}":
|
|
295
|
+
j += 1
|
|
296
|
+
token = text[i:j]
|
|
297
|
+
i = j
|
|
298
|
+
stack[-1].append(None if token == "NULL" else (token if element is None else element(token)))
|
|
299
|
+
return root[0] if root else []
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _Holder(list):
|
|
303
|
+
"""Lets the first ``{`` replace the root list contents."""
|
|
304
|
+
|
|
305
|
+
def __init__(self, root: list) -> None:
|
|
306
|
+
super().__init__()
|
|
307
|
+
self._root = root
|
|
308
|
+
|
|
309
|
+
def append(self, item: Any) -> None: # type: ignore[override]
|
|
310
|
+
self._root.append(item)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _decoder_for(oid: int, dates_ok: bool) -> Callable[[str], Any] | None:
|
|
314
|
+
base = ELEMENT_OF.get(oid)
|
|
315
|
+
if base is not None:
|
|
316
|
+
element = _decoder_for(base, dates_ok)
|
|
317
|
+
return lambda s: parse_array(s, element)
|
|
318
|
+
decoder = _SCALAR_DECODERS.get(oid)
|
|
319
|
+
if decoder is not None and oid in _DATE_OIDS and not dates_ok:
|
|
320
|
+
def refuse(s: str) -> Any:
|
|
321
|
+
raise errors.NotSupportedError(
|
|
322
|
+
"date/time values can't be decoded: the connection's DateStyle is not ISO")
|
|
323
|
+
return refuse
|
|
324
|
+
return decoder
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class BinaryColumn:
|
|
328
|
+
"""Marks a column whose values are binary (decoder takes bytes, not text)."""
|
|
329
|
+
|
|
330
|
+
__slots__ = ("fn",)
|
|
331
|
+
|
|
332
|
+
def __init__(self, fn: Callable[[bytes], Any]) -> None:
|
|
333
|
+
self.fn = fn
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def make_loaders(oids: Sequence[int], dates_ok: bool = True,
|
|
337
|
+
cache: dict[int, Any] | None = None,
|
|
338
|
+
formats: Sequence[int] | None = None) -> list[Any]:
|
|
339
|
+
"""One loader per column: a text decoder (``str`` in), a :class:`BinaryColumn`, or ``None`` (keep text)."""
|
|
340
|
+
out: list[Any] = []
|
|
341
|
+
for i, oid in enumerate(oids):
|
|
342
|
+
if formats is not None and formats[i] == 1:
|
|
343
|
+
from ._binary import scalar_decoder # imported here: _binary depends on this module
|
|
344
|
+
decoder = scalar_decoder(oid)
|
|
345
|
+
if decoder is None:
|
|
346
|
+
raise errors.InterfaceError(f"no binary decoder for type oid {oid}")
|
|
347
|
+
out.append(BinaryColumn(decoder))
|
|
348
|
+
continue
|
|
349
|
+
user = cache.get(oid) if cache else None
|
|
350
|
+
out.append(user if user is not None else _decoder_for(oid, dates_ok))
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------- user adapters
|
|
355
|
+
_DUMPERS: dict[type, Callable[[Any], str | None]] = {}
|
|
356
|
+
_LOADERS: dict[str, Callable[[str], Any]] = {}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _dumper_for(cls: type) -> Callable[[Any], str | None] | None:
|
|
360
|
+
for klass in cls.__mro__:
|
|
361
|
+
fn = _DUMPERS.get(klass)
|
|
362
|
+
if fn is not None:
|
|
363
|
+
return fn
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def has_loaders() -> bool:
|
|
368
|
+
return bool(_LOADERS)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def loader_for_name(nspname: str, typname: str) -> Callable[[str], Any] | None:
|
|
372
|
+
return _LOADERS.get(f"{nspname}.{typname}") or _LOADERS.get(typname)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _nn(value: bytes | None) -> bytes:
|
|
376
|
+
if value is None:
|
|
377
|
+
raise errors.InterfaceError("unexpected NULL in a catalog row")
|
|
378
|
+
return value
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def resolve_loaders(pg_type_rows: list[list[bytes | None]], cache: dict[int, Any], wanted: Sequence[int]) -> None:
|
|
382
|
+
"""Fill ``cache`` (oid -> loader or None) for ``wanted``.
|
|
383
|
+
|
|
384
|
+
``pg_type_rows`` are rows of (oid, nspname, typname, typelem, typcategory) for the wanted
|
|
385
|
+
types and their element types. Arrays get a loader when their element type has one.
|
|
386
|
+
"""
|
|
387
|
+
info: dict[int, tuple[str, str, int, str]] = {}
|
|
388
|
+
for oid_b, nsp_b, name_b, elem_b, cat_b in pg_type_rows:
|
|
389
|
+
info[int(_nn(oid_b))] = (_nn(nsp_b).decode(), _nn(name_b).decode(), int(_nn(elem_b)), _nn(cat_b).decode())
|
|
390
|
+
|
|
391
|
+
def loader_for(oid: int, depth: int = 0) -> Callable[[str], Any] | None:
|
|
392
|
+
if oid in cache:
|
|
393
|
+
return cache[oid]
|
|
394
|
+
result: Callable[[str], Any] | None = None
|
|
395
|
+
entry = info.get(oid)
|
|
396
|
+
if entry is not None and depth < 4:
|
|
397
|
+
nsp, name, elem, cat = entry
|
|
398
|
+
if cat == "A" and elem:
|
|
399
|
+
element = loader_for(elem, depth + 1)
|
|
400
|
+
if element is not None:
|
|
401
|
+
result = lambda s, _e=element: parse_array(s, _e) # noqa: E731
|
|
402
|
+
if result is None:
|
|
403
|
+
result = loader_for_name(nsp, name)
|
|
404
|
+
cache[oid] = result
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
for oid in wanted:
|
|
408
|
+
loader_for(int(oid))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def load_row(raw: Sequence[bytes | None], loaders: Sequence[Any]) -> tuple:
|
|
413
|
+
out: list[Any] = []
|
|
414
|
+
for v, ld in zip(raw, loaders):
|
|
415
|
+
if v is None:
|
|
416
|
+
out.append(None)
|
|
417
|
+
elif type(ld) is BinaryColumn:
|
|
418
|
+
out.append(ld.fn(v))
|
|
419
|
+
else:
|
|
420
|
+
out.append(v.decode() if ld is None else ld(v.decode()))
|
|
421
|
+
return tuple(out)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def binary_param_plan(server_oids: Sequence[int], values: Sequence[Any]) -> tuple[list[int], list[bytes | None]]:
|
|
425
|
+
"""Per parameter: (format, bytes). Binary when the server's type has a binary encoder for the
|
|
426
|
+
value; otherwise text, which the server parses exactly as it would without binary mode."""
|
|
427
|
+
from ._binary import encode_scalar
|
|
428
|
+
formats: list[int] = []
|
|
429
|
+
payloads: list[bytes | None] = []
|
|
430
|
+
for oid, value in zip(server_oids, values):
|
|
431
|
+
if value is None:
|
|
432
|
+
formats.append(0)
|
|
433
|
+
payloads.append(None)
|
|
434
|
+
continue
|
|
435
|
+
data = encode_scalar(value, oid)
|
|
436
|
+
if data is not None:
|
|
437
|
+
formats.append(1)
|
|
438
|
+
payloads.append(data)
|
|
439
|
+
else:
|
|
440
|
+
formats.append(0)
|
|
441
|
+
payloads.append(dump(value)[1])
|
|
442
|
+
return formats, payloads
|
pgtern/adapters.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Custom type adapters: register how your own Python types go to and from the database.
|
|
3
|
+
|
|
4
|
+
Loaders are registered by PostgreSQL type name (``"mood"`` or ``"public.mood"``) and apply
|
|
5
|
+
to results (and to arrays of that type). Dumpers are registered by Python class and apply to
|
|
6
|
+
parameters. Registrations are process-wide.
|
|
7
|
+
|
|
8
|
+
from pgtern import adapters
|
|
9
|
+
|
|
10
|
+
adapters.register_loader("mood", adapters.enum_loader(Mood))
|
|
11
|
+
adapters.register_loader("address", adapters.record_loader(Address, (str, int)))
|
|
12
|
+
adapters.register_loader("span", adapters.range_loader(int))
|
|
13
|
+
adapters.register_dumper(Money, lambda m: f"{m.cents / 100:.2f}")
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from . import _types
|
|
21
|
+
from ._composite import Range, format_range, format_record, parse_range, parse_record
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"register_loader", "unregister_loader", "register_dumper", "unregister_dumper",
|
|
25
|
+
"enum_loader", "record_loader", "range_loader", "range_dumper", "record_dumper",
|
|
26
|
+
"Range", "parse_record", "format_record", "parse_range", "format_range",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_loader(type_name: str, loader: Callable[[str], Any]) -> None:
|
|
31
|
+
"""``loader(text)`` builds the Python value from the server's text form of the type."""
|
|
32
|
+
_types._LOADERS[type_name] = loader
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def unregister_loader(type_name: str) -> None:
|
|
36
|
+
_types._LOADERS.pop(type_name, None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def register_dumper(cls: type, dumper: Callable[[Any], str | None]) -> None:
|
|
40
|
+
"""``dumper(value)`` returns the text form (``None`` for SQL NULL). Sent untyped, so the
|
|
41
|
+
server infers the type from context (``col = %s``); add a cast in SQL if needed."""
|
|
42
|
+
_types._DUMPERS[cls] = dumper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def unregister_dumper(cls: type) -> None:
|
|
46
|
+
_types._DUMPERS.pop(cls, None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enum_loader(enum_cls: type[Enum]) -> Callable[[str], Enum]:
|
|
50
|
+
return lambda text: enum_cls(text)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def record_loader(factory: Callable[..., Any], fields: Sequence[Callable[[str], Any] | type | None] = ()) -> Callable[[str], Any]:
|
|
54
|
+
"""Build ``factory(*fields)`` from a composite value. ``fields`` converts each text field
|
|
55
|
+
(e.g. ``(str, int)``); a missing or ``None`` converter keeps the text, NULL stays ``None``."""
|
|
56
|
+
def load(text: str) -> Any:
|
|
57
|
+
values = parse_record(text)
|
|
58
|
+
converted = []
|
|
59
|
+
for i, v in enumerate(values):
|
|
60
|
+
conv = fields[i] if i < len(fields) else None
|
|
61
|
+
converted.append(v if v is None or conv is None else conv(v))
|
|
62
|
+
return factory(*converted)
|
|
63
|
+
return load
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _dumped_text(value: Any) -> str:
|
|
67
|
+
_oid, data = _types.dump(value)
|
|
68
|
+
if data is None:
|
|
69
|
+
raise TypeError(f"{type(value).__name__} cannot be sent as text")
|
|
70
|
+
return data.decode()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def record_dumper(getter: Callable[[Any], Sequence[Any]]) -> Callable[[Any], str]:
|
|
74
|
+
"""``getter(obj)`` returns the fields; each is sent in its text form (``None`` is NULL)."""
|
|
75
|
+
def dump(obj: Any) -> str:
|
|
76
|
+
return format_record([None if v is None else _dumped_text(v) for v in getter(obj)])
|
|
77
|
+
return dump
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def range_loader(element: Callable[[str], Any] | None = None) -> Callable[[str], Range]:
|
|
81
|
+
return lambda text: parse_range(text, element)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def range_dumper() -> Callable[[Range], str]:
|
|
85
|
+
return lambda r: format_range(r, _dumped_text)
|