Serve neo4j-browser with Starlette and uvicorn (the optional browser extra) instead of a hand-written HTTP server, and handle Bolt over WebSocket with the websockets library's Sans-I/O protocol. Rename Dockerfile, .dockerignore and docker/ to Containerfile, .containerignore and container/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
peo
neo4j-browser, cypher-shell and the Neo4j drivers on top of PostgreSQL, using Apache AGE as the graph engine.
peo is a small Python server that speaks Neo4j's Bolt protocol and translates Cypher
into AGE cypher() calls. It can also serve neo4j-browser. A container
image bundles everything, together with
pgAdmin for working with the same data in SQL.
neo4j-browser (static files) peo (Python) PostgreSQL + AGE
┌──────────────────────┐ Bolt ┌────────────────────────-──┐ SQL ┌────────────────┐
│ in your web browser │───────▶│ Bolt server (TCP + WS) │──────▶│ cypher('g',$$ │
│ served on :7474 │ :7687 │ Cypher → AGE translation │ :5432 │ ... $$) │
└──────────────────────┘ │ emulated Neo4j procedures │ └────────────────┘
└─────────────────────────-─┘
Installation
peo needs Python 3.11 or later and a PostgreSQL server with the AGE extension installed
and loaded (shared_preload_libraries=age).
pip install 'peo[binary]'
PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres peo
The binary extra pulls in psycopg's prebuilt wheels; leave it out to use a psycopg
built against your system's libpq. python -m peo works the same as peo.
Neo4j clients can now connect to neo4j://localhost:7687 with a PostgreSQL user name
and password.
peo does not include neo4j-browser itself. To serve it, install the browser extra
(Starlette and uvicorn),
build neo4j-browser and point PEO_BROWSER_DIR at its dist directory; it is then
served on http://localhost:7474/. The container image does all of this for you.
pip install 'peo[binary,browser]'
PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres PEO_BROWSER_DIR=/path/to/neo4j-browser/dist peo
Configuration
peo is configured through environment variables:
| Variable | Default | Purpose |
|---|---|---|
PEO_PG_HOST |
127.0.0.1 |
PostgreSQL host. |
PEO_PG_PORT |
5432 |
PostgreSQL port. |
PEO_PG_DATABASE |
$POSTGRES_DB, else $POSTGRES_USER, else postgres |
PostgreSQL database holding the graphs. |
PEO_DEFAULT_GRAPH |
neo4j |
Graph used when a client does not pick a database. |
PEO_BOLT_PORT |
7687 |
Port the Bolt server listens on (TCP and WebSocket). |
PEO_HTTP_PORT |
7474 |
Port neo4j-browser is served on, when PEO_BROWSER_DIR is set. |
PEO_BROWSER_DIR |
(unset) | neo4j-browser dist directory to serve; needs the browser extra. |
PEO_ADVERTISED_BOLT_PORT |
$PEO_BOLT_PORT |
Bolt port advertised to the browser; set it when clients reach the Bolt port on another port. |
PEO_ADVERTISED_HOST |
localhost |
Host reported in SHOW DATABASES and to clients that do not send a routing address. |
PEO_LOG_LEVEL |
INFO |
DEBUG logs every generated SQL statement. |
How it works
- Databases are AGE graphs. Every AGE graph shows up as a Neo4j database, and
:use <name>switches between them. Clients that do not pick a database getPEO_DEFAULT_GRAPH(neo4j); the container image creates that graph on first start.CREATE DATABASE xandDROP DATABASE xon thesystemdatabase callcreate_graph/drop_graph; AGE requires names of at least 3 characters. - Queries are sent to AGE as
SELECT * FROM cypher('<graph>', $$ <query> $$) AS (...). peo works out the result columns from the finalRETURNclause and inlines query parameters as Cypher literals. - Neo4j procedures that neo4j-browser relies on (
dbms.components,SHOW DATABASES,db.labels,db.schema.visualization,SHOW PROCEDURES, ...) are emulated from the AGE catalog. - Workarounds for AGE: neo4j-browser's "expand node" query uses a pattern
comprehension, and its relationship lookup uses
id(n) IN [...], which AGE rejects for lists of more than one element. peo rewrites both into equivalent Cypher that AGE accepts. - Authentication is passed straight through: every Bolt connection opens its own PostgreSQL connection with the credentials you log in with.
Querying the graph from SQL
AGE stores each node label and relationship type as a table in the graph's schema,
with all properties in one agtype column, properties; properties ->> 'key'::text
returns a property as text. For typed columns, add them yourself, either as a view or
as a generated column:
CREATE VIEW neo4j.people AS
SELECT id, properties ->> 'name'::text AS name, (properties ->> 'age'::text)::bigint AS age
FROM neo4j."Person";
ALTER TABLE neo4j."Person"
ADD COLUMN age bigint GENERATED ALWAYS AS ((properties ->> 'age'::text)::bigint) STORED;
PostgreSQL keeps generated columns up to date on every Cypher CREATE, MERGE, SET
and REMOVE, and they can be indexed. Some things to keep in mind:
- A value that does not fit the column's type makes the Cypher write fail: with the
column above,
CREATE (:Person {age: 'old'})is rejected. A view only fails when it is read. - AGE does not allow dropping columns from its label tables, so a generated column cannot be removed again.
- Adding a stored generated column rewrites the table and locks it while doing so.
- AGE has no date or time types, so store those as ISO 8601 strings and cast them in SQL.
Querying the graph from SQL
AGE stores each node label and relationship type as a table in the graph's schema,
with all properties in one agtype column, properties; properties ->> 'key'::text
returns a property as text. For typed columns, add them yourself, either as a view or
as a generated column:
CREATE VIEW neo4j.people AS
SELECT id, properties ->> 'name'::text AS name, (properties ->> 'age'::text)::bigint AS age
FROM neo4j."Person";
ALTER TABLE neo4j."Person"
ADD COLUMN age bigint GENERATED ALWAYS AS ((properties ->> 'age'::text)::bigint) STORED;
PostgreSQL keeps generated columns up to date on every Cypher CREATE, MERGE, SET
and REMOVE, and they can be indexed. Some things to keep in mind:
- A value that does not fit the column's type makes the Cypher write fail: with the
column above,
CREATE (:Person {age: 'old'})is rejected. A view only fails when it is read. - AGE does not allow dropping columns from its label tables, so a generated column cannot be removed again.
- Adding a stored generated column rewrites the table and locks it while doing so.
- AGE has no date or time types, so store those as ISO 8601 strings and cast them in SQL.
Container image
The container image runs PostgreSQL with AGE, peo, neo4j-browser and pgAdmin in one container:
podman build -t peo .
podman run -d --name peo -e POSTGRES_PASSWORD=secret \
-p 7474:7474 -p 7687:7687 -p 5050:5050 -p 5432:5432 \
-v peo-data:/var/lib/postgresql -v peo-pgadmin:/var/lib/pgadmin peo
| Port | Service | Log in with |
|---|---|---|
| 7474 | neo4j-browser: http://localhost:7474/ | PostgreSQL user postgres and POSTGRES_PASSWORD |
| 7687 | Bolt, for Neo4j drivers and cypher-shell (neo4j://localhost:7687) |
same as above |
| 5050 | pgAdmin: http://localhost:5050/ | admin@example.com and POSTGRES_PASSWORD |
| 5432 | PostgreSQL, for psql and other SQL clients |
same as neo4j-browser |
pgAdmin comes with the bundled PostgreSQL server already registered as "peo"; it asks for the PostgreSQL password the first time you open it. AGE stores each graph as a schema of the same name, with one table per label.
Any OCI-compatible build tool works. With Docker, pass the file name explicitly:
docker build -f Containerfile -t peo .; docker run takes the same options as
podman run.
Besides peo's own variables above, the image uses these:
| Variable | Default | Purpose |
|---|---|---|
POSTGRES_PASSWORD |
(required) | Password of the PostgreSQL superuser, see the postgres image docs. POSTGRES_USER and POSTGRES_DB work too. |
PEO_DEFAULT_GRAPH |
neo4j |
Also the graph created when the database is initialised. |
PGADMIN_DEFAULT_EMAIL |
admin@example.com |
pgAdmin login, created on first start. |
PGADMIN_DEFAULT_PASSWORD |
$POSTGRES_PASSWORD |
pgAdmin login password, set on first start. |
PEO_PGADMIN |
true |
Set to false to not start pgAdmin. |
PEO_PGADMIN_PORT |
5050 |
Port pgAdmin listens on inside the container. |
Build arguments: AGE_IMAGE (base image, default apache/age:release_PG18_1.8.0),
NEO4J_BROWSER_REF (neo4j-browser commit to build) and PGADMIN_VERSION (default 9.18).
pgAdmin keeps its users and settings in /var/lib/pgadmin; changing
PGADMIN_DEFAULT_* after the first start has no effect unless that directory is empty.
Limitations
This is a first version. Things that do not work (yet):
RETURN *,EXPLAINandPROFILE.- Write statistics: write queries report "(no changes, no records)" even when they changed the graph.
- Anything AGE does not support, for example multiple labels per node, pattern comprehensions, temporal and spatial types, indexes and constraints through Cypher.
- User and role management,
SHOW TRANSACTIONS,:sysinfo. - TLS (
bolt+s://, HTTPS). - Each query's full result is fetched from PostgreSQL before it is streamed to the client, so very large results use memory in peo.
Development
peo depends on psycopg 3 and websockets, plus Starlette and uvicorn for the
browser extra. To work on it, install it in editable mode; python -m build (or
uv build) builds an sdist and wheel:
pip install -e '.[binary,browser]'
tests/smoke_test.py runs end-to-end checks through the official
Neo4j Python driver against a running instance:
pip install neo4j
python tests/smoke_test.py neo4j://localhost:7687 postgres secret
License
peo is free software, licensed under the GNU General Public License v3.0 or later; see LICENSE. The container image bundles third-party software under its own licenses, including neo4j-browser (GPL-3.0), Apache AGE (Apache-2.0), PostgreSQL and pgAdmin (PostgreSQL License). See THIRD_PARTY_NOTICES.md for the full list and where each license text and source archive is in the image.
Neo4j is a trademark of Neo4j, Inc.; peo is not affiliated with or endorsed by Neo4j.