Skip to content

Commit bbef5a4

Browse files
author
Erlend E. Aasland
committed
Add support for sqlite3 aggregate window functions
See https://docs.python.org/3/library/exceptions.html#NotImplementedError: It should not be used to indicate that an operator or method is not meant to be supported at all – in that case either leave the operator / method undefined or, if a subclass, set it to None.
1 parent fe24088 commit bbef5a4

File tree

5 files changed

+531
-13
lines changed

5 files changed

+531
-13
lines changed

Doc/includes/sqlite3/sumintwindow.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Example taken from https://www.sqlite.org/windowfunctions.html#udfwinfunc
2+
import sqlite3
3+
4+
5+
class WindowSumInt:
6+
def __init__(self):
7+
self.count = 0
8+
9+
def step(self, value):
10+
"""This method is invoked to add a row to the current window."""
11+
self.count += value
12+
13+
def value(self):
14+
"""This method is invoked to return the current value of the aggregate."""
15+
return self.count
16+
17+
def inverse(self, value):
18+
"""This method is invoked to remove a row from the current window."""
19+
self.count -= value
20+
21+
def finalize(self):
22+
"""This method is invoked to return the current value of the aggregate.
23+
24+
Any clean-up actions should be placed here.
25+
"""
26+
return self.count
27+
28+
29+
con = sqlite3.connect(":memory:")
30+
cur = con.execute("create table test(x, y)")
31+
values = [
32+
("a", 4),
33+
("b", 5),
34+
("c", 3),
35+
("d", 8),
36+
("e", 1),
37+
]
38+
cur.executemany("insert into test values(?, ?)", values)
39+
con.create_window_function("sumint", 1, WindowSumInt)
40+
cur.execute("""
41+
select x, sumint(y) over (
42+
order by x rows between 1 preceding and 1 following
43+
) as sum_y
44+
from test order by x
45+
""")
46+
print(cur.fetchall())

Doc/library/sqlite3.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,52 @@ Connection Objects
379379
.. literalinclude:: ../includes/sqlite3/mysumaggr.py
380380

381381

382+
.. method:: create_window_function(name, num_params, aggregate_class, /, *,
383+
deterministic=False, innocuous=False,
384+
directonly=False)
385+
386+
Creates a user-defined aggregate window function. Aggregate window
387+
functions are supported by SQLite 3.25.0 and higher.
388+
:exc:`NotSupportedError` will be raised if used with older
389+
versions.
390+
391+
The aggregate class must implement ``step`` and ``inverse``
392+
methods, which accept the number of parameters *num_params* (if
393+
*num_params* is -1, the function may take any number of arguments),
394+
and ``finalize`` and ``value`` methods which return the final and
395+
the current result of the aggregate.
396+
397+
The ``finalize`` and ``value`` methods can return any of the types
398+
supported by SQLite: :class:`bytes`, :class:`str`, :class:`int`,
399+
:class:`float` and :const:`None`.
400+
401+
If *deterministic* is :const:`True`, the created function is marked as
402+
`deterministic <https://sqlite.org/deterministic.html>`_, which
403+
allows SQLite to perform additional optimizations. This flag is
404+
supported by SQLite 3.8.3 or higher. :exc:`NotSupportedError` will
405+
be raised if used with older versions.
406+
407+
If *innocuous* is :const:`True`, the created function is marked as
408+
`innocuous <https://sqlite.org/deterministic.html>`_, which
409+
indicates to SQLite that it is unlikely to cause problems, even if
410+
misused. This flag is supported by SQLite 3.31.0 or higher.
411+
:exc:`NotSupportedError` will be raised if used with older
412+
versions.
413+
414+
If *directonly* is :const:`True`, the created function is marked as
415+
`directonly <https://sqlite.org/deterministic.html>`_, which
416+
means that it may only be invoked from top-level SQL. This flag
417+
is an SQLite security feature that is recommended for all
418+
user-defined SQL functions. This flag is supported by SQLite
419+
3.30.0 or higher. :exc:`NotSupportedError` will be raised if used
420+
with older versions.
421+
422+
.. versionadded:: 3.10
423+
424+
Example:
425+
426+
.. literalinclude:: ../includes/sqlite3/sumintwindow.py
427+
382428
.. method:: create_collation(name, callable)
383429

384430
Creates a collation with the specified *name* and *callable*. The callable will

Lib/sqlite3/test/userfunctions.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,85 @@ def test_func_deterministic_keyword_only(self):
321321
self.con.create_function("deterministic", 0, int, True)
322322

323323

324+
class WindowSumInt:
325+
def __init__(self):
326+
self.count = 0
327+
328+
def step(self, value):
329+
self.count += value
330+
331+
def value(self):
332+
return self.count
333+
334+
def inverse(self, value):
335+
self.count -= value
336+
337+
def finalize(self):
338+
return self.count
339+
340+
341+
@unittest.skipIf(sqlite.sqlite_version_info < (3, 25, 0),
342+
"Requires SQLite 3.25.0 or newer")
343+
class WindowFunctionTests(unittest.TestCase):
344+
def setUp(self):
345+
self.con = sqlite.connect(":memory:")
346+
347+
# Test case taken from https://www.sqlite.org/windowfunctions.html#udfwinfunc
348+
values = [
349+
("a", 4),
350+
("b", 5),
351+
("c", 3),
352+
("d", 8),
353+
("e", 1),
354+
]
355+
self.cur = self.con.execute("create table test(x, y)")
356+
self.cur.executemany("insert into test values(?, ?)", values)
357+
self.expected = [
358+
("a", 9),
359+
("b", 12),
360+
("c", 16),
361+
("d", 12),
362+
("e", 9),
363+
]
364+
self.query = ("""
365+
select x, %s(y) over (
366+
order by x rows between 1 preceding and 1 following
367+
) as sum_y
368+
from test order by x
369+
""")
370+
self.con.create_window_function("sumint", 1, WindowSumInt)
371+
372+
def test_sum_int(self):
373+
self.cur.execute(self.query % "sumint")
374+
self.assertEqual(self.cur.fetchall(), self.expected)
375+
376+
def test_error_on_create(self):
377+
with self.assertRaises(sqlite.OperationalError):
378+
self.con.create_window_function("shouldfail", -100, WindowSumInt)
379+
380+
def test_exception_in_method(self):
381+
for meth in ["step", "value", "inverse"]:
382+
with unittest.mock.patch.object(WindowSumInt, meth,
383+
side_effect=Exception):
384+
func = f"exc_{meth}"
385+
self.con.create_window_function(func, 1, WindowSumInt)
386+
with self.assertRaises(sqlite.OperationalError):
387+
self.cur.execute(self.query % func)
388+
ret = self.cur.fetchall()
389+
390+
def test_clear_function(self):
391+
self.con.create_window_function("sumint", 1, None)
392+
with self.assertRaises(sqlite.OperationalError):
393+
self.cur.execute(self.query % "sumint")
394+
395+
def test_redefine_function(self):
396+
class Redefined(WindowSumInt):
397+
pass
398+
self.con.create_window_function("sumint", 1, Redefined)
399+
self.cur.execute(self.query % "sumint")
400+
self.assertEqual(self.cur.fetchall(), self.expected)
401+
402+
324403
class AggregateTests(unittest.TestCase):
325404
def setUp(self):
326405
self.con = sqlite.connect(":memory:")
@@ -510,6 +589,7 @@ def suite():
510589
AuthorizerRaiseExceptionTests,
511590
AuthorizerTests,
512591
FunctionTests,
592+
WindowFunctionTests,
513593
]
514594
return unittest.TestSuite(
515595
[unittest.TestLoader().loadTestsFromTestCase(t) for t in tests]

Modules/_sqlite/clinic/connection.c.h

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,109 @@ pysqlite_connection_create_function(pysqlite_Connection *self, PyObject *const *
156156
return return_value;
157157
}
158158

159+
#if defined(HAVE_WINDOW_FUNCTIONS)
160+
161+
PyDoc_STRVAR(pysqlite_connection_create_window_function__doc__,
162+
"create_window_function($self, name, num_params, aggregate_class, /, *,\n"
163+
" deterministic=False, directonly=False,\n"
164+
" innocuous=False)\n"
165+
"--\n"
166+
"\n"
167+
"Creates or redefines an aggregate window function. Non-standard.\n"
168+
"\n"
169+
" name\n"
170+
" The name of the SQL aggregate window function to be created or\n"
171+
" redefined.\n"
172+
" num_params\n"
173+
" The number of arguments that the SQL aggregate window function\n"
174+
" takes.\n"
175+
" aggregate_class\n"
176+
" A class with step(), finalize(), value(), and inverse() methods.\n"
177+
" Set to None to clear the window function.");
178+
179+
#define PYSQLITE_CONNECTION_CREATE_WINDOW_FUNCTION_METHODDEF \
180+
{"create_window_function", (PyCFunction)(void(*)(void))pysqlite_connection_create_window_function, METH_FASTCALL|METH_KEYWORDS, pysqlite_connection_create_window_function__doc__},
181+
182+
static PyObject *
183+
pysqlite_connection_create_window_function_impl(pysqlite_Connection *self,
184+
const char *name,
185+
int num_params,
186+
PyObject *aggregate_class,
187+
int deterministic,
188+
int directonly,
189+
int innocuous);
190+
191+
static PyObject *
192+
pysqlite_connection_create_window_function(pysqlite_Connection *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
193+
{
194+
PyObject *return_value = NULL;
195+
static const char * const _keywords[] = {"", "", "", "deterministic", "directonly", "innocuous", NULL};
196+
static _PyArg_Parser _parser = {NULL, _keywords, "create_window_function", 0};
197+
PyObject *argsbuf[6];
198+
Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 3;
199+
const char *name;
200+
int num_params;
201+
PyObject *aggregate_class;
202+
int deterministic = 0;
203+
int directonly = 0;
204+
int innocuous = 0;
205+
206+
args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, 3, 3, 0, argsbuf);
207+
if (!args) {
208+
goto exit;
209+
}
210+
if (!PyUnicode_Check(args[0])) {
211+
_PyArg_BadArgument("create_window_function", "argument 1", "str", args[0]);
212+
goto exit;
213+
}
214+
Py_ssize_t name_length;
215+
name = PyUnicode_AsUTF8AndSize(args[0], &name_length);
216+
if (name == NULL) {
217+
goto exit;
218+
}
219+
if (strlen(name) != (size_t)name_length) {
220+
PyErr_SetString(PyExc_ValueError, "embedded null character");
221+
goto exit;
222+
}
223+
num_params = _PyLong_AsInt(args[1]);
224+
if (num_params == -1 && PyErr_Occurred()) {
225+
goto exit;
226+
}
227+
aggregate_class = args[2];
228+
if (!noptargs) {
229+
goto skip_optional_kwonly;
230+
}
231+
if (args[3]) {
232+
deterministic = PyObject_IsTrue(args[3]);
233+
if (deterministic < 0) {
234+
goto exit;
235+
}
236+
if (!--noptargs) {
237+
goto skip_optional_kwonly;
238+
}
239+
}
240+
if (args[4]) {
241+
directonly = PyObject_IsTrue(args[4]);
242+
if (directonly < 0) {
243+
goto exit;
244+
}
245+
if (!--noptargs) {
246+
goto skip_optional_kwonly;
247+
}
248+
}
249+
innocuous = PyObject_IsTrue(args[5]);
250+
if (innocuous < 0) {
251+
goto exit;
252+
}
253+
skip_optional_kwonly:
254+
return_value = pysqlite_connection_create_window_function_impl(self, name, num_params, aggregate_class, deterministic, directonly, innocuous);
255+
256+
exit:
257+
return return_value;
258+
}
259+
260+
#endif /* defined(HAVE_WINDOW_FUNCTIONS) */
261+
159262
PyDoc_STRVAR(pysqlite_connection_create_aggregate__doc__,
160263
"create_aggregate($self, /, name, n_arg, aggregate_class)\n"
161264
"--\n"
@@ -703,11 +806,15 @@ pysqlite_connection_exit(pysqlite_Connection *self, PyObject *const *args, Py_ss
703806
return return_value;
704807
}
705808

809+
#ifndef PYSQLITE_CONNECTION_CREATE_WINDOW_FUNCTION_METHODDEF
810+
#define PYSQLITE_CONNECTION_CREATE_WINDOW_FUNCTION_METHODDEF
811+
#endif /* !defined(PYSQLITE_CONNECTION_CREATE_WINDOW_FUNCTION_METHODDEF) */
812+
706813
#ifndef PYSQLITE_CONNECTION_ENABLE_LOAD_EXTENSION_METHODDEF
707814
#define PYSQLITE_CONNECTION_ENABLE_LOAD_EXTENSION_METHODDEF
708815
#endif /* !defined(PYSQLITE_CONNECTION_ENABLE_LOAD_EXTENSION_METHODDEF) */
709816

710817
#ifndef PYSQLITE_CONNECTION_LOAD_EXTENSION_METHODDEF
711818
#define PYSQLITE_CONNECTION_LOAD_EXTENSION_METHODDEF
712819
#endif /* !defined(PYSQLITE_CONNECTION_LOAD_EXTENSION_METHODDEF) */
713-
/*[clinic end generated code: output=c1bf09db3bcd0105 input=a9049054013a1b77]*/
820+
/*[clinic end generated code: output=54e136772a234bed input=a9049054013a1b77]*/

0 commit comments

Comments
 (0)