Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ Node.js and Rust — or delegates to a database you already have.
server-side, streaming only what's visible.

- Virtual servers that run Perspective's UI directly on external engines like
[DuckDB](https://duckdb.org/), [ClickHouse](https://clickhouse.com/) and
[DuckDB](https://duckdb.org/), [ClickHouse](https://clickhouse.com/),
[PostgreSQL](https://www.postgresql.org/) and
[Polars](https://pola.rs/), translating view configurations into native
queries — no ETL or data copy required.

Expand All @@ -76,6 +77,7 @@ Node.js and Rust — or delegates to a database you already have.
- [`perspective.handlers.tornado`](https://perspective-dev.github.io/python/perspective/handlers/tornado.html)
- [`perspective.virtual_servers.clickhouse`](https://perspective-dev.github.io/python/perspective/virtual_servers/clickhouse.html)
- [`perspective.virtual_servers.duckdb`](https://perspective-dev.github.io/python/perspective/virtual_servers/duckdb.html)
- [`perspective.virtual_servers.postgres`](https://perspective-dev.github.io/python/perspective/virtual_servers/postgres.html)
- Rust API
- [`perspective`](https://docs.rs/perspective/latest/perspective/)

Expand Down
9 changes: 5 additions & 4 deletions docs/md/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ const view = await table.view({
```

Window Columns are supported by Perspective's built-in engine, by the DuckDB,
ClickHouse and Polars [Virtual Servers](./explanation/virtual_servers.md), and
ClickHouse, PostgreSQL and Polars
[Virtual Servers](./explanation/virtual_servers.md), and
by the `<perspective-viewer>` UI. They update incrementally as the `Table`
updates.

Expand Down Expand Up @@ -373,9 +374,9 @@ No. The WebSocket `Server` is not a security boundary. Every connected `Client`
is treated as the author of the queries it submits, and is permitted to create
and delete `Table`/`View` resources, author arbitrary
[expression columns](./explanation/view/config/expressions.md), and — for
[Virtual Server](./explanation/virtual_servers.md) backends like DuckDB or
ClickHouse — author SQL fragments executed under the configured database
role. The bundled WebSocket adapters
[Virtual Server](./explanation/virtual_servers.md) backends like DuckDB,
ClickHouse or PostgreSQL — author SQL fragments executed under the configured
database role. The bundled WebSocket adapters
(`tornado.py`/`aiohttp.py`/`starlette.py`/`WebSocketServer`) are reference
integrations and do not authenticate, authorize, or enforce origin policy.

Expand Down
1 change: 1 addition & 0 deletions docs/md/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
- [DuckDB](./how_to/python/virtual_server/duckdb.md)
- [ClickHouse](./how_to/python/virtual_server/clickhouse.md)
- [Polars](./how_to/python/virtual_server/polars.md)
- [PostgreSQL](./how_to/python/virtual_server/postgres.md)
- [Custom](./how_to/python/virtual_server/custom.md)

# Rust
Expand Down
2 changes: 1 addition & 1 deletion docs/md/explanation/view/config/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ first row has no predecessor to difference against:
## Support

Window Columns are implemented by Perspective's built-in engine, by the
DuckDB, ClickHouse and Polars
DuckDB, ClickHouse, PostgreSQL and Polars
[Virtual Servers](../../virtual_servers.md), and by the
`<perspective-viewer>` UI. Virtual Servers advertise support through their
_features_ declaration, so the UI control is hidden for backends which do not
Expand Down
2 changes: 2 additions & 0 deletions docs/md/explanation/virtual_servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Perspective ships with virtual server implementations for:
- **ClickHouse** — query a ClickHouse server from the browser
([JavaScript](../how_to/javascript/virtual_server/clickhouse.md)) or from
Python ([Python](../how_to/python/virtual_server/clickhouse.md)).
- **PostgreSQL** — query a PostgreSQL server (16 or later) from Python
([Python](../how_to/python/virtual_server/postgres.md)).

## Custom implementations

Expand Down
2 changes: 2 additions & 0 deletions docs/md/how_to/python/virtual_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Perspective ships with built-in virtual server implementations for:
using the `clickhouse-connect` Python package.
- [**Polars**](./virtual_server/polars.md) — query in-memory Polars DataFrames
using the `polars` Python package.
- [**PostgreSQL**](./virtual_server/postgres.md) — query a PostgreSQL server
(16 or later) using the `psycopg` Python package.

You can also [**implement your own**](./virtual_server/custom.md) virtual server
to connect Perspective to any data source by subclassing `VirtualServerHandler`.
6 changes: 3 additions & 3 deletions docs/md/how_to/python/virtual_server/custom.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,6 @@ app = tornado.web.Application([
])
```

The built-in [DuckDB](./duckdb.md), [ClickHouse](./clickhouse.md) and
[Polars](./polars.md) implementations all follow exactly this shape and are
worth reading as complete references.
The built-in [DuckDB](./duckdb.md), [ClickHouse](./clickhouse.md),
[PostgreSQL](./postgres.md) and [Polars](./polars.md) implementations all
follow exactly this shape and are worth reading as complete references.
84 changes: 84 additions & 0 deletions docs/md/how_to/python/virtual_server/postgres.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# PostgreSQL Virtual Server

Perspective provides a built-in virtual server for
[PostgreSQL](https://www.postgresql.org/), allowing `<perspective-viewer>`
clients to query a PostgreSQL server over WebSocket.

Requires PostgreSQL 16 or later.

## Installation

```bash
pip install perspective-python "psycopg[binary]"
```

## Usage

Create a server that exposes PostgreSQL tables to browser clients:

```python
import tornado.web
import tornado.ioloop
from perspective.virtual_servers.postgres import PostgresVirtualServer
from perspective.handlers.tornado import PerspectiveTornadoHandler

# Create virtual server backed by PostgreSQL. Each browser session opens its
# own connection with this DSN.
server = PostgresVirtualServer("postgresql://user@localhost:5432/mydb")

# Serve over WebSocket
app = tornado.web.Application([
(r"/websocket", PerspectiveTornadoHandler, {"perspective_server": server}),
])

app.listen(8080)
tornado.ioloop.IOLoop.current().start()
```

Connect from the browser (table names are schema-qualified):

```javascript
const websocket = await perspective.websocket("ws://localhost:8080/websocket");
const table = await websocket.open_table("public.my_table");
document.getElementById("viewer").load(table);
```

The server is read-only with respect to your data: each viewer session
materializes its queries as connection-scoped `TEMPORARY VIEW`s, which
PostgreSQL drops automatically when the session disconnects.

## Aggregates

Aggregates are PostgreSQL's own functions, under their PostgreSQL names, and
each column type advertises only the aggregates PostgreSQL defines for it —
for example `bit_and`/`bit_or`/`bit_xor` on integers only, and
`bool_and`/`bool_or`/`every` (rather than `min`/`max`) on booleans.
`any_value` is the default for columns with no explicit aggregate, which is
why PostgreSQL 16 is required.

## Window functions

Window columns are PostgreSQL's own functions, under their PostgreSQL names —
the advertised name is emitted into the `OVER` clause verbatim.

| | |
| -------------------- | ------------------------------------------------------------------- |
| Aggregating | `sum` `avg` `count` `min` `max` |
| Deviation / variance | `stddev_samp` `stddev_pop` `var_samp` `var_pop` |
| Navigation | `first_value` `last_value` `nth_value` `lag` `lead` |
| Ranking | `row_number` `rank` `dense_rank` `percent_rank` `cume_dist` `ntile` |
| Perspective's own | `diff` |

`range` frames require a numeric order key in PostgreSQL, so they are
advertised for numeric column types only.

## Limitations

- **Split by** is not supported — PostgreSQL has no `PIVOT` statement.
- Natural-order (unsorted) window functions are not supported, since
PostgreSQL has no stable row identity; window columns require an explicit
order key.

## Examples

- [Python PostgreSQL example](https://github.com/perspective-dev/perspective/tree/master/examples/python-postgres-virtual)
29 changes: 29 additions & 0 deletions examples/python-postgres-virtual/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<link rel="stylesheet" crossorigin="anonymous" href="/node_modules/@perspective-dev/viewer/dist/css/themes.css" />
<style>
perspective-viewer {
position: absolute;
inset: 0;
}
</style>
</head>
<body>
<perspective-viewer id="viewer"></perspective-viewer>
<script type="module">
import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js";
import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js";
import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js";
import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js";
const viewer = document.getElementById("viewer");

// Create a client that expects a Perspective server to accept
// Websocket connections at the specified URL.
const websocket = await perspective.websocket("ws://localhost:3000/websocket");
viewer.load(websocket);
viewer.restore({ table: "public.superstore" });
</script>
</body>
</html>
21 changes: 21 additions & 0 deletions examples/python-postgres-virtual/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "python-postgres-virtual",
"private": true,
"version": "5.2.0",
"description": "An example of streaming a PostgreSQL-backed `perspective-python` server to the browser.",
"scripts": {
"start": "PYTHONPATH=../../python/perspective python3 server.py"
},
"keywords": [],
"license": "Apache-2.0",
"dependencies": {
"@perspective-dev/client": "workspace:^",
"@perspective-dev/viewer": "workspace:^",
"@perspective-dev/viewer-charts": "workspace:^",
"@perspective-dev/viewer-datagrid": "workspace:^",
"superstore-arrow": "catalog:"
},
"devDependencies": {
"npm-run-all": "catalog:"
}
}
111 changes: 111 additions & 0 deletions examples/python-postgres-virtual/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
# ┃ This file is part of the Perspective library, distributed under the terms ┃
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import logging
import os
from pathlib import Path

import perspective
import perspective.handlers.tornado
import perspective.virtual_servers.postgres
import psycopg
import pyarrow.parquet as pq
import tornado.ioloop
import tornado.web

from tornado.web import StaticFileHandler

logging.basicConfig(
level=logging.DEBUG,
)

logger = logging.getLogger(__name__)

# Requires a running PostgreSQL >= 16 - e.g.
# `docker run --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:16`
# with `PSP_POSTGRES_DSN="postgresql://postgres@localhost:5432/postgres"`.
DSN = os.environ.get("PSP_POSTGRES_DSN", "postgresql:///postgres")

INPUT_FILE = (
Path(__file__).parent.resolve()
/ "node_modules"
/ "superstore-arrow"
/ "superstore.parquet"
)


def arrow_type_to_postgres(arrow_type):
t = str(arrow_type)
if t in ("int8", "int16", "int32", "uint8", "uint16"):
return "INTEGER"

if t.startswith("int") or t.startswith("uint"):
return "BIGINT"

if t in ("float", "double", "halffloat"):
return "DOUBLE PRECISION"

if t.startswith("timestamp"):
return "TIMESTAMP"

if t.startswith("date"):
return "DATE"

if t == "bool":
return "BOOLEAN"

return "TEXT"


if __name__ == "__main__":
db = psycopg.connect(DSN, autocommit=True)

# Load superstore parquet data into Postgres
arrow_table = pq.read_table(str(INPUT_FILE))
db.execute('DROP TABLE IF EXISTS "superstore"')
names = arrow_table.schema.names
cols = ", ".join(
f'"{field.name}" {arrow_type_to_postgres(field.type)}'
for field in arrow_table.schema
)

db.execute(f'CREATE TABLE "superstore" ({cols})')
with db.cursor() as cur:
with cur.copy('COPY "superstore" FROM STDIN') as copy:
for row in arrow_table.to_pylist():
copy.write_row(tuple(row[n] for n in names))

logger.info("Loaded superstore data into Postgres")

virtual_server = perspective.virtual_servers.postgres.PostgresVirtualServer(DSN)

app = tornado.web.Application(
[
(
r"/websocket",
perspective.handlers.tornado.PerspectiveTornadoHandler,
{"perspective_server": virtual_server},
),
(r"/node_modules/(.*)", StaticFileHandler, {"path": "../../node_modules/"}),
(
r"/(.*)",
StaticFileHandler,
{"path": "./", "default_filename": "index.html"},
),
],
websocket_max_message_size=100 * 1024 * 1024,
)

app.listen(3000)
logger.info("Listening on http://localhost:3000")
loop = tornado.ioloop.IOLoop.current()
loop.start()
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading