crate 2.1.2__tar.gz → 2.2.1__tar.gz
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.
- {crate-2.1.2 → crate-2.2.1}/CHANGES.rst +24 -1
- {crate-2.1.2 → crate-2.2.1}/PKG-INFO +1 -1
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/cursor.rst +51 -2
- {crate-2.1.2 → crate-2.2.1}/docs/connect.rst +26 -0
- {crate-2.1.2 → crate-2.2.1}/docs/query.rst +76 -2
- {crate-2.1.2 → crate-2.2.1}/pyproject.toml +1 -1
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_cursor.py +241 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_http.py +123 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_serialization.py +38 -0
- {crate-2.1.2 → crate-2.2.1}/tests/testing/test_layer.py +5 -15
- {crate-2.1.2 → crate-2.2.1}/.gitignore +0 -0
- {crate-2.1.2 → crate-2.2.1}/CONTRIBUTING.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/DEVELOP.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/LICENSE +0 -0
- {crate-2.1.2 → crate-2.2.1}/NOTICE +0 -0
- {crate-2.1.2 → crate-2.2.1}/README.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/.gitignore +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/Makefile +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/_extra/robots.txt +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/blobs.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/build.json +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/blob.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/client.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/connection.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/http.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/https.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/by-example/index.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/conf.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/data-types.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/docutils.conf +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/getting-started.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/index-all.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/index.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/other-options.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/docs/requirements.txt +0 -0
- {crate-2.1.2 → crate-2.2.1}/examples/README.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/__init__.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/import/test_a.json +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/mappings/locations.sql +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/cacert_invalid.pem +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/cacert_valid.pem +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/client_invalid.pem +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/client_valid.pem +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/readme.rst +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/pki/server_valid.pem +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/assets/settings/test_a.json +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/__init__.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/settings.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_blob.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_connection.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_exceptions.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/client/test_utils.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/conftest.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/test_docs.py +0 -0
- {crate-2.1.2 → crate-2.2.1}/tests/testing/__init__.py +0 -0
|
@@ -2,8 +2,31 @@
|
|
|
2
2
|
Changes for crate
|
|
3
3
|
=================
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
2026/06/17 2.2.1
|
|
6
|
+
================
|
|
7
|
+
|
|
8
|
+
- Fixed ``cursor.execute()`` with ``bulk_parameters`` and pyformat SQL: when
|
|
9
|
+
rows are dicts, both the SQL template and the rows are now converted to
|
|
10
|
+
positional format before sending to CrateDB. Positional-list rows
|
|
11
|
+
continue to work as before.
|
|
12
|
+
|
|
13
|
+
2026/06/04 2.2.0
|
|
6
14
|
==========
|
|
15
|
+
- Added JSON serialization support for Python's ``datetime.time`` type,
|
|
16
|
+
encoding it as an ISO 8601 string compatible with CrateDB's ``TIMETZ``
|
|
17
|
+
column type.
|
|
18
|
+
|
|
19
|
+
- Added gzip compression for outgoing request bodies via the ``compress``
|
|
20
|
+
parameter (default: ``8192`` bytes).
|
|
21
|
+
Pass ``True`` to always compress, ``False`` to disable, or an integer
|
|
22
|
+
as a byte threshold. The driver always sends ``Accept-Encoding: gzip,
|
|
23
|
+
deflate`` to negotiate compressed responses from the server when
|
|
24
|
+
compression is enabled.
|
|
25
|
+
|
|
26
|
+
- Added named parameter support (``pyformat`` paramstyle). Passing a
|
|
27
|
+
:class:`py:dict` as ``parameters`` to ``cursor.execute()`` now accepts
|
|
28
|
+
``%(name)s`` placeholders and converts them to positional ``?`` markers
|
|
29
|
+
client-side. Positional parameters using ``?`` continue to work unchanged.
|
|
7
30
|
|
|
8
31
|
2026/03/09 2.1.2
|
|
9
32
|
================
|
|
@@ -266,6 +266,37 @@ For completeness' sake the cursor description is updated nonetheless:
|
|
|
266
266
|
>>> [ desc[0] for desc in cursor.description ]
|
|
267
267
|
['name', 'position']
|
|
268
268
|
|
|
269
|
+
executemany() with named parameters
|
|
270
|
+
====================================
|
|
271
|
+
|
|
272
|
+
``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` when
|
|
273
|
+
the SQL statement contains ``%(name)s`` placeholders. The client converts both the SQL
|
|
274
|
+
template and all rows to positional format before sending them to CrateDB:
|
|
275
|
+
|
|
276
|
+
.. Hidden: set up mocked response
|
|
277
|
+
|
|
278
|
+
>>> connection.client.set_next_response({
|
|
279
|
+
... "results": [
|
|
280
|
+
... {"rowcount": 1},
|
|
281
|
+
... {"rowcount": 1}
|
|
282
|
+
... ],
|
|
283
|
+
... "duration": 123,
|
|
284
|
+
... "cols": [],
|
|
285
|
+
... })
|
|
286
|
+
|
|
287
|
+
>>> cursor = connection.cursor()
|
|
288
|
+
|
|
289
|
+
>>> cursor.executemany(
|
|
290
|
+
... "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)",
|
|
291
|
+
... [{"id": 1, "val": "foo"}, {"id": 2, "val": "bar"}])
|
|
292
|
+
[{'rowcount': 1}, {'rowcount': 1}]
|
|
293
|
+
|
|
294
|
+
>>> cursor.rowcount
|
|
295
|
+
2
|
|
296
|
+
|
|
297
|
+
>>> cursor.duration
|
|
298
|
+
123
|
|
299
|
+
|
|
269
300
|
>>> connection.client.set_next_response({
|
|
270
301
|
... "rows":[ [ "North West Ripple", 1 ], [ "Arkintoofle Minor", 3 ], [ "Alpha Centauri", 3 ] ],
|
|
271
302
|
... "cols":[ "name", "position" ],
|
|
@@ -311,8 +342,8 @@ Python data type conversion
|
|
|
311
342
|
===========================
|
|
312
343
|
|
|
313
344
|
The cursor object can optionally convert database types to native Python data
|
|
314
|
-
types. Currently, this is implemented for the CrateDB data types ``IP
|
|
315
|
-
``TIMESTAMP`` on behalf of the ``DefaultTypeConverter``.
|
|
345
|
+
types. Currently, this is implemented for the CrateDB data types ``IP``,
|
|
346
|
+
``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``.
|
|
316
347
|
|
|
317
348
|
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
|
|
318
349
|
|
|
@@ -329,6 +360,24 @@ types. Currently, this is implemented for the CrateDB data types ``IP`` and
|
|
|
329
360
|
>>> cursor.fetchone()
|
|
330
361
|
['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)]
|
|
331
362
|
|
|
363
|
+
CrateDB's ``TIMETZ`` type is returned over HTTP as ``[microseconds_since_midnight, tz_offset_seconds]``
|
|
364
|
+
and decoded to a ``datetime.time`` object with the appropriate timezone:
|
|
365
|
+
|
|
366
|
+
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
|
|
367
|
+
|
|
368
|
+
>>> connection.client.set_next_response({
|
|
369
|
+
... "col_types": [20],
|
|
370
|
+
... "rows":[ [ [45045000000, 0] ] ],
|
|
371
|
+
... "cols":[ "t" ],
|
|
372
|
+
... "rowcount":1,
|
|
373
|
+
... "duration":1
|
|
374
|
+
... })
|
|
375
|
+
|
|
376
|
+
>>> cursor.execute('')
|
|
377
|
+
|
|
378
|
+
>>> cursor.fetchone()
|
|
379
|
+
[datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)]
|
|
380
|
+
|
|
332
381
|
|
|
333
382
|
Custom data type conversion
|
|
334
383
|
===========================
|
|
@@ -266,6 +266,32 @@ with the rest of your arguments.
|
|
|
266
266
|
|
|
267
267
|
However, you can query any schema you like by specifying it in the query.
|
|
268
268
|
|
|
269
|
+
.. _compression:
|
|
270
|
+
|
|
271
|
+
Request and response compression
|
|
272
|
+
=================================
|
|
273
|
+
|
|
274
|
+
The ``compress`` parameter controls gzip compression of outgoing request
|
|
275
|
+
bodies. The default ``8192`` compresses payloads larger than 8 KB::
|
|
276
|
+
|
|
277
|
+
>>> connection = client.connect('localhost:4200')
|
|
278
|
+
# compress=8192 is the default — payloads > 8 KB are gzip-compressed
|
|
279
|
+
|
|
280
|
+
To always compress, regardless of payload size::
|
|
281
|
+
|
|
282
|
+
>>> connection = client.connect('localhost:4200', compress=True)
|
|
283
|
+
|
|
284
|
+
To disable compression entirely::
|
|
285
|
+
|
|
286
|
+
>>> connection = client.connect('localhost:4200', compress=False)
|
|
287
|
+
|
|
288
|
+
To use a custom threshold (bytes)::
|
|
289
|
+
|
|
290
|
+
>>> connection = client.connect('localhost:4200', compress=4096)
|
|
291
|
+
|
|
292
|
+
The driver always sends ``Accept-Encoding: gzip, deflate`` so the server
|
|
293
|
+
may return compressed responses if compression is enabled.
|
|
294
|
+
|
|
269
295
|
Next steps
|
|
270
296
|
==========
|
|
271
297
|
|
|
@@ -54,11 +54,37 @@ characters appear, in the order they appear.
|
|
|
54
54
|
Always use the parameter interpolation feature of the client library to
|
|
55
55
|
guard against malicious input, as demonstrated in the example above.
|
|
56
56
|
|
|
57
|
+
Named parameters
|
|
58
|
+
----------------
|
|
59
|
+
|
|
60
|
+
For queries with many parameters or repeated values, named parameters improve
|
|
61
|
+
readability. Pass a :class:`py:dict` as the second argument using
|
|
62
|
+
``%(name)s`` placeholders:
|
|
63
|
+
|
|
64
|
+
>>> cursor.execute(
|
|
65
|
+
... "INSERT INTO locations (name, date, kind, position) "
|
|
66
|
+
... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)",
|
|
67
|
+
... {"name": "Einstein Cross", "date": "2007-03-11", "kind": "Quasar", "pos": 7})
|
|
68
|
+
|
|
69
|
+
The same parameter name may appear multiple times in the query:
|
|
70
|
+
|
|
71
|
+
>>> cursor.execute(
|
|
72
|
+
... "SELECT * FROM locations WHERE name = %(q)s OR kind = %(q)s",
|
|
73
|
+
... {"q": "Quasar"})
|
|
74
|
+
|
|
75
|
+
The client converts the ``%(name)s`` placeholders to ``$N`` positional
|
|
76
|
+
markers before sending the query to CrateDB.
|
|
77
|
+
|
|
57
78
|
Bulk inserts
|
|
58
79
|
------------
|
|
59
80
|
|
|
60
81
|
:ref:`Bulk inserts <crate-reference:http-bulk-ops>` are possible with the
|
|
61
|
-
``executemany()`` method
|
|
82
|
+
``executemany()`` method.
|
|
83
|
+
|
|
84
|
+
Positional parameters
|
|
85
|
+
.....................
|
|
86
|
+
|
|
87
|
+
Pass a :class:`py:list` of sequences using ``?`` placeholders:
|
|
62
88
|
|
|
63
89
|
>>> cursor.executemany(
|
|
64
90
|
... "INSERT INTO locations (name, date, kind, position) VALUES (?, ?, ?, ?)",
|
|
@@ -67,10 +93,58 @@ Bulk inserts
|
|
|
67
93
|
[{'rowcount': 1}, {'rowcount': 1}]
|
|
68
94
|
|
|
69
95
|
The ``executemany()`` method returns a result :class:`dictionary <py:dict>`
|
|
70
|
-
for every
|
|
96
|
+
for every row. This dictionary always has a ``rowcount`` key, indicating
|
|
71
97
|
how many rows were inserted. If an error occurs, the ``rowcount`` value is
|
|
72
98
|
``-2``, and the dictionary may additionally have an ``error_message`` key.
|
|
73
99
|
|
|
100
|
+
Named parameters
|
|
101
|
+
................
|
|
102
|
+
|
|
103
|
+
``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` using
|
|
104
|
+
``%(name)s`` placeholders. The client converts both the SQL template and all
|
|
105
|
+
rows to positional format before sending to CrateDB:
|
|
106
|
+
|
|
107
|
+
>>> cursor.executemany(
|
|
108
|
+
... "INSERT INTO locations (name, date, kind, position) "
|
|
109
|
+
... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)",
|
|
110
|
+
... [{"name": "Cloverleaf", "date": "2007-03-11", "kind": "Quasar", "pos": 7},
|
|
111
|
+
... {"name": "Old Faithful", "date": "2007-03-11", "kind": "Quasar", "pos": 8}])
|
|
112
|
+
[{'rowcount': 1}, {'rowcount': 1}]
|
|
113
|
+
|
|
114
|
+
Using ``bulk_parameters`` directly
|
|
115
|
+
...................................
|
|
116
|
+
|
|
117
|
+
``execute()`` accepts a ``bulk_parameters`` keyword argument directly:
|
|
118
|
+
|
|
119
|
+
.. NOTE::
|
|
120
|
+
Please prefer ``executemany()`` for bulk inserts, it is the standard DB-API 2.0
|
|
121
|
+
interface. The ``bulk_parameters`` argument is a lower-level alternative.
|
|
122
|
+
|
|
123
|
+
>>> cursor.execute(
|
|
124
|
+
... "INSERT INTO locations (name, kind, position) VALUES (?, ?, ?)",
|
|
125
|
+
... bulk_parameters=[('Cloverleaf', 'Quasar', 7),
|
|
126
|
+
... ('Old Faithful', 'Quasar', 8)])
|
|
127
|
+
|
|
128
|
+
Named ``%(name)s`` placeholders are also supported. When the rows are
|
|
129
|
+
:class:`py:dict` objects the SQL template and rows are fully converted,
|
|
130
|
+
identical to the ``executemany()`` path:
|
|
131
|
+
|
|
132
|
+
>>> cursor.execute(
|
|
133
|
+
... "INSERT INTO locations (name, kind, position) "
|
|
134
|
+
... "VALUES (%(name)s, %(kind)s, %(pos)s)",
|
|
135
|
+
... bulk_parameters=[{"name": "Cloverleaf", "kind": "Quasar", "pos": 7},
|
|
136
|
+
... {"name": "Old Faithful", "kind": "Quasar", "pos": 8}])
|
|
137
|
+
|
|
138
|
+
When the rows are already positional lists (e.g. data coming from a
|
|
139
|
+
DataFrame), only the SQL template is rewritten. In this case the caller must
|
|
140
|
+
ensure the value order in each row matches the placeholder order in the SQL:
|
|
141
|
+
|
|
142
|
+
>>> cursor.execute(
|
|
143
|
+
... "INSERT INTO locations (name, kind, position) "
|
|
144
|
+
... "VALUES (%(name)s, %(kind)s, %(pos)s)",
|
|
145
|
+
... bulk_parameters=[['Cloverleaf', 'Quasar', 7],
|
|
146
|
+
... ['Old Faithful', 'Quasar', 8]])
|
|
147
|
+
|
|
74
148
|
.. _selects:
|
|
75
149
|
|
|
76
150
|
Selecting data
|
|
@@ -125,6 +125,94 @@ def test_cursor_executemany(mocked_connection):
|
|
|
125
125
|
assert response["results"] == result
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def test_executemany_with_named_params(mocked_connection):
|
|
129
|
+
"""
|
|
130
|
+
Verify that executemany() translates pyformat %(name)s placeholders to
|
|
131
|
+
positional $N markers and converts each dict row to a positional list.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
response = {
|
|
135
|
+
"col_types": [],
|
|
136
|
+
"cols": [],
|
|
137
|
+
"duration": 123,
|
|
138
|
+
"results": [{"rowcount": 1}, {"rowcount": 1}],
|
|
139
|
+
}
|
|
140
|
+
with mock.patch.object(
|
|
141
|
+
mocked_connection.client, "sql", return_value=response
|
|
142
|
+
):
|
|
143
|
+
cursor = mocked_connection.cursor()
|
|
144
|
+
cursor.executemany(
|
|
145
|
+
"INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
|
|
146
|
+
[
|
|
147
|
+
{"name": "Arthur", "age": 42},
|
|
148
|
+
{"name": "Bill", "age": 35},
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
|
|
152
|
+
assert sql == "INSERT INTO characters (name, age) VALUES ($1, $2)"
|
|
153
|
+
assert bulk_args == [["Arthur", 42], ["Bill", 35]]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_executemany_with_named_params_missing_key(mocked_connection):
|
|
157
|
+
"""
|
|
158
|
+
Verify that executemany() raises ProgrammingError when a row is missing a
|
|
159
|
+
key that appears as a placeholder in the SQL.
|
|
160
|
+
"""
|
|
161
|
+
cursor = mocked_connection.cursor()
|
|
162
|
+
with pytest.raises(
|
|
163
|
+
ProgrammingError, match="Named parameter 'age' not found"
|
|
164
|
+
):
|
|
165
|
+
cursor.executemany(
|
|
166
|
+
"INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
|
|
167
|
+
[
|
|
168
|
+
{"name": "Arthur", "age": 42},
|
|
169
|
+
{"name": "Bill"}, # missing 'age'
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
mocked_connection.client.sql.assert_not_called()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_executemany_with_named_params_repeated(mocked_connection):
|
|
176
|
+
"""
|
|
177
|
+
Verify that a placeholder name used multiple times in the SQL maps to the
|
|
178
|
+
same $N position in every occurrence, and the value appears only once in
|
|
179
|
+
each row's positional list.
|
|
180
|
+
"""
|
|
181
|
+
response = {
|
|
182
|
+
"col_types": [],
|
|
183
|
+
"cols": [],
|
|
184
|
+
"duration": 123,
|
|
185
|
+
"results": [{"rowcount": 1}, {"rowcount": 1}],
|
|
186
|
+
}
|
|
187
|
+
with mock.patch.object(
|
|
188
|
+
mocked_connection.client, "sql", return_value=response
|
|
189
|
+
):
|
|
190
|
+
cursor = mocked_connection.cursor()
|
|
191
|
+
cursor.executemany(
|
|
192
|
+
"INSERT INTO t (a, b) VALUES (%(x)s, %(x)s)",
|
|
193
|
+
[{"x": 1}, {"x": 2}],
|
|
194
|
+
)
|
|
195
|
+
sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
|
|
196
|
+
assert sql == "INSERT INTO t (a, b) VALUES ($1, $1)"
|
|
197
|
+
assert bulk_args == [[1], [2]]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_executemany_with_mixed_param_types(mocked_connection):
|
|
201
|
+
"""
|
|
202
|
+
Verify that executemany() raises a clear ProgrammingError when the
|
|
203
|
+
parameter sequence mixes dicts and non-dicts while the SQL uses pyformat.
|
|
204
|
+
"""
|
|
205
|
+
cursor = mocked_connection.cursor()
|
|
206
|
+
with pytest.raises(
|
|
207
|
+
ProgrammingError, match="All bulk parameter rows must be dicts"
|
|
208
|
+
):
|
|
209
|
+
cursor.executemany(
|
|
210
|
+
"INSERT INTO characters (name) VALUES (%(name)s)",
|
|
211
|
+
[{"name": "Arthur"}, ["Trillian"]], # second row is a list
|
|
212
|
+
)
|
|
213
|
+
mocked_connection.client.sql.assert_not_called()
|
|
214
|
+
|
|
215
|
+
|
|
128
216
|
def test_create_with_timezone_as_datetime_object(mocked_connection):
|
|
129
217
|
"""
|
|
130
218
|
The cursor can return timezone-aware `datetime` objects when requested.
|
|
@@ -243,6 +331,68 @@ def test_execute_with_bulk_args(mocked_connection):
|
|
|
243
331
|
mocked_connection.client.sql.assert_called_once_with(statement, None, [[1]])
|
|
244
332
|
|
|
245
333
|
|
|
334
|
+
def test_execute_with_pyformat_sql_and_bulk_parameters(mocked_connection):
|
|
335
|
+
"""
|
|
336
|
+
cursor.execute() converts %(name)s SQL to $N when bulk_parameters is
|
|
337
|
+
provided. Rows are already positional; only the SQL needs conversion.
|
|
338
|
+
"""
|
|
339
|
+
cursor = mocked_connection.cursor()
|
|
340
|
+
sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
|
|
341
|
+
bulk = [[1, "hello"], [2, "world"]]
|
|
342
|
+
cursor.execute(sql, bulk_parameters=bulk)
|
|
343
|
+
mocked_connection.client.sql.assert_called_once_with(
|
|
344
|
+
"INSERT INTO t (id, val) VALUES ($1, $2)", None, bulk
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_execute_with_pyformat_sql_and_dict_bulk_parameters(mocked_connection):
|
|
349
|
+
"""
|
|
350
|
+
cursor.execute() with pyformat SQL and dict-format bulk_parameters converts
|
|
351
|
+
both the SQL template (%(x)s → $N) and the rows (dicts → positional lists).
|
|
352
|
+
"""
|
|
353
|
+
cursor = mocked_connection.cursor()
|
|
354
|
+
sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
|
|
355
|
+
bulk = [{"id": 1, "val": "hello"}, {"id": 2, "val": "world"}]
|
|
356
|
+
cursor.execute(sql, bulk_parameters=bulk)
|
|
357
|
+
mocked_connection.client.sql.assert_called_once_with(
|
|
358
|
+
"INSERT INTO t (id, val) VALUES ($1, $2)",
|
|
359
|
+
None,
|
|
360
|
+
[[1, "hello"], [2, "world"]],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_execute_with_dict_bulk_parameters_mixed_types_raises(
|
|
365
|
+
mocked_connection,
|
|
366
|
+
):
|
|
367
|
+
"""
|
|
368
|
+
cursor.execute() raises ProgrammingError when bulk_parameters mixes
|
|
369
|
+
dict and non-dict rows with pyformat SQL.
|
|
370
|
+
"""
|
|
371
|
+
cursor = mocked_connection.cursor()
|
|
372
|
+
with pytest.raises(
|
|
373
|
+
ProgrammingError, match="All bulk parameter rows must be dicts"
|
|
374
|
+
):
|
|
375
|
+
cursor.execute(
|
|
376
|
+
"INSERT INTO t (id) VALUES (%(id)s)",
|
|
377
|
+
bulk_parameters=[{"id": 1}, [2]],
|
|
378
|
+
)
|
|
379
|
+
mocked_connection.client.sql.assert_not_called()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_execute_with_pyformat_sql_and_bulk_parameters_no_placeholders(
|
|
383
|
+
mocked_connection,
|
|
384
|
+
):
|
|
385
|
+
"""
|
|
386
|
+
SQL without %(name)s placeholders is passed through unchanged
|
|
387
|
+
even when bulk_parameters is provided.
|
|
388
|
+
"""
|
|
389
|
+
cursor = mocked_connection.cursor()
|
|
390
|
+
sql = "INSERT INTO t (id, val) VALUES (?, ?)"
|
|
391
|
+
bulk = [[1, "hello"], [2, "world"]]
|
|
392
|
+
cursor.execute(sql, bulk_parameters=bulk)
|
|
393
|
+
mocked_connection.client.sql.assert_called_once_with(sql, None, bulk)
|
|
394
|
+
|
|
395
|
+
|
|
246
396
|
def test_execute_custom_converter(mocked_connection):
|
|
247
397
|
"""
|
|
248
398
|
Verify that a custom converter is correctly applied when passed to a cursor.
|
|
@@ -293,6 +443,41 @@ def test_execute_custom_converter(mocked_connection):
|
|
|
293
443
|
]
|
|
294
444
|
|
|
295
445
|
|
|
446
|
+
def test_execute_time_converter(mocked_connection):
|
|
447
|
+
"""
|
|
448
|
+
Verify that CrateDB's TIMETZ wire format
|
|
449
|
+
``[microseconds, tz_offset_seconds]`` is decoded to a ``datetime.time``
|
|
450
|
+
object by ``DefaultTypeConverter``.
|
|
451
|
+
"""
|
|
452
|
+
converter = DefaultTypeConverter()
|
|
453
|
+
cursor = mocked_connection.cursor(converter=converter)
|
|
454
|
+
response = {
|
|
455
|
+
"col_types": [20],
|
|
456
|
+
"cols": ["t"],
|
|
457
|
+
"rows": [
|
|
458
|
+
[[45045000000, 0]], # 12:30:45 UTC
|
|
459
|
+
[[45045123456, 7200]], # 12:30:45.123456 +02:00
|
|
460
|
+
[None],
|
|
461
|
+
],
|
|
462
|
+
"rowcount": 3,
|
|
463
|
+
"duration": 1,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
with mock.patch.object(
|
|
467
|
+
mocked_connection.client, "sql", return_value=response
|
|
468
|
+
):
|
|
469
|
+
cursor.execute("")
|
|
470
|
+
result = cursor.fetchall()
|
|
471
|
+
|
|
472
|
+
assert result == [
|
|
473
|
+
[datetime.time(12, 30, 45, 0,
|
|
474
|
+
tzinfo=datetime.timezone.utc)],
|
|
475
|
+
[datetime.time(12, 30, 45, 123456,
|
|
476
|
+
tzinfo=datetime.timezone(datetime.timedelta(hours=2)))],
|
|
477
|
+
[None],
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
|
|
296
481
|
def test_execute_with_converter_and_invalid_data_type(mocked_connection):
|
|
297
482
|
converter = DefaultTypeConverter()
|
|
298
483
|
|
|
@@ -492,6 +677,62 @@ def test_execute_with_timezone(mocked_connection):
|
|
|
492
677
|
assert result[0][1].tzname() == "UTC"
|
|
493
678
|
|
|
494
679
|
|
|
680
|
+
def test_execute_with_named_params(mocked_connection):
|
|
681
|
+
"""
|
|
682
|
+
Verify that named %(name)s parameters are converted to positional ? markers
|
|
683
|
+
and the values are passed as an ordered list.
|
|
684
|
+
"""
|
|
685
|
+
cursor = mocked_connection.cursor()
|
|
686
|
+
cursor.execute(
|
|
687
|
+
"SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
|
|
688
|
+
{"a": 1, "b": 2},
|
|
689
|
+
)
|
|
690
|
+
mocked_connection.client.sql.assert_called_once_with(
|
|
691
|
+
"SELECT * FROM t WHERE a = $1 AND b = $2", [1, 2], None
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def test_execute_with_named_params_repeated(mocked_connection):
|
|
696
|
+
"""
|
|
697
|
+
Verify that a parameter name used multiple times in the SQL is resolved
|
|
698
|
+
correctly each time it appears.
|
|
699
|
+
"""
|
|
700
|
+
cursor = mocked_connection.cursor()
|
|
701
|
+
cursor.execute("SELECT %(x)s, %(x)s", {"x": 42})
|
|
702
|
+
mocked_connection.client.sql.assert_called_once_with(
|
|
703
|
+
"SELECT $1, $1", [42], None
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def test_execute_with_named_params_missing(mocked_connection):
|
|
708
|
+
"""
|
|
709
|
+
Verify that a ProgrammingError is raised when a placeholder name is absent
|
|
710
|
+
from the parameters dict, and that the client is never called.
|
|
711
|
+
"""
|
|
712
|
+
cursor = mocked_connection.cursor()
|
|
713
|
+
with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
|
|
714
|
+
cursor.execute("SELECT %(z)s", {"a": 1})
|
|
715
|
+
mocked_connection.client.sql.assert_not_called()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def test_execute_with_named_params_non_identifier_keys(mocked_connection):
|
|
719
|
+
"""
|
|
720
|
+
Verify that %(name)s placeholders whose name contains characters outside
|
|
721
|
+
[a-zA-Z0-9_] are still converted to positional $N markers.
|
|
722
|
+
|
|
723
|
+
"""
|
|
724
|
+
cursor = mocked_connection.cursor()
|
|
725
|
+
|
|
726
|
+
cursor.execute(
|
|
727
|
+
"UPDATE characters SET data['x'] = %(data['x'])s WHERE name = %(name)s",
|
|
728
|
+
{"data['x']": 42, "name": "Berlin"},
|
|
729
|
+
)
|
|
730
|
+
sql, args, _ = mocked_connection.client.sql.call_args[0]
|
|
731
|
+
assert "%" not in sql
|
|
732
|
+
assert sql == "UPDATE characters SET data['x'] = $1 WHERE name = $2"
|
|
733
|
+
assert args == [42, "Berlin"]
|
|
734
|
+
|
|
735
|
+
|
|
495
736
|
def test_cursor_close(mocked_connection):
|
|
496
737
|
"""
|
|
497
738
|
Verify that a cursor is not closed if not specifically closed.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
# with Crate these terms will supersede the license and you may use the
|
|
20
20
|
# software solely pursuant to the terms of the relevant commercial agreement.
|
|
21
21
|
|
|
22
|
+
import gzip
|
|
22
23
|
import json
|
|
23
24
|
import os
|
|
24
25
|
import queue
|
|
@@ -200,6 +201,48 @@ def test_redirect_handling():
|
|
|
200
201
|
assert conn_kw == {"socket_options": _get_socket_opts(keepalive=True)}
|
|
201
202
|
|
|
202
203
|
|
|
204
|
+
@pytest.mark.parametrize("method,args,success_status", [
|
|
205
|
+
("blob_exists", ("blobs", "fake_digest"), 200),
|
|
206
|
+
("blob_put", ("blobs", "fake_digest", b"data"), 201),
|
|
207
|
+
("blob_del", ("blobs", "fake_digest"), 204),
|
|
208
|
+
("blob_get", ("blobs", "fake_digest"), 200),
|
|
209
|
+
])
|
|
210
|
+
def test_redirect_blob_preserves_basic_auth(method, args, success_status):
|
|
211
|
+
"""
|
|
212
|
+
Verify Basic HTTP auth credentials are forwarded when following blob
|
|
213
|
+
endpoint redirects.
|
|
214
|
+
"""
|
|
215
|
+
redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
|
|
216
|
+
success = fake_response(success_status)
|
|
217
|
+
|
|
218
|
+
with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
|
|
219
|
+
client = Client(
|
|
220
|
+
servers="localhost:4200", username="admin", password="secret"
|
|
221
|
+
)
|
|
222
|
+
getattr(client, method)(*args)
|
|
223
|
+
|
|
224
|
+
assert mock_req.call_count == 2
|
|
225
|
+
for call in mock_req.call_args_list:
|
|
226
|
+
assert call.kwargs.get("username") == "admin"
|
|
227
|
+
assert call.kwargs.get("password") == "secret"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_redirect_blob_preserves_jwt_auth():
|
|
231
|
+
"""
|
|
232
|
+
Verify JWT bearer token is forwarded when following blob endpoint redirects.
|
|
233
|
+
"""
|
|
234
|
+
redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
|
|
235
|
+
success = fake_response(200)
|
|
236
|
+
|
|
237
|
+
with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
|
|
238
|
+
client = Client(servers="localhost:4200", jwt_token="my.jwt.token")
|
|
239
|
+
client.blob_exists("blobs", "fake_digest")
|
|
240
|
+
|
|
241
|
+
assert mock_req.call_count == 2
|
|
242
|
+
for call in mock_req.call_args_list:
|
|
243
|
+
assert call.kwargs.get("jwt_token") == "my.jwt.token"
|
|
244
|
+
|
|
245
|
+
|
|
203
246
|
def test_server_infos():
|
|
204
247
|
"""
|
|
205
248
|
Verify that when a `MaxRetryError` is raised, a `ConnectionError` is raised.
|
|
@@ -735,3 +778,83 @@ def test_credentials_and_token(serve_http):
|
|
|
735
778
|
assert excinfo.match(
|
|
736
779
|
"Either JWT tokens are accepted, or user credentials, but not both"
|
|
737
780
|
)
|
|
781
|
+
|
|
782
|
+
def test_compress_accept_encoding_always_sent():
|
|
783
|
+
"""Accept-Encoding is sent even when compression is disabled."""
|
|
784
|
+
captured = {}
|
|
785
|
+
|
|
786
|
+
def capturing(*_, **kwargs):
|
|
787
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
788
|
+
return fake_response(200)
|
|
789
|
+
|
|
790
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
791
|
+
Client(servers="localhost:4200", compress=False).sql("SELECT 1")
|
|
792
|
+
assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def test_compress_false_no_content_encoding():
|
|
796
|
+
"""No Content-Encoding header when compress=False."""
|
|
797
|
+
captured = {}
|
|
798
|
+
|
|
799
|
+
def capturing(*_, **kwargs):
|
|
800
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
801
|
+
return fake_response(200)
|
|
802
|
+
|
|
803
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
804
|
+
Client(servers="localhost:4200", compress=False).sql("SELECT 1")
|
|
805
|
+
assert "Content-Encoding" not in captured["headers"]
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def test_compress_true_always_compresses():
|
|
809
|
+
"""compress=True compresses regardless of payload size."""
|
|
810
|
+
captured = {}
|
|
811
|
+
|
|
812
|
+
def capturing(*_, **kwargs):
|
|
813
|
+
captured["data"] = kwargs.get("data", b"")
|
|
814
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
815
|
+
return fake_response(200)
|
|
816
|
+
|
|
817
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
818
|
+
Client(servers="localhost:4200", compress=True).sql("SELECT 1")
|
|
819
|
+
assert captured["headers"].get("Content-Encoding") == "gzip"
|
|
820
|
+
assert b'"stmt"' in gzip.decompress(captured["data"])
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def test_compress_threshold_above():
|
|
824
|
+
"""Payload above threshold is compressed."""
|
|
825
|
+
captured = {}
|
|
826
|
+
|
|
827
|
+
def capturing(*_, **kwargs):
|
|
828
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
829
|
+
return fake_response(200)
|
|
830
|
+
|
|
831
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
832
|
+
Client(servers="localhost:4200", compress=0).sql("SELECT 1")
|
|
833
|
+
assert captured["headers"].get("Content-Encoding") == "gzip"
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def test_compress_threshold_below():
|
|
837
|
+
"""Payload below threshold is not compressed."""
|
|
838
|
+
captured = {}
|
|
839
|
+
|
|
840
|
+
def capturing(*_, **kwargs):
|
|
841
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
842
|
+
return fake_response(200)
|
|
843
|
+
|
|
844
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
845
|
+
Client(servers="localhost:4200", compress=999_999).sql("SELECT 1")
|
|
846
|
+
assert "Content-Encoding" not in captured["headers"]
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def test_compress_default():
|
|
850
|
+
"""Default args: Accept-Encoding sent, small payload not compressed."""
|
|
851
|
+
captured = {}
|
|
852
|
+
|
|
853
|
+
def capturing(*_, **kwargs):
|
|
854
|
+
captured["headers"] = kwargs.get("headers") or {}
|
|
855
|
+
return fake_response(200)
|
|
856
|
+
|
|
857
|
+
with patch(REQUEST_PATH, side_effect=capturing):
|
|
858
|
+
Client(servers="localhost:4200").sql("SELECT 1")
|
|
859
|
+
assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
|
|
860
|
+
assert "Content-Encoding" not in captured["headers"]
|
|
@@ -125,6 +125,44 @@ def test_date_serialization():
|
|
|
125
125
|
assert result == b"1461196800000"
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def test_naive_time_serialization():
|
|
129
|
+
"""
|
|
130
|
+
Verify that a naive `datetime.time` serializes to an ISO 8601 string.
|
|
131
|
+
"""
|
|
132
|
+
data = dt.time(12, 30, 45)
|
|
133
|
+
result = json_dumps(data)
|
|
134
|
+
assert result == b'"12:30:45"'
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_time_with_microseconds_serialization():
|
|
138
|
+
"""
|
|
139
|
+
Verify that `datetime.time` with microseconds serializes correctly.
|
|
140
|
+
"""
|
|
141
|
+
data = dt.time(12, 30, 45, 123456)
|
|
142
|
+
result = json_dumps(data)
|
|
143
|
+
assert result == b'"12:30:45.123456"'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_aware_time_serialization():
|
|
147
|
+
"""
|
|
148
|
+
Verify that a timezone-aware `datetime.time` serializes to ISO 8601 format,
|
|
149
|
+
including the UTC offset.
|
|
150
|
+
"""
|
|
151
|
+
data = dt.time(12, 30, 45, tzinfo=dt.timezone.utc)
|
|
152
|
+
result = json_dumps(data)
|
|
153
|
+
assert result == b'"12:30:45+00:00"'
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_aware_time_with_offset_serialization():
|
|
157
|
+
"""
|
|
158
|
+
Verify that a `datetime.time` with a non-UTC offset serializes correctly.
|
|
159
|
+
"""
|
|
160
|
+
tz = dt.timezone(dt.timedelta(hours=2))
|
|
161
|
+
data = dt.time(12, 30, 45, tzinfo=tz)
|
|
162
|
+
result = json_dumps(data)
|
|
163
|
+
assert result == b'"12:30:45+02:00"'
|
|
164
|
+
|
|
165
|
+
|
|
128
166
|
def test_uuid_serialization():
|
|
129
167
|
"""
|
|
130
168
|
Verify that a `uuid.UUID` can be serialized.
|
|
@@ -22,13 +22,11 @@
|
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
24
|
import tempfile
|
|
25
|
-
import urllib
|
|
26
25
|
from io import BytesIO
|
|
27
26
|
from pathlib import Path
|
|
28
27
|
from unittest import TestCase, mock
|
|
29
28
|
|
|
30
29
|
import urllib3
|
|
31
|
-
from verlib2 import Version
|
|
32
30
|
|
|
33
31
|
import crate
|
|
34
32
|
from crate.testing.layer import (
|
|
@@ -38,7 +36,7 @@ from crate.testing.layer import (
|
|
|
38
36
|
wait_for_http_url,
|
|
39
37
|
)
|
|
40
38
|
from tests.client.settings import crate_path
|
|
41
|
-
from tests.conftest import download_cratedb
|
|
39
|
+
from tests.conftest import download_cratedb, get_crate_url
|
|
42
40
|
|
|
43
41
|
|
|
44
42
|
class LayerUtilsTest(TestCase):
|
|
@@ -86,18 +84,10 @@ class LayerUtilsTest(TestCase):
|
|
|
86
84
|
The CrateLayer can also be created by providing an URI that points to
|
|
87
85
|
a CrateDB tarball.
|
|
88
86
|
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
version = versions["crate_testing"]
|
|
94
|
-
|
|
95
|
-
self.assertGreaterEqual(Version(version), Version("4.5.0"))
|
|
96
|
-
|
|
97
|
-
uri = "https://cdn.crate.io/downloads/releases/crate-{}.tar.gz".format(
|
|
98
|
-
version
|
|
99
|
-
)
|
|
100
|
-
layer = CrateLayer.from_uri(uri, name="crate-by-uri", http_port=42203)
|
|
87
|
+
layer = CrateLayer.from_uri(get_crate_url(),
|
|
88
|
+
name="crate-by-uri",
|
|
89
|
+
http_port=42203
|
|
90
|
+
)
|
|
101
91
|
self.assertIsInstance(layer, CrateLayer)
|
|
102
92
|
|
|
103
93
|
@mock.patch.dict("os.environ", {}, clear=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|