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,448 @@
|
|
|
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
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import date, datetime, time, timezone
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
|
|
19
|
+
import psycopg
|
|
20
|
+
|
|
21
|
+
import perspective
|
|
22
|
+
from perspective.virtual_servers import VirtualServerHandler, sql_table_describe
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Requires PostgreSQL >= 16: `any_value` is both advertised below and the SQL
|
|
27
|
+
# builder's fallback aggregate for columns with no explicit aggregate.
|
|
28
|
+
#
|
|
29
|
+
# Aggregates, in Postgres's own vocabulary. Only single-argument aggregates
|
|
30
|
+
# with scalar results are expressible - the builder emits `{agg}({col})`, so
|
|
31
|
+
# e.g. two-argument `string_agg` cannot be advertised.
|
|
32
|
+
INT_AGGS = [
|
|
33
|
+
"sum",
|
|
34
|
+
"avg",
|
|
35
|
+
"count",
|
|
36
|
+
"min",
|
|
37
|
+
"max",
|
|
38
|
+
"stddev",
|
|
39
|
+
"stddev_pop",
|
|
40
|
+
"stddev_samp",
|
|
41
|
+
"variance",
|
|
42
|
+
"var_pop",
|
|
43
|
+
"var_samp",
|
|
44
|
+
"bit_and",
|
|
45
|
+
"bit_or",
|
|
46
|
+
"bit_xor",
|
|
47
|
+
"any_value",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# `bit_and`/`bit_or`/`bit_xor` are defined for integral types only.
|
|
51
|
+
FLOAT_AGGS = [a for a in INT_AGGS if not a.startswith("bit_")]
|
|
52
|
+
|
|
53
|
+
STRING_AGGS = [
|
|
54
|
+
"count",
|
|
55
|
+
"min",
|
|
56
|
+
"max",
|
|
57
|
+
"any_value",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Postgres has no `min(boolean)`/`max(boolean)`.
|
|
61
|
+
BOOL_AGGS = [
|
|
62
|
+
"count",
|
|
63
|
+
"bool_and",
|
|
64
|
+
"bool_or",
|
|
65
|
+
"every",
|
|
66
|
+
"any_value",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Window functions, in Postgres's own vocabulary - the advertised name is the
|
|
70
|
+
# SQL function, emitted verbatim. Excluded relative to the DuckDB handler:
|
|
71
|
+
# `product` and `median` (no such functions in Postgres), `rate` (its
|
|
72
|
+
# synthesis casts the order key to DOUBLE PRECISION, which errors for
|
|
73
|
+
# timestamp keys). `range` frames are advertised only for numeric source
|
|
74
|
+
# types - Postgres `RANGE n PRECEDING` requires a numeric order key, and
|
|
75
|
+
# errors on timestamps without an interval literal.
|
|
76
|
+
FRAMES = ["rows", "range", "cumulative"]
|
|
77
|
+
FRAMES_ANY = ["rows", "cumulative"]
|
|
78
|
+
|
|
79
|
+
WINDOW_AGGREGATES = [
|
|
80
|
+
{"name": "sum", "frames": FRAMES, "result_type": "float"},
|
|
81
|
+
{"name": "avg", "frames": FRAMES, "result_type": "float"},
|
|
82
|
+
{"name": "count", "frames": FRAMES, "result_type": "float"},
|
|
83
|
+
{"name": "min", "frames": FRAMES},
|
|
84
|
+
{"name": "max", "frames": FRAMES},
|
|
85
|
+
{"name": "stddev_samp", "frames": FRAMES, "result_type": "float"},
|
|
86
|
+
{"name": "stddev_pop", "frames": FRAMES, "result_type": "float"},
|
|
87
|
+
{"name": "var_samp", "frames": FRAMES, "result_type": "float"},
|
|
88
|
+
{"name": "var_pop", "frames": FRAMES, "result_type": "float"},
|
|
89
|
+
# Navigation.
|
|
90
|
+
{"name": "first_value", "frames": FRAMES},
|
|
91
|
+
{"name": "last_value", "frames": FRAMES},
|
|
92
|
+
{"name": "nth_value", "frames": FRAMES, "offset": True},
|
|
93
|
+
{"name": "lag", "offset": True},
|
|
94
|
+
{"name": "lead", "offset": True},
|
|
95
|
+
# Ranking. These take no source column - the window's `order_by` is their
|
|
96
|
+
# input - but Perspective requires one, so the choice of source is
|
|
97
|
+
# immaterial for them. Ranks are `bigint`, which Perspective's 32-bit
|
|
98
|
+
# `integer` cannot hold, so they are `float`.
|
|
99
|
+
{"name": "row_number", "result_type": "float"},
|
|
100
|
+
{"name": "rank", "result_type": "float"},
|
|
101
|
+
{"name": "dense_rank", "result_type": "float"},
|
|
102
|
+
{"name": "percent_rank", "result_type": "float"},
|
|
103
|
+
{"name": "cume_dist", "result_type": "float"},
|
|
104
|
+
# `ntile`'s argument is a bucket count rather than a row offset.
|
|
105
|
+
{"name": "ntile", "offset": True, "result_type": "float"},
|
|
106
|
+
# Perspective's own, with no Postgres equivalent - the SQL translation
|
|
107
|
+
# synthesizes it from `lag`.
|
|
108
|
+
{"name": "diff", "offset": True, "result_type": "float"},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Arithmetic is undefined for the non-numeric types; ordering and navigation
|
|
112
|
+
# are not.
|
|
113
|
+
WINDOW_AGGREGATES_ANY = [
|
|
114
|
+
{"name": "count", "frames": FRAMES_ANY, "result_type": "float"},
|
|
115
|
+
{"name": "min", "frames": FRAMES_ANY},
|
|
116
|
+
{"name": "max", "frames": FRAMES_ANY},
|
|
117
|
+
{"name": "first_value", "frames": FRAMES_ANY},
|
|
118
|
+
{"name": "last_value", "frames": FRAMES_ANY},
|
|
119
|
+
{"name": "nth_value", "frames": FRAMES_ANY, "offset": True},
|
|
120
|
+
{"name": "lag", "offset": True},
|
|
121
|
+
{"name": "lead", "offset": True},
|
|
122
|
+
{"name": "row_number", "result_type": "float"},
|
|
123
|
+
{"name": "rank", "result_type": "float"},
|
|
124
|
+
{"name": "dense_rank", "result_type": "float"},
|
|
125
|
+
{"name": "percent_rank", "result_type": "float"},
|
|
126
|
+
{"name": "cume_dist", "result_type": "float"},
|
|
127
|
+
{"name": "ntile", "offset": True, "result_type": "float"},
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
COMPARE_OPS = [
|
|
131
|
+
"==",
|
|
132
|
+
"!=",
|
|
133
|
+
"IS DISTINCT FROM",
|
|
134
|
+
"IS NOT DISTINCT FROM",
|
|
135
|
+
">=",
|
|
136
|
+
"<=",
|
|
137
|
+
">",
|
|
138
|
+
"<",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# `LIKE` only for strings - Postgres does not implicitly cast its operand.
|
|
142
|
+
STRING_FILTER_OPS = COMPARE_OPS + ["LIKE"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PostgresVirtualSession:
|
|
146
|
+
def __init__(self, callback, conninfo):
|
|
147
|
+
self.session = perspective.VirtualServer(PostgresVirtualServerHandler(conninfo))
|
|
148
|
+
self.callback = callback
|
|
149
|
+
|
|
150
|
+
def handle_request(self, msg):
|
|
151
|
+
self.callback(self.session.handle_request(msg))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PostgresVirtualServer:
|
|
155
|
+
def __init__(self, conninfo=""):
|
|
156
|
+
self.conninfo = conninfo
|
|
157
|
+
|
|
158
|
+
def new_session(self, callback):
|
|
159
|
+
return PostgresVirtualSession(callback, self.conninfo)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PostgresVirtualServerHandler(VirtualServerHandler):
|
|
163
|
+
"""
|
|
164
|
+
An implementation of a `perspective.VirtualServerHandler` for PostgreSQL
|
|
165
|
+
(16 or later, for `any_value`).
|
|
166
|
+
|
|
167
|
+
Each handler owns one connection, and views are created as `TEMPORARY
|
|
168
|
+
VIEW`s: view names are connection-scoped so sessions cannot collide, every
|
|
169
|
+
viewport read re-plans the view's inner `ORDER BY` (a materialized
|
|
170
|
+
`CREATE TABLE AS` would depend on seq-scan order, which Postgres does not
|
|
171
|
+
guarantee), and all views drop automatically on disconnect.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, conninfo):
|
|
175
|
+
self.db = psycopg.connect(conninfo, autocommit=True)
|
|
176
|
+
self.sql_builder = perspective.GenericSQLVirtualServerModel(
|
|
177
|
+
{
|
|
178
|
+
"create_entity": "TEMPORARY VIEW",
|
|
179
|
+
"drop_entity": "VIEW",
|
|
180
|
+
"grouping_fn": "GROUPING",
|
|
181
|
+
"row_id_expr": "ctid",
|
|
182
|
+
"like_escape_clause": "\\",
|
|
183
|
+
"regex_fn": "regexp_like",
|
|
184
|
+
"describe_template": "SELECT * FROM ({}) AS __psp_describe__ LIMIT 0",
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def get_features(self):
|
|
189
|
+
return {
|
|
190
|
+
"group_by": True,
|
|
191
|
+
# Postgres has no `PIVOT`; pivoting needs a two-pass
|
|
192
|
+
# filtered-aggregate strategy the SQL builder does not yet have.
|
|
193
|
+
"split_by": False,
|
|
194
|
+
"sort": True,
|
|
195
|
+
"expressions": True,
|
|
196
|
+
"view_derivations": True,
|
|
197
|
+
"group_rollup_mode": ["rollup", "flat", "total"],
|
|
198
|
+
# `ctid` is not a stable row identity, so natural-order windows
|
|
199
|
+
# are unsupported.
|
|
200
|
+
"unordered": True,
|
|
201
|
+
"filter_ops": {
|
|
202
|
+
"integer": COMPARE_OPS,
|
|
203
|
+
"float": COMPARE_OPS,
|
|
204
|
+
"string": STRING_FILTER_OPS,
|
|
205
|
+
"boolean": COMPARE_OPS,
|
|
206
|
+
"date": COMPARE_OPS,
|
|
207
|
+
"datetime": COMPARE_OPS,
|
|
208
|
+
},
|
|
209
|
+
"aggregates": {
|
|
210
|
+
"integer": INT_AGGS,
|
|
211
|
+
"float": FLOAT_AGGS,
|
|
212
|
+
"string": STRING_AGGS,
|
|
213
|
+
"boolean": BOOL_AGGS,
|
|
214
|
+
"date": STRING_AGGS,
|
|
215
|
+
"datetime": STRING_AGGS,
|
|
216
|
+
},
|
|
217
|
+
"window_aggregates": {
|
|
218
|
+
"integer": WINDOW_AGGREGATES,
|
|
219
|
+
"float": WINDOW_AGGREGATES,
|
|
220
|
+
"string": WINDOW_AGGREGATES_ANY,
|
|
221
|
+
"boolean": WINDOW_AGGREGATES_ANY,
|
|
222
|
+
"date": WINDOW_AGGREGATES_ANY,
|
|
223
|
+
"datetime": WINDOW_AGGREGATES_ANY,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def get_hosted_tables(self):
|
|
228
|
+
# `information_schema.tables` omits materialized views, so query the
|
|
229
|
+
# catalog directly.
|
|
230
|
+
query = """
|
|
231
|
+
SELECT n.nspname, c.relname
|
|
232
|
+
FROM pg_catalog.pg_class c
|
|
233
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
234
|
+
WHERE c.relkind IN ('r', 'v', 'm', 'f', 'p')
|
|
235
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
236
|
+
AND n.nspname NOT LIKE 'pg_toast%'
|
|
237
|
+
AND n.nspname NOT LIKE 'pg_temp%'
|
|
238
|
+
ORDER BY 1, 2
|
|
239
|
+
"""
|
|
240
|
+
results = run_query(self.db, query)
|
|
241
|
+
return [f"{result[0]}.{result[1]}" for result in results]
|
|
242
|
+
|
|
243
|
+
def _describe(self, entity_id):
|
|
244
|
+
"""The column name/OID pairs of any relation or query result.
|
|
245
|
+
|
|
246
|
+
`SELECT ... LIMIT 0` planning is the one schema mechanism that works
|
|
247
|
+
uniformly for tables, temporary views (which hide in `pg_temp`), and
|
|
248
|
+
expression validation - Postgres has no `DESCRIBE`.
|
|
249
|
+
"""
|
|
250
|
+
cur = run_query(self.db, f"SELECT * FROM {entity_id} LIMIT 0", cursor=True)
|
|
251
|
+
return [(d.name, d.type_code) for d in cur.description]
|
|
252
|
+
|
|
253
|
+
def table_schema(self, table_name, config=None):
|
|
254
|
+
schema = {}
|
|
255
|
+
for col_name, oid in self._describe(table_name):
|
|
256
|
+
if not col_name.startswith("__"):
|
|
257
|
+
schema[col_name] = pg_oid_to_psp(oid, col_name)
|
|
258
|
+
|
|
259
|
+
return schema
|
|
260
|
+
|
|
261
|
+
def view_column_size(self, view_name, config):
|
|
262
|
+
n = len(self._describe(view_name))
|
|
263
|
+
gs = len(config["group_by"])
|
|
264
|
+
if gs > 0:
|
|
265
|
+
# Group views carry `__ROW_PATH_N__` columns, plus
|
|
266
|
+
# `__GROUPING_ID__` except in flat mode.
|
|
267
|
+
n -= gs
|
|
268
|
+
if config.get("group_rollup_mode", "rollup") != "flat":
|
|
269
|
+
n -= 1
|
|
270
|
+
|
|
271
|
+
return n
|
|
272
|
+
|
|
273
|
+
def table_size(self, table_name):
|
|
274
|
+
query = self.sql_builder.table_size(table_name)
|
|
275
|
+
results = run_query(self.db, query)
|
|
276
|
+
return results[0][0]
|
|
277
|
+
|
|
278
|
+
def table_make_view(self, table_name, view_name, config):
|
|
279
|
+
# Window order keys need column types for `range` frame emission.
|
|
280
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
281
|
+
query = self.sql_builder.table_make_view(table_name, view_name, config, schema)
|
|
282
|
+
run_query(self.db, query, execute=True)
|
|
283
|
+
|
|
284
|
+
def table_describe(self, table_name, config):
|
|
285
|
+
schema = self.table_schema(table_name) if config.get("windows") else None
|
|
286
|
+
return sql_table_describe(
|
|
287
|
+
self.sql_builder, table_name, config, self._describe_query, schema
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _describe_query(self, query):
|
|
291
|
+
cur = run_query(self.db, query, cursor=True)
|
|
292
|
+
return {
|
|
293
|
+
d.name: pg_oid_to_psp(d.type_code, d.name)
|
|
294
|
+
for d in cur.description
|
|
295
|
+
if not d.name.startswith("__")
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
def view_make_table(self, view_name, table_name, config, schema=None):
|
|
299
|
+
query = self.sql_builder.view_make_table(
|
|
300
|
+
view_name, table_name, config, self.table_schema(view_name), schema
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
run_query(self.db, query, execute=True)
|
|
304
|
+
|
|
305
|
+
def view_delete(self, view_name):
|
|
306
|
+
query = self.sql_builder.view_delete(view_name)
|
|
307
|
+
run_query(self.db, query, execute=True)
|
|
308
|
+
|
|
309
|
+
def view_get_min_max(self, view_name, column_name, config):
|
|
310
|
+
query = self.sql_builder.view_get_min_max(view_name, column_name, config)
|
|
311
|
+
results = run_query(self.db, query)
|
|
312
|
+
row = results[0]
|
|
313
|
+
return (pg_to_py(row[0]), pg_to_py(row[1]))
|
|
314
|
+
|
|
315
|
+
def view_get_data(self, view_name, config, schema, viewport, data):
|
|
316
|
+
group_by = config["group_by"]
|
|
317
|
+
is_flat = config.get("group_rollup_mode", "rollup") == "flat"
|
|
318
|
+
query = self.sql_builder.view_get_data(view_name, config, viewport, schema)
|
|
319
|
+
cur = run_query(self.db, query, cursor=True)
|
|
320
|
+
results = cur.fetchall()
|
|
321
|
+
for cidx, desc in enumerate(cur.description):
|
|
322
|
+
dtype = pg_oid_to_psp(desc.type_code, desc.name)
|
|
323
|
+
for ridx, row in enumerate(results):
|
|
324
|
+
# `__ROW_PATH_N__` cells are kept only while the row's
|
|
325
|
+
# grouping id marks that level as un-rolled-up; flat views
|
|
326
|
+
# have no `__GROUPING_ID__` column and keep every level.
|
|
327
|
+
if len(group_by) == 0:
|
|
328
|
+
grouping_id = None
|
|
329
|
+
elif is_flat:
|
|
330
|
+
grouping_id = 0
|
|
331
|
+
else:
|
|
332
|
+
grouping_id = row[0]
|
|
333
|
+
|
|
334
|
+
value = pg_to_py(row[cidx])
|
|
335
|
+
if (
|
|
336
|
+
dtype == "string"
|
|
337
|
+
and value is not None
|
|
338
|
+
and not isinstance(value, str)
|
|
339
|
+
):
|
|
340
|
+
value = str(value)
|
|
341
|
+
|
|
342
|
+
data.set_col(dtype, desc.name, ridx, value, grouping_id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
################################################################################
|
|
346
|
+
#
|
|
347
|
+
# Postgres Utils
|
|
348
|
+
|
|
349
|
+
# Standard `pg_type` OIDs, stable across every supported Postgres version.
|
|
350
|
+
_BOOL_OIDS = frozenset([16])
|
|
351
|
+
_INT_OIDS = frozenset([21, 23]) # int2, int4
|
|
352
|
+
_FLOAT_OIDS = frozenset(
|
|
353
|
+
[20, 26, 700, 701, 790, 1700]
|
|
354
|
+
) # int8, oid, float4/8, money, numeric
|
|
355
|
+
_DATE_OIDS = frozenset([1082])
|
|
356
|
+
_DATETIME_OIDS = frozenset([1083, 1114, 1184, 1266]) # time, timestamp(tz), timetz
|
|
357
|
+
_STRING_OIDS = frozenset(
|
|
358
|
+
[
|
|
359
|
+
17, # bytea
|
|
360
|
+
18, # char
|
|
361
|
+
19, # name
|
|
362
|
+
25, # text
|
|
363
|
+
114, # json
|
|
364
|
+
142, # xml
|
|
365
|
+
1042, # bpchar
|
|
366
|
+
1043, # varchar
|
|
367
|
+
1186, # interval
|
|
368
|
+
2950, # uuid
|
|
369
|
+
3802, # jsonb
|
|
370
|
+
]
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def pg_oid_to_psp(oid, col_name=""):
|
|
375
|
+
"""Convert a `pg_type` OID to a Perspective `ColumnType`.
|
|
376
|
+
|
|
377
|
+
Must agree with the value normalization in `pg_to_py`, which decides the
|
|
378
|
+
Python value the same column's data arrives as - the two are halves of
|
|
379
|
+
one contract.
|
|
380
|
+
|
|
381
|
+
`int8` and `numeric` go to `float` because Perspective's `integer` is
|
|
382
|
+
32-bit; `time` goes to `datetime` because it renders as one. Everything
|
|
383
|
+
unrecognized - arrays, enums, ranges, user-defined types - renders as
|
|
384
|
+
text. Unknown is not fatal: raising here would take down the whole table
|
|
385
|
+
for one odd column.
|
|
386
|
+
"""
|
|
387
|
+
if oid in _BOOL_OIDS:
|
|
388
|
+
return "boolean"
|
|
389
|
+
|
|
390
|
+
if oid in _INT_OIDS:
|
|
391
|
+
return "integer"
|
|
392
|
+
|
|
393
|
+
if oid in _FLOAT_OIDS:
|
|
394
|
+
return "float"
|
|
395
|
+
|
|
396
|
+
if oid in _DATE_OIDS:
|
|
397
|
+
return "date"
|
|
398
|
+
|
|
399
|
+
if oid in _DATETIME_OIDS:
|
|
400
|
+
return "datetime"
|
|
401
|
+
|
|
402
|
+
if oid not in _STRING_OIDS:
|
|
403
|
+
logger.warning(f"Unknown type OID '{oid}' for column '{col_name}'")
|
|
404
|
+
|
|
405
|
+
return "string"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def pg_to_py(value):
|
|
409
|
+
"""Normalize a psycopg result value for `PerspectiveColumn.set_col`.
|
|
410
|
+
|
|
411
|
+
`datetime` first: it subclasses `date`, and the binding's date branch
|
|
412
|
+
would truncate the time-of-day. Naive timestamps are taken as UTC (the
|
|
413
|
+
engine's convention); `time` renders on the epoch date, matching how
|
|
414
|
+
narrower temporal types coerce in the Arrow path.
|
|
415
|
+
"""
|
|
416
|
+
if isinstance(value, Decimal):
|
|
417
|
+
return float(value)
|
|
418
|
+
|
|
419
|
+
if isinstance(value, datetime):
|
|
420
|
+
if value.tzinfo is None:
|
|
421
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
422
|
+
|
|
423
|
+
return int(value.timestamp() * 1000)
|
|
424
|
+
|
|
425
|
+
if isinstance(value, time):
|
|
426
|
+
return pg_to_py(datetime.combine(date(1970, 1, 1), value))
|
|
427
|
+
|
|
428
|
+
if isinstance(value, (dict, list)):
|
|
429
|
+
return json.dumps(value)
|
|
430
|
+
|
|
431
|
+
return value
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def run_query(db, query, execute=False, cursor=False):
|
|
435
|
+
query = " ".join(query.split())
|
|
436
|
+
start = datetime.now(datetime.UTC)
|
|
437
|
+
try:
|
|
438
|
+
cur = db.execute(query)
|
|
439
|
+
except psycopg.Error as e:
|
|
440
|
+
logger.error(e)
|
|
441
|
+
logger.error(f"{query}")
|
|
442
|
+
raise
|
|
443
|
+
else:
|
|
444
|
+
logger.debug(f"{datetime.now(datetime.UTC) - start} {query}")
|
|
445
|
+
if cursor:
|
|
446
|
+
return cur
|
|
447
|
+
elif not execute:
|
|
448
|
+
return cur.fetchall()
|