perspective-python 5.6.0__cp311-abi3-pyemscripten_2026_0_wasm32.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.
- perspective/__init__.py +403 -0
- perspective/extension/finos-perspective-nbextension.json +5 -0
- perspective/handlers/__init__.py +11 -0
- perspective/handlers/aiohttp.py +69 -0
- perspective/handlers/starlette.py +63 -0
- perspective/handlers/tornado.py +192 -0
- perspective/perspective.abi3.so +0 -0
- perspective/templates/exported_widget.html.template +39 -0
- perspective/tests/__init__.py +11 -0
- perspective/tests/async/test_async_client.py +83 -0
- perspective/tests/async/test_websocket_client.py +124 -0
- perspective/tests/column_paths.py +33 -0
- perspective/tests/conftest.py +276 -0
- perspective/tests/core/__init__.py +11 -0
- perspective/tests/core/test_async.py +351 -0
- perspective/tests/multi_threaded/__init__.py +11 -0
- perspective/tests/multi_threaded/test_derived_threaded.py +103 -0
- perspective/tests/multi_threaded/test_multi_threaded.py +293 -0
- perspective/tests/server/__init__.py +11 -0
- perspective/tests/server/test_server.py +1016 -0
- perspective/tests/server/test_session.py +110 -0
- perspective/tests/table/__init__.py +11 -0
- perspective/tests/table/arrow/date32.arrow +0 -0
- perspective/tests/table/arrow/date64.arrow +0 -0
- perspective/tests/table/arrow/dict.arrow +0 -0
- perspective/tests/table/arrow/dict_update.arrow +0 -0
- perspective/tests/table/arrow/int_float_str.arrow +0 -0
- perspective/tests/table/arrow/int_float_str_file.arrow +0 -0
- perspective/tests/table/arrow/int_float_str_update.arrow +0 -0
- perspective/tests/table/object_sequence.py +402 -0
- perspective/tests/table/test_column_paths.py +88 -0
- perspective/tests/table/test_delete.py +124 -0
- perspective/tests/table/test_derived.py +145 -0
- perspective/tests/table/test_exception.py +73 -0
- perspective/tests/table/test_join.py +115 -0
- perspective/tests/table/test_leaks.py +105 -0
- perspective/tests/table/test_on_remove.py +140 -0
- perspective/tests/table/test_ports.py +178 -0
- perspective/tests/table/test_remove.py +102 -0
- perspective/tests/table/test_table.py +641 -0
- perspective/tests/table/test_table_arrow.py +500 -0
- perspective/tests/table/test_table_arrow_nested.py +535 -0
- perspective/tests/table/test_table_datetime.py +2544 -0
- perspective/tests/table/test_table_infer.py +201 -0
- perspective/tests/table/test_table_limit.py +45 -0
- perspective/tests/table/test_table_numpy.py +1022 -0
- perspective/tests/table/test_table_page_to_disk.py +304 -0
- perspective/tests/table/test_table_pandas.py +1018 -0
- perspective/tests/table/test_table_polars.py +251 -0
- perspective/tests/table/test_table_view_table.py +130 -0
- perspective/tests/table/test_to_arrow.py +416 -0
- perspective/tests/table/test_to_arrow_lz4.py +32 -0
- perspective/tests/table/test_to_arrow_zstd.py +41 -0
- perspective/tests/table/test_to_format.py +1030 -0
- perspective/tests/table/test_to_polars.py +26 -0
- perspective/tests/table/test_update.py +561 -0
- perspective/tests/table/test_update_arrow.py +980 -0
- perspective/tests/table/test_update_pandas.py +211 -0
- perspective/tests/table/test_view.py +2503 -0
- perspective/tests/table/test_view_expression.py +2256 -0
- perspective/tests/test_dependencies.py +53 -0
- perspective/tests/viewer/__init__.py +11 -0
- perspective/tests/viewer/test_viewer.py +246 -0
- perspective/tests/virtual_servers/__init__.py +12 -0
- perspective/tests/virtual_servers/test_coerce_types.py +464 -0
- perspective/tests/virtual_servers/test_duckdb.py +1280 -0
- perspective/tests/virtual_servers/test_polars.py +1075 -0
- perspective/tests/virtual_servers/test_postgres.py +964 -0
- perspective/tests/widget/__init__.py +11 -0
- perspective/tests/widget/test_anywidget_smoke.py +109 -0
- perspective/tests/widget/test_widget.py +278 -0
- perspective/tests/widget/test_widget_html_export.py +113 -0
- perspective/tests/widget/test_widget_pandas.py +453 -0
- perspective/virtual_servers/__init__.py +206 -0
- perspective/virtual_servers/clickhouse.py +296 -0
- perspective/virtual_servers/duckdb.py +367 -0
- perspective/virtual_servers/polars.py +750 -0
- perspective/virtual_servers/postgres.py +448 -0
- perspective/widget/__init__.py +351 -0
- perspective/widget/static/perspective-anywidget.css +1 -0
- perspective/widget/static/perspective-anywidget.js +31 -0
- perspective/widget/viewer/__init__.py +15 -0
- perspective/widget/viewer/viewer.py +355 -0
- perspective/widget/viewer/viewer_traitlets.py +98 -0
- perspective_python-5.6.0.dist-info/METADATA +127 -0
- perspective_python-5.6.0.dist-info/RECORD +90 -0
- perspective_python-5.6.0.dist-info/WHEEL +4 -0
- perspective_python-5.6.0.dist-info/licenses/LICENSE.md +193 -0
- perspective_python-5.6.0.dist-info/licenses/LICENSE_THIRDPARTY_cargo.yml +16075 -0
- perspective_python-5.6.0.dist-info/sboms/perspective-python.cyclonedx.json +5334 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
2
|
+
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
|
3
|
+
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
|
4
|
+
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
|
5
|
+
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
|
6
|
+
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
7
|
+
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
|
8
|
+
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
|
9
|
+
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
|
10
|
+
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
|
11
|
+
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
12
|
+
|
|
13
|
+
import perspective
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from perspective.virtual_servers import VirtualServerHandler, sql_table_describe
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
NUMBER_AGGS = [
|
|
23
|
+
"sum",
|
|
24
|
+
"count",
|
|
25
|
+
"any_value",
|
|
26
|
+
"arbitrary",
|
|
27
|
+
"array_agg",
|
|
28
|
+
"avg",
|
|
29
|
+
"bit_and",
|
|
30
|
+
"bit_or",
|
|
31
|
+
"bit_xor",
|
|
32
|
+
"bitstring_agg",
|
|
33
|
+
"bool_and",
|
|
34
|
+
"bool_or",
|
|
35
|
+
"countif",
|
|
36
|
+
"favg",
|
|
37
|
+
"fsum",
|
|
38
|
+
"geomean",
|
|
39
|
+
"kahan_sum",
|
|
40
|
+
"last",
|
|
41
|
+
"max",
|
|
42
|
+
"min",
|
|
43
|
+
"product",
|
|
44
|
+
"string_agg",
|
|
45
|
+
"sumkahan",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
STRING_AGGS = [
|
|
49
|
+
"count",
|
|
50
|
+
"any_value",
|
|
51
|
+
"arbitrary",
|
|
52
|
+
"first",
|
|
53
|
+
"countif",
|
|
54
|
+
"last",
|
|
55
|
+
"string_agg",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Window functions. Renamed from Perspective's `stddev`/`var` to the SQL
|
|
59
|
+
# standard spellings DuckDB and ClickHouse both accept, since the advertised
|
|
60
|
+
# name is now emitted verbatim.
|
|
61
|
+
#
|
|
62
|
+
# NOTE: this set is inherited from the DuckDB handler and has NOT been audited
|
|
63
|
+
# against a live ClickHouse - see the aggregate lists below, which have the
|
|
64
|
+
# same problem. ClickHouse's own navigation functions are `lagInFrame` /
|
|
65
|
+
# `leadInFrame`, and its ranking set differs; both need verifying before being
|
|
66
|
+
# advertised here.
|
|
67
|
+
FRAMES = ["rows", "range", "cumulative"]
|
|
68
|
+
|
|
69
|
+
WINDOW_AGGREGATES = [
|
|
70
|
+
{"name": "sum", "frames": FRAMES, "result_type": "float"},
|
|
71
|
+
{"name": "avg", "frames": FRAMES, "result_type": "float"},
|
|
72
|
+
{"name": "count", "frames": FRAMES, "result_type": "float"},
|
|
73
|
+
{"name": "min", "frames": FRAMES},
|
|
74
|
+
{"name": "max", "frames": FRAMES},
|
|
75
|
+
{"name": "stddev_samp", "frames": FRAMES, "result_type": "float"},
|
|
76
|
+
{"name": "var_samp", "frames": FRAMES, "result_type": "float"},
|
|
77
|
+
{"name": "lag", "offset": True},
|
|
78
|
+
{"name": "lead", "offset": True},
|
|
79
|
+
{"name": "diff", "offset": True, "result_type": "float"},
|
|
80
|
+
{"name": "rate", "frames": ["range"], "result_type": "float"},
|
|
81
|
+
{"name": "row_number", "result_type": "float"},
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
WINDOW_AGGREGATES_ANY = [
|
|
85
|
+
{"name": "count", "frames": FRAMES, "result_type": "float"},
|
|
86
|
+
{"name": "min", "frames": FRAMES},
|
|
87
|
+
{"name": "max", "frames": FRAMES},
|
|
88
|
+
{"name": "lag", "offset": True},
|
|
89
|
+
{"name": "lead", "offset": True},
|
|
90
|
+
{"name": "row_number", "result_type": "float"},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
FILTER_OPS = [
|
|
94
|
+
"==",
|
|
95
|
+
"!=",
|
|
96
|
+
"LIKE",
|
|
97
|
+
"IS DISTINCT FROM",
|
|
98
|
+
"IS NOT DISTINCT FROM",
|
|
99
|
+
">=",
|
|
100
|
+
"<=",
|
|
101
|
+
">",
|
|
102
|
+
"<",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ClickhouseVirtualSession:
|
|
107
|
+
def __init__(self, callback, db):
|
|
108
|
+
self.session = perspective.VirtualServer(ClickhouseVirtualServerHandler(db))
|
|
109
|
+
self.callback = callback
|
|
110
|
+
|
|
111
|
+
def handle_request(self, msg):
|
|
112
|
+
self.callback(self.session.handle_request(msg))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ClickhouseVirtualServer:
|
|
116
|
+
def __init__(self, db):
|
|
117
|
+
self.db = db
|
|
118
|
+
|
|
119
|
+
def new_session(self, callback):
|
|
120
|
+
return ClickhouseVirtualSession(callback, self.db)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ClickhouseVirtualServerHandler(VirtualServerHandler):
|
|
124
|
+
"""
|
|
125
|
+
An implementation of a `perspective.VirtualServerHandler` for ClickHouse.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, db):
|
|
129
|
+
self.db = db
|
|
130
|
+
self.sql_builder = perspective.GenericSQLVirtualServerModel(
|
|
131
|
+
{"create_entity": "VIEW", "grouping_fn": "GROUPING"}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get_features(self):
|
|
135
|
+
return {
|
|
136
|
+
"group_by": True,
|
|
137
|
+
"split_by": False,
|
|
138
|
+
"sort": True,
|
|
139
|
+
"expressions": True,
|
|
140
|
+
"group_rollup_mode": ["rollup", "flat", "total"],
|
|
141
|
+
"filter_ops": {
|
|
142
|
+
"integer": FILTER_OPS,
|
|
143
|
+
"float": FILTER_OPS,
|
|
144
|
+
"string": FILTER_OPS,
|
|
145
|
+
"boolean": FILTER_OPS,
|
|
146
|
+
"date": FILTER_OPS,
|
|
147
|
+
"datetime": FILTER_OPS,
|
|
148
|
+
},
|
|
149
|
+
"aggregates": {
|
|
150
|
+
"integer": NUMBER_AGGS,
|
|
151
|
+
"float": NUMBER_AGGS,
|
|
152
|
+
"string": STRING_AGGS,
|
|
153
|
+
"boolean": STRING_AGGS,
|
|
154
|
+
"date": STRING_AGGS,
|
|
155
|
+
"datetime": STRING_AGGS,
|
|
156
|
+
},
|
|
157
|
+
# ClickHouse has no stable `rowid`, so natural-order windows are
|
|
158
|
+
# unsupported.
|
|
159
|
+
"unordered": True,
|
|
160
|
+
"window_aggregates": {
|
|
161
|
+
"integer": WINDOW_AGGREGATES,
|
|
162
|
+
"float": WINDOW_AGGREGATES,
|
|
163
|
+
"string": WINDOW_AGGREGATES_ANY,
|
|
164
|
+
"boolean": WINDOW_AGGREGATES_ANY,
|
|
165
|
+
"date": WINDOW_AGGREGATES_ANY,
|
|
166
|
+
"datetime": WINDOW_AGGREGATES_ANY,
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def get_hosted_tables(self):
|
|
171
|
+
query = "SHOW TABLES"
|
|
172
|
+
results = run_query(self.db, query)
|
|
173
|
+
return [result[0] for result in results]
|
|
174
|
+
|
|
175
|
+
def table_schema(self, table_name, config=None):
|
|
176
|
+
query = self.sql_builder.table_schema(table_name)
|
|
177
|
+
results = run_query(self.db, query)
|
|
178
|
+
schema = {}
|
|
179
|
+
for result in results:
|
|
180
|
+
col_name = result[0]
|
|
181
|
+
if not col_name.startswith("__"):
|
|
182
|
+
schema[col_name] = clickhouse_type_to_psp(result[1])
|
|
183
|
+
|
|
184
|
+
return schema
|
|
185
|
+
|
|
186
|
+
def view_column_size(self, view_name, config):
|
|
187
|
+
query = f"SELECT COUNT() FROM system.columns WHERE table = '{view_name}'"
|
|
188
|
+
results = run_query(self.db, query)
|
|
189
|
+
gs = len(config["group_by"])
|
|
190
|
+
return results[0][0] - (
|
|
191
|
+
0 if gs == 0 else gs + (1 if len(config["split_by"]) == 0 else 0)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def table_size(self, table_name):
|
|
195
|
+
query = self.sql_builder.table_size(table_name)
|
|
196
|
+
results = run_query(self.db, query)
|
|
197
|
+
return results[0][0]
|
|
198
|
+
|
|
199
|
+
def table_make_view(self, table_name, view_name, config):
|
|
200
|
+
# Window order keys need column types for `range` frame emission.
|
|
201
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
202
|
+
query = self.sql_builder.table_make_view(table_name, view_name, config, schema)
|
|
203
|
+
run_query(self.db, query, execute=True)
|
|
204
|
+
|
|
205
|
+
def table_describe(self, table_name, config):
|
|
206
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
207
|
+
return sql_table_describe(
|
|
208
|
+
self.sql_builder, table_name, config, self._describe_query, schema
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _describe_query(self, query):
|
|
212
|
+
return {
|
|
213
|
+
row[0]: clickhouse_type_to_psp(row[1])
|
|
214
|
+
for row in run_query(self.db, query)
|
|
215
|
+
if not row[0].startswith("__")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def view_delete(self, view_name):
|
|
219
|
+
query = self.sql_builder.view_delete(view_name)
|
|
220
|
+
run_query(self.db, query, execute=True)
|
|
221
|
+
|
|
222
|
+
def view_get_min_max(self, view_name, column_name, config):
|
|
223
|
+
query = self.sql_builder.view_get_min_max(view_name, column_name, config)
|
|
224
|
+
results = run_query(self.db, query)
|
|
225
|
+
row = results[0]
|
|
226
|
+
return (row[0], row[1])
|
|
227
|
+
|
|
228
|
+
def view_get_data(self, view_name, config, schema, viewport, data):
|
|
229
|
+
group_by = config["group_by"]
|
|
230
|
+
query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
|
|
231
|
+
results, columns, dtypes = run_query(self.db, query, columns=True)
|
|
232
|
+
for cidx, col in enumerate(columns):
|
|
233
|
+
dtype = clickhouse_type_to_psp(str(dtypes[cidx]))
|
|
234
|
+
for ridx, row in enumerate(results):
|
|
235
|
+
grouping_id = row[0] if len(group_by) > 0 else None
|
|
236
|
+
|
|
237
|
+
value = row[cidx]
|
|
238
|
+
if dtype == "string" and not isinstance(value, str):
|
|
239
|
+
value = str(value)
|
|
240
|
+
|
|
241
|
+
data.set_col(dtype, col, ridx, value, grouping_id)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
################################################################################
|
|
245
|
+
#
|
|
246
|
+
# ClickHouse Utils
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def clickhouse_type_to_psp(name):
|
|
250
|
+
"""Convert a ClickHouse `dtype` to a Perspective `ColumnType`."""
|
|
251
|
+
if name.startswith("Nullable(") and name.endswith(")"):
|
|
252
|
+
name = name[9:-1]
|
|
253
|
+
|
|
254
|
+
if name.startswith("Array"):
|
|
255
|
+
return "string"
|
|
256
|
+
|
|
257
|
+
if name in ("Int64", "UInt64", "Float64"):
|
|
258
|
+
return "float"
|
|
259
|
+
|
|
260
|
+
if name == "String":
|
|
261
|
+
return "string"
|
|
262
|
+
|
|
263
|
+
if name == "DateTime":
|
|
264
|
+
return "datetime"
|
|
265
|
+
|
|
266
|
+
if name == "Date":
|
|
267
|
+
return "date"
|
|
268
|
+
|
|
269
|
+
msg = f"Unknown type '{name}'"
|
|
270
|
+
raise ValueError(msg)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def run_query(db, query, execute=False, columns=False):
|
|
274
|
+
query = " ".join(query.split())
|
|
275
|
+
start = datetime.now()
|
|
276
|
+
result = None
|
|
277
|
+
try:
|
|
278
|
+
if execute:
|
|
279
|
+
db.command(query)
|
|
280
|
+
else:
|
|
281
|
+
req = db.query(query)
|
|
282
|
+
result = req.result_rows
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(e)
|
|
285
|
+
logger.error(f"{query}")
|
|
286
|
+
raise e
|
|
287
|
+
else:
|
|
288
|
+
logger.debug(f"{datetime.now() - start} {query}")
|
|
289
|
+
if columns:
|
|
290
|
+
return (
|
|
291
|
+
result,
|
|
292
|
+
req.column_names,
|
|
293
|
+
[(x.name if hasattr(x, "name") else str(x)) for x in req.column_types],
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
return result
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
2
|
+
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
|
3
|
+
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
|
4
|
+
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
|
5
|
+
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
|
6
|
+
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
7
|
+
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
|
8
|
+
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
|
9
|
+
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
|
10
|
+
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
|
11
|
+
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import duckdb
|
|
15
|
+
import perspective
|
|
16
|
+
import pyarrow.ipc as ipc
|
|
17
|
+
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
from perspective.virtual_servers import VirtualServerHandler, sql_table_describe
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
NUMBER_AGGS = [
|
|
26
|
+
"sum",
|
|
27
|
+
"count",
|
|
28
|
+
"any_value",
|
|
29
|
+
"arbitrary",
|
|
30
|
+
# "arg_max",
|
|
31
|
+
# "arg_max_null",
|
|
32
|
+
# "arg_min",
|
|
33
|
+
# "arg_min_null",
|
|
34
|
+
"array_agg",
|
|
35
|
+
"avg",
|
|
36
|
+
"bit_and",
|
|
37
|
+
"bit_or",
|
|
38
|
+
"bit_xor",
|
|
39
|
+
"bitstring_agg",
|
|
40
|
+
"bool_and",
|
|
41
|
+
"bool_or",
|
|
42
|
+
"countif",
|
|
43
|
+
"favg",
|
|
44
|
+
"fsum",
|
|
45
|
+
"geomean",
|
|
46
|
+
# "histogram",
|
|
47
|
+
# "histogram_values",
|
|
48
|
+
"kahan_sum",
|
|
49
|
+
"last",
|
|
50
|
+
# "list"
|
|
51
|
+
"max",
|
|
52
|
+
# "max_by"
|
|
53
|
+
"min",
|
|
54
|
+
# "min_by"
|
|
55
|
+
"product",
|
|
56
|
+
"string_agg",
|
|
57
|
+
"sumkahan",
|
|
58
|
+
# "weighted_avg",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
STRING_AGGS = [
|
|
62
|
+
"count",
|
|
63
|
+
"any_value",
|
|
64
|
+
"arbitrary",
|
|
65
|
+
"first",
|
|
66
|
+
"countif",
|
|
67
|
+
"last",
|
|
68
|
+
"string_agg",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Window functions, in DuckDB's own vocabulary - the advertised name is the
|
|
72
|
+
# SQL function, emitted verbatim. `frames` are the frame kinds the function
|
|
73
|
+
# accepts (empty = none), and `result_type` is the output column type, omitted
|
|
74
|
+
# where it is the source column's.
|
|
75
|
+
#
|
|
76
|
+
# Result types follow `duckdb_type_to_psp`: DuckDB's counts and ranks are
|
|
77
|
+
# `BIGINT`, which Perspective's 32-bit `integer` cannot hold, so they are
|
|
78
|
+
# `float` - the same mapping the view's own schema will report.
|
|
79
|
+
FRAMES = ["rows", "range", "cumulative"]
|
|
80
|
+
|
|
81
|
+
WINDOW_AGGREGATES = [
|
|
82
|
+
{"name": "sum", "frames": FRAMES, "result_type": "float"},
|
|
83
|
+
{"name": "avg", "frames": FRAMES, "result_type": "float"},
|
|
84
|
+
{"name": "count", "frames": FRAMES, "result_type": "float"},
|
|
85
|
+
{"name": "min", "frames": FRAMES},
|
|
86
|
+
{"name": "max", "frames": FRAMES},
|
|
87
|
+
{"name": "product", "frames": FRAMES, "result_type": "float"},
|
|
88
|
+
{"name": "median", "frames": FRAMES, "result_type": "float"},
|
|
89
|
+
# DuckDB spells sample and population variants separately, so both are
|
|
90
|
+
# offered rather than one being picked on the user's behalf.
|
|
91
|
+
{"name": "stddev_samp", "frames": FRAMES, "result_type": "float"},
|
|
92
|
+
{"name": "stddev_pop", "frames": FRAMES, "result_type": "float"},
|
|
93
|
+
{"name": "var_samp", "frames": FRAMES, "result_type": "float"},
|
|
94
|
+
{"name": "var_pop", "frames": FRAMES, "result_type": "float"},
|
|
95
|
+
# Navigation.
|
|
96
|
+
{"name": "first_value", "frames": FRAMES},
|
|
97
|
+
{"name": "last_value", "frames": FRAMES},
|
|
98
|
+
{"name": "nth_value", "frames": FRAMES, "offset": True},
|
|
99
|
+
{"name": "lag", "offset": True},
|
|
100
|
+
{"name": "lead", "offset": True},
|
|
101
|
+
# Ranking. These take no source column - the window's `order_by` is their
|
|
102
|
+
# input - but Perspective requires one, so the choice of source is
|
|
103
|
+
# immaterial for them.
|
|
104
|
+
{"name": "row_number", "result_type": "float"},
|
|
105
|
+
{"name": "rank", "result_type": "float"},
|
|
106
|
+
{"name": "dense_rank", "result_type": "float"},
|
|
107
|
+
{"name": "percent_rank", "result_type": "float"},
|
|
108
|
+
{"name": "cume_dist", "result_type": "float"},
|
|
109
|
+
# `ntile`'s argument is a bucket count rather than a row offset.
|
|
110
|
+
{"name": "ntile", "offset": True, "result_type": "float"},
|
|
111
|
+
# Perspective's own, with no DuckDB equivalent - the SQL translation
|
|
112
|
+
# synthesizes them from `lag` and `first_value`.
|
|
113
|
+
{"name": "diff", "offset": True, "result_type": "float"},
|
|
114
|
+
{"name": "rate", "frames": ["range"], "result_type": "float"},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Arithmetic is undefined for the non-numeric types; ordering and navigation
|
|
118
|
+
# are not.
|
|
119
|
+
WINDOW_AGGREGATES_ANY = [
|
|
120
|
+
{"name": "count", "frames": FRAMES, "result_type": "float"},
|
|
121
|
+
{"name": "min", "frames": FRAMES},
|
|
122
|
+
{"name": "max", "frames": FRAMES},
|
|
123
|
+
{"name": "first_value", "frames": FRAMES},
|
|
124
|
+
{"name": "last_value", "frames": FRAMES},
|
|
125
|
+
{"name": "nth_value", "frames": FRAMES, "offset": True},
|
|
126
|
+
{"name": "lag", "offset": True},
|
|
127
|
+
{"name": "lead", "offset": True},
|
|
128
|
+
{"name": "row_number", "result_type": "float"},
|
|
129
|
+
{"name": "rank", "result_type": "float"},
|
|
130
|
+
{"name": "dense_rank", "result_type": "float"},
|
|
131
|
+
{"name": "percent_rank", "result_type": "float"},
|
|
132
|
+
{"name": "cume_dist", "result_type": "float"},
|
|
133
|
+
{"name": "ntile", "offset": True, "result_type": "float"},
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
FILTER_OPS = [
|
|
137
|
+
"==",
|
|
138
|
+
"!=",
|
|
139
|
+
"LIKE",
|
|
140
|
+
"IS DISTINCT FROM",
|
|
141
|
+
"IS NOT DISTINCT FROM",
|
|
142
|
+
">=",
|
|
143
|
+
"<=",
|
|
144
|
+
">",
|
|
145
|
+
"<",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DuckDBVirtualSession:
|
|
150
|
+
def __init__(self, callback, db):
|
|
151
|
+
self.session = perspective.VirtualServer(DuckDBVirtualServerHandler(db))
|
|
152
|
+
self.callback = callback
|
|
153
|
+
|
|
154
|
+
def handle_request(self, msg):
|
|
155
|
+
self.callback(self.session.handle_request(msg))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class DuckDBVirtualServer:
|
|
159
|
+
def __init__(self, db):
|
|
160
|
+
self.db = db
|
|
161
|
+
|
|
162
|
+
def new_session(self, callback):
|
|
163
|
+
return DuckDBVirtualSession(callback, self.db)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DuckDBVirtualServerHandler(VirtualServerHandler):
|
|
167
|
+
"""
|
|
168
|
+
An implementation of a `perspective.VirtualServerHandler` for DuckDB.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, db):
|
|
172
|
+
self.db = db
|
|
173
|
+
self.sql_builder = perspective.GenericSQLVirtualServerModel({})
|
|
174
|
+
|
|
175
|
+
def get_features(self):
|
|
176
|
+
return {
|
|
177
|
+
"group_by": True,
|
|
178
|
+
"split_by": True,
|
|
179
|
+
"sort": True,
|
|
180
|
+
"expressions": True,
|
|
181
|
+
"view_derivations": True,
|
|
182
|
+
"group_rollup_mode": ["rollup", "flat", "total"],
|
|
183
|
+
"split_rollup_mode": ["flat", "rollup"],
|
|
184
|
+
"filter_ops": {
|
|
185
|
+
"integer": FILTER_OPS,
|
|
186
|
+
"float": FILTER_OPS,
|
|
187
|
+
"string": FILTER_OPS,
|
|
188
|
+
"boolean": FILTER_OPS,
|
|
189
|
+
"date": FILTER_OPS,
|
|
190
|
+
"datetime": FILTER_OPS,
|
|
191
|
+
},
|
|
192
|
+
"aggregates": {
|
|
193
|
+
"integer": NUMBER_AGGS,
|
|
194
|
+
"float": NUMBER_AGGS,
|
|
195
|
+
"string": STRING_AGGS,
|
|
196
|
+
"boolean": STRING_AGGS,
|
|
197
|
+
"date": STRING_AGGS,
|
|
198
|
+
"datetime": STRING_AGGS,
|
|
199
|
+
},
|
|
200
|
+
"window_aggregates": {
|
|
201
|
+
"integer": WINDOW_AGGREGATES,
|
|
202
|
+
"float": WINDOW_AGGREGATES,
|
|
203
|
+
"string": WINDOW_AGGREGATES_ANY,
|
|
204
|
+
"boolean": WINDOW_AGGREGATES_ANY,
|
|
205
|
+
"date": WINDOW_AGGREGATES_ANY,
|
|
206
|
+
"datetime": WINDOW_AGGREGATES_ANY,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def get_hosted_tables(self):
|
|
211
|
+
query = self.sql_builder.get_hosted_tables()
|
|
212
|
+
results = run_query(self.db, query)
|
|
213
|
+
return [f"{result[0]}.{result[2]}" for result in results]
|
|
214
|
+
|
|
215
|
+
def table_schema(self, table_name, config=None):
|
|
216
|
+
query = self.sql_builder.table_schema(table_name)
|
|
217
|
+
results = run_query(self.db, query)
|
|
218
|
+
schema = {}
|
|
219
|
+
for result in results:
|
|
220
|
+
col_name = result[0]
|
|
221
|
+
if not col_name.startswith("__"):
|
|
222
|
+
schema[col_name] = duckdb_type_to_psp(result[1])
|
|
223
|
+
|
|
224
|
+
return schema
|
|
225
|
+
|
|
226
|
+
def view_column_size(self, table_name, config):
|
|
227
|
+
query = self.sql_builder.view_column_size(table_name)
|
|
228
|
+
results = run_query(self.db, query)
|
|
229
|
+
gs = len(config["group_by"])
|
|
230
|
+
return results[0][0] - (
|
|
231
|
+
0 if gs == 0 else gs + (1 if len(config["split_by"]) == 0 else 0)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def table_size(self, table_name):
|
|
235
|
+
query = self.sql_builder.table_size(table_name)
|
|
236
|
+
results = run_query(self.db, query)
|
|
237
|
+
return results[0][0]
|
|
238
|
+
|
|
239
|
+
def table_make_view(self, table_name, view_name, config):
|
|
240
|
+
# Window order keys need column types for `range` frame emission.
|
|
241
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
242
|
+
query = self.sql_builder.table_make_view(table_name, view_name, config, schema)
|
|
243
|
+
run_query(self.db, query, execute=True)
|
|
244
|
+
|
|
245
|
+
def table_describe(self, table_name, config):
|
|
246
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
247
|
+
return sql_table_describe(
|
|
248
|
+
self.sql_builder, table_name, config, self._describe_query, schema
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _describe_query(self, query):
|
|
252
|
+
return {
|
|
253
|
+
row[0]: duckdb_type_to_psp(row[1])
|
|
254
|
+
for row in run_query(self.db, query)
|
|
255
|
+
if not row[0].startswith("__")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
def view_make_table(self, view_name, table_name, config, schema=None):
|
|
259
|
+
query = self.sql_builder.view_make_table(
|
|
260
|
+
view_name, table_name, config, self.table_schema(view_name), schema
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
run_query(self.db, query, execute=True)
|
|
264
|
+
|
|
265
|
+
def view_delete(self, view_name):
|
|
266
|
+
query = self.sql_builder.view_delete(view_name)
|
|
267
|
+
run_query(self.db, query, execute=True)
|
|
268
|
+
|
|
269
|
+
def view_get_min_max(self, view_name, column_name, config):
|
|
270
|
+
query = self.sql_builder.view_get_min_max(view_name, column_name, config)
|
|
271
|
+
results = run_query(self.db, query)
|
|
272
|
+
row = results[0]
|
|
273
|
+
return (row[0], row[1])
|
|
274
|
+
|
|
275
|
+
def view_get_data(self, view_name, config, schema, viewport, data):
|
|
276
|
+
query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
|
|
277
|
+
result = self.db.sql(query)
|
|
278
|
+
arrow_table = result.fetch_arrow_table()
|
|
279
|
+
buf = io.BytesIO()
|
|
280
|
+
with ipc.new_stream(buf, arrow_table.schema) as writer:
|
|
281
|
+
writer.write_table(arrow_table)
|
|
282
|
+
data.from_arrow_ipc(buf.getvalue())
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
################################################################################
|
|
286
|
+
#
|
|
287
|
+
# DuckDB Utils
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def duckdb_type_to_psp(name):
|
|
291
|
+
"""Convert a DuckDB `dtype` to a Perspective `ColumnType`.
|
|
292
|
+
|
|
293
|
+
Must agree with `coerce_column` in `perspective-client`, which decides
|
|
294
|
+
the Arrow type the same column's data arrives as - a column declared
|
|
295
|
+
`integer` whose data coerces to `Float64` gets numeric filters the
|
|
296
|
+
engine then rejects. The mapping is duplicated in `duckdb.ts` for
|
|
297
|
+
DuckDB WASM; change both.
|
|
298
|
+
|
|
299
|
+
`BIGINT` and wider go to `float` because Perspective's `integer` is
|
|
300
|
+
32-bit, matching the `Int64 -> Float64` coercion. `TIME` goes to
|
|
301
|
+
`datetime` because that is what `Time32`/`Time64` coerce to.
|
|
302
|
+
"""
|
|
303
|
+
name = name.upper()
|
|
304
|
+
|
|
305
|
+
if name.startswith("BOOL"):
|
|
306
|
+
return "boolean"
|
|
307
|
+
|
|
308
|
+
# 32-bit and narrower - `coerce_column` widens these to `Int32`.
|
|
309
|
+
if name in ("TINYINT", "SMALLINT", "INTEGER", "UTINYINT", "USMALLINT"):
|
|
310
|
+
return "integer"
|
|
311
|
+
|
|
312
|
+
# Wider than `Int32`, or fractional - all coerce to `Float64`.
|
|
313
|
+
if (
|
|
314
|
+
name in ("BIGINT", "HUGEINT", "UHUGEINT", "UINTEGER", "UBIGINT")
|
|
315
|
+
or name in ("FLOAT", "REAL", "DOUBLE", "VARINT")
|
|
316
|
+
or name.startswith("DECIMAL")
|
|
317
|
+
or name.startswith("NUMERIC")
|
|
318
|
+
):
|
|
319
|
+
return "float"
|
|
320
|
+
|
|
321
|
+
if name.startswith("DATE"):
|
|
322
|
+
return "date"
|
|
323
|
+
|
|
324
|
+
# `TIMESTAMP`, `TIMESTAMPTZ`, `TIMESTAMP_NS`, and `TIME`/`TIMETZ`,
|
|
325
|
+
# which coerce to `Timestamp(Millisecond)` rather than to a number.
|
|
326
|
+
if name.startswith("TIME"):
|
|
327
|
+
return "datetime"
|
|
328
|
+
|
|
329
|
+
# Everything else renders as text: `VARCHAR`, `ENUM(...)` (which
|
|
330
|
+
# arrives dictionary-encoded), `JSON`, `UUID`, `BLOB`, `INTERVAL`,
|
|
331
|
+
# and the nested types.
|
|
332
|
+
if not (
|
|
333
|
+
name.startswith("VARCHAR")
|
|
334
|
+
or name.startswith("ENUM")
|
|
335
|
+
or name in ("JSON", "UUID", "BLOB", "BIT", "INTERVAL")
|
|
336
|
+
or name.startswith("STRUCT")
|
|
337
|
+
or name.startswith("MAP")
|
|
338
|
+
or name.startswith("UNION")
|
|
339
|
+
or name.endswith("[]")
|
|
340
|
+
):
|
|
341
|
+
# Unknown, not fatal - the column still renders, as text. Raising
|
|
342
|
+
# here would take down the whole table for one odd column.
|
|
343
|
+
logger.warning(f"Unknown type '{name}'")
|
|
344
|
+
|
|
345
|
+
return "string"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def run_query(db, query, execute=False, columns=False):
|
|
349
|
+
query = " ".join(query.split())
|
|
350
|
+
start = datetime.now()
|
|
351
|
+
result = None
|
|
352
|
+
try:
|
|
353
|
+
if execute:
|
|
354
|
+
db.execute(query)
|
|
355
|
+
else:
|
|
356
|
+
req = db.sql(query)
|
|
357
|
+
result = req.fetchall()
|
|
358
|
+
except (duckdb.ParserException, duckdb.BinderException) as e:
|
|
359
|
+
logger.error(e)
|
|
360
|
+
logger.error(f"{query}")
|
|
361
|
+
raise e
|
|
362
|
+
else:
|
|
363
|
+
logger.debug(f"{datetime.now() - start} {query}")
|
|
364
|
+
if columns:
|
|
365
|
+
return (result, req.columns, req.dtypes)
|
|
366
|
+
else:
|
|
367
|
+
return result
|