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,964 @@
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 os
14
+ import tempfile
15
+ import urllib.request
16
+ from datetime import datetime
17
+
18
+ import pytest
19
+
20
+ psycopg = pytest.importorskip("psycopg")
21
+ pq = pytest.importorskip("pyarrow.parquet")
22
+
23
+ from perspective import Client
24
+ from perspective.virtual_servers.postgres import PostgresVirtualServer
25
+ from perspective.tests.column_paths import joined_column_paths
26
+
27
+ # Set `PSP_TEST_POSTGRES_DSN` to point these tests at a server; without a
28
+ # reachable PostgreSQL >= 16 they skip rather than fail.
29
+ DSN = os.environ.get("PSP_TEST_POSTGRES_DSN", "postgresql:///postgres")
30
+
31
+ _SUPERSTORE_LOCAL = os.path.join(
32
+ os.path.dirname(__file__),
33
+ "..",
34
+ "..",
35
+ "..",
36
+ "node_modules",
37
+ "superstore-arrow",
38
+ "superstore.parquet",
39
+ )
40
+
41
+ _SUPERSTORE_URL = (
42
+ "https://cdn.jsdelivr.net/npm/superstore-arrow@3.2.0/superstore.parquet"
43
+ )
44
+
45
+
46
+ def _get_superstore_parquet():
47
+ if os.path.exists(_SUPERSTORE_LOCAL):
48
+ return _SUPERSTORE_LOCAL
49
+ path = os.path.join(tempfile.gettempdir(), "superstore.parquet")
50
+ if not os.path.exists(path):
51
+ urllib.request.urlretrieve(_SUPERSTORE_URL, path)
52
+ return path
53
+
54
+
55
+ # Perspective-relevant Postgres types per superstore column - `BIGINT` and
56
+ # `DOUBLE PRECISION` both present as `float`, matching the DuckDB handler's
57
+ # schema for the same dataset.
58
+ _SUPERSTORE_TYPES = {
59
+ "Row ID": "INTEGER",
60
+ "Order ID": "TEXT",
61
+ "Order Date": "DATE",
62
+ "Ship Date": "DATE",
63
+ "Ship Mode": "TEXT",
64
+ "Customer ID": "TEXT",
65
+ "Customer Name": "TEXT",
66
+ "Segment": "TEXT",
67
+ "Country": "TEXT",
68
+ "City": "TEXT",
69
+ "State": "TEXT",
70
+ "Postal Code": "BIGINT",
71
+ "Region": "TEXT",
72
+ "Product ID": "TEXT",
73
+ "Category": "TEXT",
74
+ "Sub-Category": "TEXT",
75
+ "Product Name": "TEXT",
76
+ "Sales": "DOUBLE PRECISION",
77
+ "Quantity": "INTEGER",
78
+ "Discount": "DOUBLE PRECISION",
79
+ "Profit": "DOUBLE PRECISION",
80
+ }
81
+
82
+
83
+ def _load_superstore(conn):
84
+ arrow_table = pq.read_table(_get_superstore_parquet())
85
+ names = arrow_table.schema.names
86
+ cols = ", ".join(f'"{name}" {_SUPERSTORE_TYPES[name]}' for name in names)
87
+ conn.execute(f"CREATE TABLE psp_test.superstore ({cols})")
88
+ with conn.cursor() as cur:
89
+ with cur.copy('COPY psp_test.superstore FROM STDIN') as copy:
90
+ for row in arrow_table.to_pylist():
91
+ copy.write_row(
92
+ tuple(
93
+ v.date() if isinstance(v, datetime) else v
94
+ for v in (row[name] for name in names)
95
+ )
96
+ )
97
+
98
+
99
+ def _load_coerce_types(conn):
100
+ """A column of each Postgres type whose Perspective type is not its own.
101
+
102
+ `mood` is the interesting one: enum OIDs are user-defined and unknown to
103
+ the OID map, so the column must fall back to `string` rather than fail.
104
+ """
105
+ conn.execute("CREATE TYPE psp_test.mood AS ENUM ('happy', 'sad')")
106
+ conn.execute("""
107
+ CREATE TABLE psp_test.coerce_types (
108
+ "small" SMALLINT,
109
+ "big" BIGINT,
110
+ "float" REAL,
111
+ "decimal" NUMERIC(18, 3),
112
+ "time" TIME,
113
+ "timestamp" TIMESTAMP,
114
+ "timestamptz" TIMESTAMPTZ,
115
+ "date" DATE,
116
+ "enum" psp_test.mood,
117
+ "uuid" UUID,
118
+ "json" JSONB,
119
+ "string" TEXT
120
+ )
121
+ """)
122
+
123
+ conn.execute("""
124
+ INSERT INTO psp_test.coerce_types VALUES
125
+ (-300, 9007199254740992, 1.5, 1.234, TIME '01:01:01',
126
+ TIMESTAMP '2023-01-01 00:00:00',
127
+ TIMESTAMPTZ '2023-01-01 00:00:00+00', DATE '2023-01-01',
128
+ 'happy', '00000000-0000-0000-0000-000000000001',
129
+ '{"a": 1}', 'a'),
130
+ (300, -9007199254740992, -1.5, -5.678, TIME '00:00:01',
131
+ TIMESTAMP '2023-01-02 00:00:00',
132
+ TIMESTAMPTZ '2023-01-02 00:00:00+00', DATE '2023-01-02',
133
+ 'sad', '00000000-0000-0000-0000-000000000002',
134
+ '{"b": 2}', 'b')
135
+ """)
136
+
137
+
138
+ @pytest.fixture(scope="module")
139
+ def pg_db():
140
+ try:
141
+ conn = psycopg.connect(DSN, autocommit=True, connect_timeout=5)
142
+ except psycopg.OperationalError as e:
143
+ pytest.skip(f"no PostgreSQL server at '{DSN}': {e}")
144
+
145
+ if conn.info.server_version < 160000:
146
+ pytest.skip("PostgreSQL >= 16 required (`any_value`)")
147
+
148
+ conn.execute("DROP SCHEMA IF EXISTS psp_test CASCADE")
149
+ conn.execute("CREATE SCHEMA psp_test")
150
+ _load_superstore(conn)
151
+ _load_coerce_types(conn)
152
+ yield conn
153
+ conn.execute("DROP SCHEMA psp_test CASCADE")
154
+ conn.close()
155
+
156
+
157
+ @pytest.fixture
158
+ def client(pg_db):
159
+ server = PostgresVirtualServer(DSN)
160
+
161
+ def handle_request(msg):
162
+ session.handle_request(msg)
163
+
164
+ def handle_response(msg):
165
+ c.handle_response(msg)
166
+
167
+ session = server.new_session(handle_response)
168
+ c = Client(handle_request)
169
+ return c
170
+
171
+
172
+ def approx(x):
173
+ # Float aggregation order (and so its rounding) is Postgres's own,
174
+ # not DuckDB's.
175
+ return pytest.approx(x, rel=1e-7)
176
+
177
+
178
+ class TestPostgresClient:
179
+ def test_get_hosted_table_names(self, client):
180
+ tables = client.get_hosted_table_names()
181
+ assert {"psp_test.superstore", "psp_test.coerce_types"} <= set(tables)
182
+
183
+
184
+ class TestPostgresTable:
185
+ def test_schema(self, client):
186
+ table = client.open_table("psp_test.superstore")
187
+ schema = table.schema()
188
+ assert schema == {
189
+ "Product Name": "string",
190
+ "Ship Date": "date",
191
+ "City": "string",
192
+ "Row ID": "integer",
193
+ "Customer Name": "string",
194
+ "Quantity": "integer",
195
+ "Discount": "float",
196
+ "Sub-Category": "string",
197
+ "Segment": "string",
198
+ "Category": "string",
199
+ "Order Date": "date",
200
+ "Order ID": "string",
201
+ "Sales": "float",
202
+ "State": "string",
203
+ "Postal Code": "float",
204
+ "Country": "string",
205
+ "Customer ID": "string",
206
+ "Ship Mode": "string",
207
+ "Region": "string",
208
+ "Profit": "float",
209
+ "Product ID": "string",
210
+ }
211
+
212
+ def test_size(self, client):
213
+ table = client.open_table("psp_test.superstore")
214
+ size = table.size()
215
+ assert size == 9994
216
+
217
+
218
+ class TestPostgresView:
219
+ def test_num_rows(self, client):
220
+ table = client.open_table("psp_test.superstore")
221
+ view = table.view(columns=["Sales", "Profit"])
222
+ num_rows = view.num_rows()
223
+ assert num_rows == 9994
224
+ view.delete()
225
+
226
+ def test_num_columns(self, client):
227
+ table = client.open_table("psp_test.superstore")
228
+ view = table.view(columns=["Sales", "Profit", "State"])
229
+ num_columns = view.num_columns()
230
+ assert num_columns == 3
231
+ view.delete()
232
+
233
+ def test_schema(self, client):
234
+ table = client.open_table("psp_test.superstore")
235
+ view = table.view(columns=["Sales", "Profit", "State"])
236
+ schema = view.schema()
237
+ assert schema == {
238
+ "Sales": "float",
239
+ "Profit": "float",
240
+ "State": "string",
241
+ }
242
+ view.delete()
243
+
244
+ def test_to_json(self, client):
245
+ table = client.open_table("psp_test.superstore")
246
+ view = table.view(columns=["Sales", "Quantity"])
247
+ json = view.to_json(start_row=0, end_row=5)
248
+ assert json == [
249
+ {"Sales": 261.96, "Quantity": 2},
250
+ {"Sales": 731.94, "Quantity": 3},
251
+ {"Sales": 14.62, "Quantity": 2},
252
+ {"Sales": 957.5775, "Quantity": 5},
253
+ {"Sales": 22.368, "Quantity": 2},
254
+ ]
255
+ view.delete()
256
+
257
+ def test_to_columns(self, client):
258
+ table = client.open_table("psp_test.superstore")
259
+ view = table.view(columns=["Sales", "Quantity"])
260
+ columns = view.to_columns(start_row=0, end_row=5)
261
+ assert columns == {
262
+ "Sales": [261.96, 731.94, 14.62, 957.5775, 22.368],
263
+ "Quantity": [2, 3, 2, 5, 2],
264
+ }
265
+ view.delete()
266
+
267
+ def test_column_paths(self, client):
268
+ table = client.open_table("psp_test.superstore")
269
+ view = table.view(columns=["Sales", "Profit", "State"])
270
+ paths = joined_column_paths(view)
271
+ assert paths == ["Sales", "Profit", "State"]
272
+ view.delete()
273
+
274
+
275
+ class TestPostgresGroupBy:
276
+ def test_single_group_by(self, client):
277
+ table = client.open_table("psp_test.superstore")
278
+ view = table.view(
279
+ columns=["Sales"],
280
+ group_by=["Region"],
281
+ aggregates={"Sales": "sum"},
282
+ )
283
+ num_rows = view.num_rows()
284
+ assert num_rows == 5
285
+ json = view.to_json()
286
+ assert json == [
287
+ {"__ROW_PATH__": [], "Sales": approx(2297200.860299955)},
288
+ {"__ROW_PATH__": ["Central"], "Sales": approx(501239.8908000005)},
289
+ {"__ROW_PATH__": ["East"], "Sales": approx(678781.2399999979)},
290
+ {"__ROW_PATH__": ["South"], "Sales": approx(391721.9050000003)},
291
+ {"__ROW_PATH__": ["West"], "Sales": approx(725457.8245000006)},
292
+ ]
293
+ view.delete()
294
+
295
+ def test_multi_level_group_by(self, client):
296
+ table = client.open_table("psp_test.superstore")
297
+ view = table.view(
298
+ columns=["Sales"],
299
+ group_by=["Region", "Category"],
300
+ aggregates={"Sales": "sum"},
301
+ )
302
+ json = view.to_json()
303
+ assert json == [
304
+ {"__ROW_PATH__": [], "Sales": approx(2297200.860299955)},
305
+ {"__ROW_PATH__": ["Central"], "Sales": approx(501239.8908000005)},
306
+ {
307
+ "__ROW_PATH__": ["Central", "Furniture"],
308
+ "Sales": approx(163797.16380000004),
309
+ },
310
+ {
311
+ "__ROW_PATH__": ["Central", "Office Supplies"],
312
+ "Sales": approx(167026.41500000027),
313
+ },
314
+ {
315
+ "__ROW_PATH__": ["Central", "Technology"],
316
+ "Sales": approx(170416.3119999999),
317
+ },
318
+ {"__ROW_PATH__": ["East"], "Sales": approx(678781.2399999979)},
319
+ {
320
+ "__ROW_PATH__": ["East", "Furniture"],
321
+ "Sales": approx(208291.20400000009),
322
+ },
323
+ {
324
+ "__ROW_PATH__": ["East", "Office Supplies"],
325
+ "Sales": approx(205516.0549999999),
326
+ },
327
+ {
328
+ "__ROW_PATH__": ["East", "Technology"],
329
+ "Sales": approx(264973.9810000003),
330
+ },
331
+ {"__ROW_PATH__": ["South"], "Sales": approx(391721.9050000003)},
332
+ {
333
+ "__ROW_PATH__": ["South", "Furniture"],
334
+ "Sales": approx(117298.6840000001),
335
+ },
336
+ {
337
+ "__ROW_PATH__": ["South", "Office Supplies"],
338
+ "Sales": approx(125651.31299999992),
339
+ },
340
+ {
341
+ "__ROW_PATH__": ["South", "Technology"],
342
+ "Sales": approx(148771.9079999999),
343
+ },
344
+ {"__ROW_PATH__": ["West"], "Sales": approx(725457.8245000006)},
345
+ {
346
+ "__ROW_PATH__": ["West", "Furniture"],
347
+ "Sales": approx(252612.7435000003),
348
+ },
349
+ {
350
+ "__ROW_PATH__": ["West", "Office Supplies"],
351
+ "Sales": approx(220853.24900000007),
352
+ },
353
+ {
354
+ "__ROW_PATH__": ["West", "Technology"],
355
+ "Sales": approx(251991.83199999997),
356
+ },
357
+ ]
358
+ view.delete()
359
+
360
+ def test_group_by_with_count_aggregate(self, client):
361
+ table = client.open_table("psp_test.superstore")
362
+ view = table.view(
363
+ columns=["Sales"],
364
+ group_by=["Region"],
365
+ aggregates={"Sales": "count"},
366
+ )
367
+ json = view.to_json()
368
+ assert json == [
369
+ {"__ROW_PATH__": [], "Sales": 9994},
370
+ {"__ROW_PATH__": ["Central"], "Sales": 2323},
371
+ {"__ROW_PATH__": ["East"], "Sales": 2848},
372
+ {"__ROW_PATH__": ["South"], "Sales": 1620},
373
+ {"__ROW_PATH__": ["West"], "Sales": 3203},
374
+ ]
375
+ view.delete()
376
+
377
+ def test_group_by_with_avg_aggregate(self, client):
378
+ table = client.open_table("psp_test.superstore")
379
+ view = table.view(
380
+ columns=["Sales"],
381
+ group_by=["Category"],
382
+ aggregates={"Sales": "avg"},
383
+ )
384
+ json = view.to_json()
385
+ assert json == [
386
+ {"__ROW_PATH__": [], "Sales": approx(229.8580008304938)},
387
+ {"__ROW_PATH__": ["Furniture"], "Sales": approx(349.83488698727007)},
388
+ {
389
+ "__ROW_PATH__": ["Office Supplies"],
390
+ "Sales": approx(119.32410089611732),
391
+ },
392
+ {"__ROW_PATH__": ["Technology"], "Sales": approx(452.70927612344155)},
393
+ ]
394
+ view.delete()
395
+
396
+ def test_group_by_with_min_aggregate(self, client):
397
+ table = client.open_table("psp_test.superstore")
398
+ view = table.view(
399
+ columns=["Quantity"],
400
+ group_by=["Region"],
401
+ aggregates={"Quantity": "min"},
402
+ )
403
+ json = view.to_json()
404
+ assert json == [
405
+ {"__ROW_PATH__": [], "Quantity": 1},
406
+ {"__ROW_PATH__": ["Central"], "Quantity": 1},
407
+ {"__ROW_PATH__": ["East"], "Quantity": 1},
408
+ {"__ROW_PATH__": ["South"], "Quantity": 1},
409
+ {"__ROW_PATH__": ["West"], "Quantity": 1},
410
+ ]
411
+ view.delete()
412
+
413
+ def test_group_by_with_max_aggregate(self, client):
414
+ table = client.open_table("psp_test.superstore")
415
+ view = table.view(
416
+ columns=["Quantity"],
417
+ group_by=["Region"],
418
+ aggregates={"Quantity": "max"},
419
+ )
420
+ json = view.to_json()
421
+ assert json == [
422
+ {"__ROW_PATH__": [], "Quantity": 14},
423
+ {"__ROW_PATH__": ["Central"], "Quantity": 14},
424
+ {"__ROW_PATH__": ["East"], "Quantity": 14},
425
+ {"__ROW_PATH__": ["South"], "Quantity": 14},
426
+ {"__ROW_PATH__": ["West"], "Quantity": 14},
427
+ ]
428
+ view.delete()
429
+
430
+ def test_group_by_with_stddev_aggregate(self, client):
431
+ # A Postgres-vocabulary aggregate with a `numeric` result, which
432
+ # arrives as `Decimal` and must normalize to `float`.
433
+ table = client.open_table("psp_test.superstore")
434
+ view = table.view(
435
+ columns=["Quantity"],
436
+ group_by=["Region"],
437
+ aggregates={"Quantity": "stddev_samp"},
438
+ )
439
+ json = view.to_json()
440
+ assert len(json) == 5
441
+ assert all(isinstance(row["Quantity"], float) for row in json)
442
+ view.delete()
443
+
444
+
445
+ class TestPostgresFilter:
446
+ def test_filter_with_equals(self, client):
447
+ table = client.open_table("psp_test.superstore")
448
+ view = table.view(
449
+ columns=["Sales", "Region"],
450
+ filter=[["Region", "==", "West"]],
451
+ )
452
+ json = view.to_json(start_row=0, end_row=5)
453
+ assert json == [
454
+ {"Sales": 14.62, "Region": "West"},
455
+ {"Sales": 48.86, "Region": "West"},
456
+ {"Sales": 7.28, "Region": "West"},
457
+ {"Sales": 907.152, "Region": "West"},
458
+ {"Sales": 18.504, "Region": "West"},
459
+ ]
460
+ view.delete()
461
+
462
+ def test_filter_with_not_equals(self, client):
463
+ table = client.open_table("psp_test.superstore")
464
+ view = table.view(
465
+ columns=["Sales", "Region"],
466
+ filter=[["Region", "!=", "West"]],
467
+ )
468
+ json = view.to_json(start_row=0, end_row=5)
469
+ assert json == [
470
+ {"Sales": 261.96, "Region": "South"},
471
+ {"Sales": 731.94, "Region": "South"},
472
+ {"Sales": 957.5775, "Region": "South"},
473
+ {"Sales": 22.368, "Region": "South"},
474
+ {"Sales": 15.552, "Region": "South"},
475
+ ]
476
+ view.delete()
477
+
478
+ def test_filter_with_greater_than(self, client):
479
+ table = client.open_table("psp_test.superstore")
480
+ view = table.view(
481
+ columns=["Sales", "Quantity"],
482
+ filter=[["Quantity", ">", 5]],
483
+ )
484
+ json = view.to_json(start_row=0, end_row=5)
485
+ assert json == [
486
+ {"Sales": 48.86, "Quantity": 7},
487
+ {"Sales": 907.152, "Quantity": 6},
488
+ {"Sales": 1706.184, "Quantity": 9},
489
+ {"Sales": 665.88, "Quantity": 6},
490
+ {"Sales": 19.46, "Quantity": 7},
491
+ ]
492
+ view.delete()
493
+
494
+ def test_filter_with_less_than(self, client):
495
+ table = client.open_table("psp_test.superstore")
496
+ view = table.view(
497
+ columns=["Sales", "Quantity"],
498
+ filter=[["Quantity", "<", 3]],
499
+ )
500
+ json = view.to_json(start_row=0, end_row=5)
501
+ assert json == [
502
+ {"Sales": 261.96, "Quantity": 2},
503
+ {"Sales": 14.62, "Quantity": 2},
504
+ {"Sales": 22.368, "Quantity": 2},
505
+ {"Sales": 55.5, "Quantity": 2},
506
+ {"Sales": 8.56, "Quantity": 2},
507
+ ]
508
+ view.delete()
509
+
510
+ def test_filter_with_like(self, client):
511
+ table = client.open_table("psp_test.superstore")
512
+ view = table.view(
513
+ columns=["Sales", "State"],
514
+ filter=[["State", "LIKE", "Cal%"]],
515
+ )
516
+ json = view.to_json(start_row=0, end_row=5)
517
+ assert json == [
518
+ {"Sales": 14.62, "State": "California"},
519
+ {"Sales": 48.86, "State": "California"},
520
+ {"Sales": 7.28, "State": "California"},
521
+ {"Sales": 907.152, "State": "California"},
522
+ {"Sales": 18.504, "State": "California"},
523
+ ]
524
+ view.delete()
525
+
526
+ def test_multiple_filters(self, client):
527
+ table = client.open_table("psp_test.superstore")
528
+ view = table.view(
529
+ columns=["Sales", "Region", "Quantity"],
530
+ filter=[
531
+ ["Region", "==", "West"],
532
+ ["Quantity", ">", 3],
533
+ ],
534
+ )
535
+ json = view.to_json(start_row=0, end_row=5)
536
+ assert json == [
537
+ {"Sales": 48.86, "Region": "West", "Quantity": 7},
538
+ {"Sales": 7.28, "Region": "West", "Quantity": 4},
539
+ {"Sales": 907.152, "Region": "West", "Quantity": 6},
540
+ {"Sales": 114.9, "Region": "West", "Quantity": 5},
541
+ {"Sales": 1706.184, "Region": "West", "Quantity": 9},
542
+ ]
543
+ view.delete()
544
+
545
+ def test_filter_with_group_by(self, client):
546
+ table = client.open_table("psp_test.superstore")
547
+ view = table.view(
548
+ columns=["Sales"],
549
+ group_by=["Category"],
550
+ filter=[["Region", "==", "West"]],
551
+ aggregates={"Sales": "sum"},
552
+ )
553
+ num_rows = view.num_rows()
554
+ assert num_rows == 4
555
+ json = view.to_json()
556
+ assert json == [
557
+ {"__ROW_PATH__": [], "Sales": approx(725457.8245000006)},
558
+ {"__ROW_PATH__": ["Furniture"], "Sales": approx(252612.7435000003)},
559
+ {
560
+ "__ROW_PATH__": ["Office Supplies"],
561
+ "Sales": approx(220853.24900000007),
562
+ },
563
+ {"__ROW_PATH__": ["Technology"], "Sales": approx(251991.83199999997)},
564
+ ]
565
+ view.delete()
566
+
567
+
568
+ class TestPostgresSort:
569
+ def test_sort_ascending(self, client):
570
+ table = client.open_table("psp_test.superstore")
571
+ view = table.view(
572
+ columns=["Sales", "Quantity"],
573
+ sort=[["Sales", "asc"]],
574
+ )
575
+ json = view.to_json(start_row=0, end_row=5)
576
+ assert json == [
577
+ {"Sales": 0.444, "Quantity": 1},
578
+ {"Sales": 0.556, "Quantity": 1},
579
+ {"Sales": 0.836, "Quantity": 1},
580
+ {"Sales": 0.852, "Quantity": 1},
581
+ {"Sales": 0.876, "Quantity": 1},
582
+ ]
583
+ view.delete()
584
+
585
+ def test_sort_descending(self, client):
586
+ table = client.open_table("psp_test.superstore")
587
+ view = table.view(
588
+ columns=["Sales", "Quantity"],
589
+ sort=[["Sales", "desc"]],
590
+ )
591
+ json = view.to_json(start_row=0, end_row=5)
592
+ assert json == [
593
+ {"Sales": 22638.48, "Quantity": 6},
594
+ {"Sales": 17499.95, "Quantity": 5},
595
+ {"Sales": 13999.96, "Quantity": 4},
596
+ {"Sales": 11199.968, "Quantity": 4},
597
+ {"Sales": 10499.97, "Quantity": 3},
598
+ ]
599
+ view.delete()
600
+
601
+ def test_sort_with_group_by(self, client):
602
+ table = client.open_table("psp_test.superstore")
603
+ view = table.view(
604
+ columns=["Sales"],
605
+ group_by=["Region"],
606
+ sort=[["Sales", "desc"]],
607
+ aggregates={"Sales": "sum"},
608
+ )
609
+ json = view.to_json()
610
+ assert json == [
611
+ {"__ROW_PATH__": [], "Sales": approx(2297200.860299955)},
612
+ {"__ROW_PATH__": ["West"], "Sales": approx(725457.8245000006)},
613
+ {"__ROW_PATH__": ["East"], "Sales": approx(678781.2399999979)},
614
+ {"__ROW_PATH__": ["Central"], "Sales": approx(501239.8908000005)},
615
+ {"__ROW_PATH__": ["South"], "Sales": approx(391721.9050000003)},
616
+ ]
617
+ view.delete()
618
+
619
+ def test_sort_with_multi_level_group_by(self, client):
620
+ # Hierarchical rollup sort - exercises the `first_value(...) OVER
621
+ # __WINDOW_N__` emission, which must be that spelling for Postgres
622
+ # (DuckDB's `first` does not exist).
623
+ table = client.open_table("psp_test.superstore")
624
+ view = table.view(
625
+ columns=["Sales"],
626
+ group_by=["Region", "Category"],
627
+ sort=[["Sales", "desc"]],
628
+ aggregates={"Sales": "sum"},
629
+ )
630
+ json = view.to_json()
631
+ assert json[0]["__ROW_PATH__"] == []
632
+ assert json[1]["__ROW_PATH__"] == ["West"]
633
+ assert json[2]["__ROW_PATH__"] == ["West", "Furniture"]
634
+ assert json[5]["__ROW_PATH__"] == ["East"]
635
+ view.delete()
636
+
637
+ def test_multi_column_sort(self, client):
638
+ table = client.open_table("psp_test.superstore")
639
+ view = table.view(
640
+ columns=["Region", "Sales", "Quantity"],
641
+ sort=[
642
+ ["Region", "asc"],
643
+ ["Sales", "desc"],
644
+ ],
645
+ )
646
+ json = view.to_json(start_row=0, end_row=5)
647
+ assert json == [
648
+ {"Region": "Central", "Sales": 17499.95, "Quantity": 5},
649
+ {"Region": "Central", "Sales": 9892.74, "Quantity": 13},
650
+ {"Region": "Central", "Sales": 9449.95, "Quantity": 5},
651
+ {"Region": "Central", "Sales": 8159.952, "Quantity": 8},
652
+ {"Region": "Central", "Sales": 5443.96, "Quantity": 4},
653
+ ]
654
+ view.delete()
655
+
656
+
657
+ class TestPostgresExpressions:
658
+ def test_simple_expression(self, client):
659
+ table = client.open_table("psp_test.superstore")
660
+ view = table.view(
661
+ columns=["Sales", "doublesales"],
662
+ expressions={"doublesales": '"Sales" * 2'},
663
+ )
664
+ json = view.to_json(start_row=0, end_row=5)
665
+ assert json == [
666
+ {"Sales": 261.96, "doublesales": 523.92},
667
+ {"Sales": 731.94, "doublesales": 1463.88},
668
+ {"Sales": 14.62, "doublesales": 29.24},
669
+ {"Sales": 957.5775, "doublesales": 1915.155},
670
+ {"Sales": 22.368, "doublesales": 44.736},
671
+ ]
672
+ view.delete()
673
+
674
+ def test_expression_with_multiple_columns(self, client):
675
+ table = client.open_table("psp_test.superstore")
676
+ view = table.view(
677
+ columns=["Sales", "Profit", "margin"],
678
+ expressions={"margin": '"Profit" / "Sales"'},
679
+ )
680
+ json = view.to_json(start_row=0, end_row=5)
681
+ assert json == [
682
+ {"Sales": 261.96, "Profit": 41.9136, "margin": approx(0.16)},
683
+ {"Sales": 731.94, "Profit": 219.582, "margin": approx(0.3)},
684
+ {"Sales": 14.62, "Profit": 6.8714, "margin": approx(0.47)},
685
+ {"Sales": 957.5775, "Profit": -383.031, "margin": approx(-0.4)},
686
+ {"Sales": 22.368, "Profit": 2.5164, "margin": approx(0.1125)},
687
+ ]
688
+ view.delete()
689
+
690
+ def test_expression_with_group_by(self, client):
691
+ table = client.open_table("psp_test.superstore")
692
+ view = table.view(
693
+ columns=["total"],
694
+ group_by=["Region"],
695
+ expressions={"total": '"Sales" + "Profit"'},
696
+ aggregates={"total": "sum"},
697
+ )
698
+ json = view.to_json()
699
+ assert json == [
700
+ {"__ROW_PATH__": [], "total": approx(2583597.882000014)},
701
+ {"__ROW_PATH__": ["Central"], "total": approx(540946.2532999996)},
702
+ {"__ROW_PATH__": ["East"], "total": approx(770304.0199999991)},
703
+ {"__ROW_PATH__": ["South"], "total": approx(438471.33530000027)},
704
+ {"__ROW_PATH__": ["West"], "total": approx(833876.2733999988)},
705
+ ]
706
+ view.delete()
707
+
708
+
709
+ class TestPostgresViewport:
710
+ def test_start_row_and_end_row(self, client):
711
+ table = client.open_table("psp_test.superstore")
712
+ view = table.view(columns=["Sales", "Profit"])
713
+ json = view.to_json(start_row=10, end_row=15)
714
+ assert json == [
715
+ {"Sales": 1706.184, "Profit": 85.3092},
716
+ {"Sales": 911.424, "Profit": 68.3568},
717
+ {"Sales": 15.552, "Profit": 5.4432},
718
+ {"Sales": 407.976, "Profit": 132.5922},
719
+ {"Sales": 68.81, "Profit": -123.858},
720
+ ]
721
+ view.delete()
722
+
723
+ def test_start_col_and_end_col(self, client):
724
+ table = client.open_table("psp_test.superstore")
725
+ view = table.view(
726
+ columns=["Sales", "Profit", "Quantity", "Discount"],
727
+ )
728
+ json = view.to_json(start_row=0, end_row=5, start_col=1, end_col=3)
729
+ assert json == [
730
+ {"Profit": 41.9136, "Quantity": 2},
731
+ {"Profit": 219.582, "Quantity": 3},
732
+ {"Profit": 6.8714, "Quantity": 2},
733
+ {"Profit": -383.031, "Quantity": 5},
734
+ {"Profit": 2.5164, "Quantity": 2},
735
+ ]
736
+ view.delete()
737
+
738
+
739
+ class TestPostgresDataTypes:
740
+ def test_date_columns(self, client):
741
+ table = client.open_table("psp_test.superstore")
742
+ view = table.view(columns=["Order Date"])
743
+ json = view.to_json(start_row=0, end_row=5)
744
+ assert json == [
745
+ {"Order Date": 1478563200000},
746
+ {"Order Date": 1478563200000},
747
+ {"Order Date": 1465689600000},
748
+ {"Order Date": 1444521600000},
749
+ {"Order Date": 1444521600000},
750
+ ]
751
+ view.delete()
752
+
753
+
754
+ class TestPostgresCombinedOperations:
755
+ def test_group_by_filter_sort(self, client):
756
+ table = client.open_table("psp_test.superstore")
757
+ view = table.view(
758
+ columns=["Sales"],
759
+ group_by=["Category"],
760
+ filter=[["Region", "==", "West"]],
761
+ sort=[["Sales", "desc"]],
762
+ aggregates={"Sales": "sum"},
763
+ )
764
+ json = view.to_json()
765
+ assert json == [
766
+ {"__ROW_PATH__": [], "Sales": approx(725457.8245000006)},
767
+ {"__ROW_PATH__": ["Furniture"], "Sales": approx(252612.7435000003)},
768
+ {"__ROW_PATH__": ["Technology"], "Sales": approx(251991.83199999997)},
769
+ {
770
+ "__ROW_PATH__": ["Office Supplies"],
771
+ "Sales": approx(220853.24900000007),
772
+ },
773
+ ]
774
+ view.delete()
775
+
776
+ def test_expressions_group_by_sort(self, client):
777
+ table = client.open_table("psp_test.superstore")
778
+ view = table.view(
779
+ columns=["profitmargin"],
780
+ group_by=["Region"],
781
+ expressions={"profitmargin": '"Profit" / "Sales" * 100'},
782
+ sort=[["profitmargin", "desc"]],
783
+ aggregates={"profitmargin": "avg"},
784
+ )
785
+ json = view.to_json()
786
+ assert json == [
787
+ {"__ROW_PATH__": [], "profitmargin": approx(12.031392972104467)},
788
+ {"__ROW_PATH__": ["West"], "profitmargin": approx(21.948661793784012)},
789
+ {"__ROW_PATH__": ["East"], "profitmargin": approx(16.722695960406636)},
790
+ {"__ROW_PATH__": ["South"], "profitmargin": approx(16.35190329218107)},
791
+ {
792
+ "__ROW_PATH__": ["Central"],
793
+ "profitmargin": approx(-10.407293926323575),
794
+ },
795
+ ]
796
+ view.delete()
797
+
798
+
799
+ class TestPostgresMinMax:
800
+ def test_min_max_integer(self, client):
801
+ table = client.open_table("psp_test.superstore")
802
+ view = table.view(columns=["Quantity"])
803
+ min_val, max_val = view.get_min_max("Quantity")
804
+ assert min_val == 1
805
+ assert max_val == 14
806
+ view.delete()
807
+
808
+ def test_min_max_float(self, client):
809
+ table = client.open_table("psp_test.superstore")
810
+ view = table.view(columns=["Sales"])
811
+ min_val, max_val = view.get_min_max("Sales")
812
+ assert min_val == 0.444
813
+ assert max_val == 22638.48
814
+ view.delete()
815
+
816
+ def test_min_max_string(self, client):
817
+ table = client.open_table("psp_test.superstore")
818
+ view = table.view(columns=["Category"])
819
+ min_val, max_val = view.get_min_max("Category")
820
+ assert min_val == "Furniture"
821
+ assert max_val == "Technology"
822
+ view.delete()
823
+
824
+ def test_min_max_with_group_by(self, client):
825
+ table = client.open_table("psp_test.superstore")
826
+ view = table.view(
827
+ columns=["Sales"],
828
+ group_by=["Region"],
829
+ aggregates={"Sales": "sum"},
830
+ )
831
+ min_val, max_val = view.get_min_max("Sales")
832
+ assert min_val > 0
833
+ assert max_val > 0
834
+ assert max_val >= min_val
835
+ view.delete()
836
+
837
+ def test_min_max_with_filter(self, client):
838
+ table = client.open_table("psp_test.superstore")
839
+ view = table.view(
840
+ columns=["Quantity"],
841
+ filter=[["Quantity", ">", 10]],
842
+ )
843
+ min_val, max_val = view.get_min_max("Quantity")
844
+ assert min_val >= 11
845
+ assert max_val == 14
846
+ view.delete()
847
+
848
+
849
+ class TestPostgresCoerceTypes:
850
+ """The Postgres types whose Perspective type is not their own."""
851
+
852
+ def test_schema(self, client):
853
+ table = client.open_table("psp_test.coerce_types")
854
+ assert table.schema() == {
855
+ "small": "integer",
856
+ "big": "float",
857
+ "float": "float",
858
+ "decimal": "float",
859
+ "time": "datetime",
860
+ "timestamp": "datetime",
861
+ "timestamptz": "datetime",
862
+ "date": "date",
863
+ "enum": "string",
864
+ "uuid": "string",
865
+ "json": "string",
866
+ "string": "string",
867
+ }
868
+
869
+ def test_numbers_flat(self, client):
870
+ table = client.open_table("psp_test.coerce_types")
871
+ view = table.view(columns=["small", "big", "float", "decimal"])
872
+ assert view.to_json() == [
873
+ {
874
+ "small": -300,
875
+ "big": 9007199254740992.0,
876
+ "float": 1.5,
877
+ "decimal": pytest.approx(1.234),
878
+ },
879
+ {
880
+ "small": 300,
881
+ "big": -9007199254740992.0,
882
+ "float": -1.5,
883
+ "decimal": pytest.approx(-5.678),
884
+ },
885
+ ]
886
+ view.delete()
887
+
888
+ def test_temporal_flat(self, client):
889
+ table = client.open_table("psp_test.coerce_types")
890
+ view = table.view(columns=["time", "timestamp", "timestamptz", "date"])
891
+ assert view.to_json() == [
892
+ {
893
+ "time": 3661000,
894
+ "timestamp": 1672531200000,
895
+ "timestamptz": 1672531200000,
896
+ "date": 1672531200000,
897
+ },
898
+ {
899
+ "time": 1000,
900
+ "timestamp": 1672617600000,
901
+ "timestamptz": 1672617600000,
902
+ "date": 1672617600000,
903
+ },
904
+ ]
905
+ view.delete()
906
+
907
+ def test_stringly_flat(self, client):
908
+ # Enums are unknown OIDs (string with a warning), uuid/jsonb
909
+ # stringify.
910
+ table = client.open_table("psp_test.coerce_types")
911
+ view = table.view(columns=["enum", "uuid", "json", "string"])
912
+ assert view.to_json() == [
913
+ {
914
+ "enum": "happy",
915
+ "uuid": "00000000-0000-0000-0000-000000000001",
916
+ "json": '{"a": 1}',
917
+ "string": "a",
918
+ },
919
+ {
920
+ "enum": "sad",
921
+ "uuid": "00000000-0000-0000-0000-000000000002",
922
+ "json": '{"b": 2}',
923
+ "string": "b",
924
+ },
925
+ ]
926
+ view.delete()
927
+
928
+ def test_enum_group_by(self, client):
929
+ table = client.open_table("psp_test.coerce_types")
930
+ view = table.view(
931
+ group_by=["enum"],
932
+ columns=["small"],
933
+ aggregates={"small": "sum"},
934
+ )
935
+ assert view.to_json() == [
936
+ {"__ROW_PATH__": [], "small": 0},
937
+ {"__ROW_PATH__": ["happy"], "small": -300},
938
+ {"__ROW_PATH__": ["sad"], "small": 300},
939
+ ]
940
+ view.delete()
941
+
942
+ def test_column_values_view(self, client):
943
+ # The filter dropdown's query shape - group by the column, select
944
+ # no columns at all.
945
+ table = client.open_table("psp_test.coerce_types")
946
+ view = table.view(group_by=["enum"], columns=[])
947
+ csv = view.to_csv()
948
+ assert [line for line in csv.splitlines() if line] == [
949
+ "enum",
950
+ "null",
951
+ '"happy"',
952
+ '"sad"',
953
+ ]
954
+ view.delete()
955
+
956
+ def test_filter_matching_nothing(self, client):
957
+ table = client.open_table("psp_test.coerce_types")
958
+ view = table.view(
959
+ columns=["small"],
960
+ filter=[["string", "==", "no such value"]],
961
+ )
962
+ assert view.num_rows() == 0
963
+ assert view.to_json() == []
964
+ view.delete()