Files
boltage/README.md
T

10 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: build it and point PEO_BROWSER_DIR at its dist directory to serve it on http://localhost:7474/, or use the container image.

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.
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 get PEO_DEFAULT_GRAPH (neo4j); the container image creates that graph on first start. CREATE DATABASE x and DROP DATABASE x on the system database call create_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 final RETURN clause 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.

docker works as a drop-in replacement for podman in the commands above.

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 *, EXPLAIN and PROFILE.
  • 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's only dependency is psycopg 3. To work on it, install it in editable mode; python -m build (or uv build) builds an sdist and wheel:

pip install -e '.[binary]'

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.