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.
Files changed (90) hide show
  1. perspective/__init__.py +403 -0
  2. perspective/extension/finos-perspective-nbextension.json +5 -0
  3. perspective/handlers/__init__.py +11 -0
  4. perspective/handlers/aiohttp.py +69 -0
  5. perspective/handlers/starlette.py +63 -0
  6. perspective/handlers/tornado.py +192 -0
  7. perspective/perspective.abi3.so +0 -0
  8. perspective/templates/exported_widget.html.template +39 -0
  9. perspective/tests/__init__.py +11 -0
  10. perspective/tests/async/test_async_client.py +83 -0
  11. perspective/tests/async/test_websocket_client.py +124 -0
  12. perspective/tests/column_paths.py +33 -0
  13. perspective/tests/conftest.py +276 -0
  14. perspective/tests/core/__init__.py +11 -0
  15. perspective/tests/core/test_async.py +351 -0
  16. perspective/tests/multi_threaded/__init__.py +11 -0
  17. perspective/tests/multi_threaded/test_derived_threaded.py +103 -0
  18. perspective/tests/multi_threaded/test_multi_threaded.py +293 -0
  19. perspective/tests/server/__init__.py +11 -0
  20. perspective/tests/server/test_server.py +1016 -0
  21. perspective/tests/server/test_session.py +110 -0
  22. perspective/tests/table/__init__.py +11 -0
  23. perspective/tests/table/arrow/date32.arrow +0 -0
  24. perspective/tests/table/arrow/date64.arrow +0 -0
  25. perspective/tests/table/arrow/dict.arrow +0 -0
  26. perspective/tests/table/arrow/dict_update.arrow +0 -0
  27. perspective/tests/table/arrow/int_float_str.arrow +0 -0
  28. perspective/tests/table/arrow/int_float_str_file.arrow +0 -0
  29. perspective/tests/table/arrow/int_float_str_update.arrow +0 -0
  30. perspective/tests/table/object_sequence.py +402 -0
  31. perspective/tests/table/test_column_paths.py +88 -0
  32. perspective/tests/table/test_delete.py +124 -0
  33. perspective/tests/table/test_derived.py +145 -0
  34. perspective/tests/table/test_exception.py +73 -0
  35. perspective/tests/table/test_join.py +115 -0
  36. perspective/tests/table/test_leaks.py +105 -0
  37. perspective/tests/table/test_on_remove.py +140 -0
  38. perspective/tests/table/test_ports.py +178 -0
  39. perspective/tests/table/test_remove.py +102 -0
  40. perspective/tests/table/test_table.py +641 -0
  41. perspective/tests/table/test_table_arrow.py +500 -0
  42. perspective/tests/table/test_table_arrow_nested.py +535 -0
  43. perspective/tests/table/test_table_datetime.py +2544 -0
  44. perspective/tests/table/test_table_infer.py +201 -0
  45. perspective/tests/table/test_table_limit.py +45 -0
  46. perspective/tests/table/test_table_numpy.py +1022 -0
  47. perspective/tests/table/test_table_page_to_disk.py +304 -0
  48. perspective/tests/table/test_table_pandas.py +1018 -0
  49. perspective/tests/table/test_table_polars.py +251 -0
  50. perspective/tests/table/test_table_view_table.py +130 -0
  51. perspective/tests/table/test_to_arrow.py +416 -0
  52. perspective/tests/table/test_to_arrow_lz4.py +32 -0
  53. perspective/tests/table/test_to_arrow_zstd.py +41 -0
  54. perspective/tests/table/test_to_format.py +1030 -0
  55. perspective/tests/table/test_to_polars.py +26 -0
  56. perspective/tests/table/test_update.py +561 -0
  57. perspective/tests/table/test_update_arrow.py +980 -0
  58. perspective/tests/table/test_update_pandas.py +211 -0
  59. perspective/tests/table/test_view.py +2503 -0
  60. perspective/tests/table/test_view_expression.py +2256 -0
  61. perspective/tests/test_dependencies.py +53 -0
  62. perspective/tests/viewer/__init__.py +11 -0
  63. perspective/tests/viewer/test_viewer.py +246 -0
  64. perspective/tests/virtual_servers/__init__.py +12 -0
  65. perspective/tests/virtual_servers/test_coerce_types.py +464 -0
  66. perspective/tests/virtual_servers/test_duckdb.py +1280 -0
  67. perspective/tests/virtual_servers/test_polars.py +1075 -0
  68. perspective/tests/virtual_servers/test_postgres.py +964 -0
  69. perspective/tests/widget/__init__.py +11 -0
  70. perspective/tests/widget/test_anywidget_smoke.py +109 -0
  71. perspective/tests/widget/test_widget.py +278 -0
  72. perspective/tests/widget/test_widget_html_export.py +113 -0
  73. perspective/tests/widget/test_widget_pandas.py +453 -0
  74. perspective/virtual_servers/__init__.py +206 -0
  75. perspective/virtual_servers/clickhouse.py +296 -0
  76. perspective/virtual_servers/duckdb.py +367 -0
  77. perspective/virtual_servers/polars.py +750 -0
  78. perspective/virtual_servers/postgres.py +448 -0
  79. perspective/widget/__init__.py +351 -0
  80. perspective/widget/static/perspective-anywidget.css +1 -0
  81. perspective/widget/static/perspective-anywidget.js +31 -0
  82. perspective/widget/viewer/__init__.py +15 -0
  83. perspective/widget/viewer/viewer.py +355 -0
  84. perspective/widget/viewer/viewer_traitlets.py +98 -0
  85. perspective_python-5.6.0.dist-info/METADATA +127 -0
  86. perspective_python-5.6.0.dist-info/RECORD +90 -0
  87. perspective_python-5.6.0.dist-info/WHEEL +4 -0
  88. perspective_python-5.6.0.dist-info/licenses/LICENSE.md +193 -0
  89. perspective_python-5.6.0.dist-info/licenses/LICENSE_THIRDPARTY_cargo.yml +16075 -0
  90. 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