Rename peo4c to peo

Renames the Python package, environment variables (PEO4C_* to PEO_*), the
SQL helper schema, image paths and the entrypoint script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bart BroereandClaude Opus 5.5 committed 2026-10-14 15:21:42 +02:00
1 parent 7bd044ddb9
commit fd0dda6470
17 files changed
+124 -124

No files matched your search

+22 -22
View File
@@ -1,11 +1,11 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
# peo4c: neo4j-browser on top of PostgreSQL + Apache AGE.
# peo: neo4j-browser on top of PostgreSQL + Apache AGE.
#
# Stage 1 builds the neo4j-browser static frontend from source.
# Stage 2 is the Apache AGE image (PostgreSQL with the AGE extension) plus the
# peo4c bridge, which speaks the Bolt protocol to the browser and translates
# peo bridge, which speaks the Bolt protocol to the browser and translates
# Cypher into AGE `cypher()` calls, and pgAdmin as a SQL interface.
ARG AGE_IMAGE=docker.io/apache/age:release_PG18_1.8.0
@@ -49,35 +49,35 @@ RUN python3 -m venv /opt/pgadmin \
COPY docker/collect-python-licenses.py /tmp/
COPY docker/licenses/ /tmp/licenses/
RUN /opt/pgadmin/bin/python /tmp/collect-python-licenses.py \
/usr/share/doc/peo4c/pgadmin-python-licenses /tmp/licenses \
/usr/share/doc/peo/pgadmin-python-licenses /tmp/licenses \
&& rm -r /tmp/collect-python-licenses.py /tmp/licenses
COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/peo4c/
COPY LICENSE THIRD_PARTY_NOTICES.md /usr/share/doc/peo/
COPY --from=browser /src/LICENSE /src/NOTICE.txt /src/LICENSES.txt /neo4j-browser-source.tar.gz \
/usr/share/doc/peo4c/neo4j-browser/
/usr/share/doc/peo/neo4j-browser/
ADD https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/LICENSE \
https://raw.githubusercontent.com/apache/age/${AGE_SOURCE_REF}/NOTICE \
/usr/share/doc/peo4c/apache-age/
/usr/share/doc/peo/apache-age/
COPY --from=browser /src/dist /opt/peo4c/browser
COPY bridge /opt/peo4c/bridge
COPY --from=browser /src/dist /opt/peo/browser
COPY bridge /opt/peo/bridge
COPY docker/initdb/ /docker-entrypoint-initdb.d/
COPY docker/pgadmin/config_distro.py /opt/peo4c/pgadmin/config_distro.py
COPY docker/peo4c-entrypoint.sh /usr/local/bin/peo4c-entrypoint.sh
COPY docker/pgadmin/config_distro.py /opt/peo/pgadmin/config_distro.py
COPY docker/peo-entrypoint.sh /usr/local/bin/peo-entrypoint.sh
ENV PEO4C_DEFAULT_GRAPH=neo4j \
PEO4C_BROWSER_DIR=/opt/peo4c/browser \
PEO4C_HTTP_PORT=7474 \
PEO4C_BOLT_PORT=7687 \
PEO4C_PG_HOST=127.0.0.1 \
PEO4C_PG_PORT=5432 \
PEO4C_PROPERTY_COLUMNS=true \
PEO4C_PROPERTY_COLUMNS_INTERVAL=5 \
PEO4C_PGADMIN=true \
PEO4C_PGADMIN_PORT=5050 \
CONFIG_DISTRO_FILE_PATH=/opt/peo4c/pgadmin/config_distro.py
ENV PEO_DEFAULT_GRAPH=neo4j \
PEO_BROWSER_DIR=/opt/peo/browser \
PEO_HTTP_PORT=7474 \
PEO_BOLT_PORT=7687 \
PEO_PG_HOST=127.0.0.1 \
PEO_PG_PORT=5432 \
PEO_PROPERTY_COLUMNS=true \
PEO_PROPERTY_COLUMNS_INTERVAL=5 \
PEO_PGADMIN=true \
PEO_PGADMIN_PORT=5050 \
CONFIG_DISTRO_FILE_PATH=/opt/peo/pgadmin/config_distro.py
# 7474: neo4j-browser (HTTP), 7687: Bolt, 5050: pgAdmin, 5432: PostgreSQL
EXPOSE 7474 7687 5050 5432
ENTRYPOINT ["/usr/local/bin/peo4c-entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/peo-entrypoint.sh"]
CMD ["postgres", "-c", "shared_preload_libraries=age"]
+25 -25
View File
@@ -1,4 +1,4 @@
# peo4c
# peo
[neo4j-browser](https://github.com/neo4j/neo4j-browser) running on PostgreSQL, using
[Apache AGE](https://github.com/apache/age) as the graph engine.
@@ -7,7 +7,7 @@ Everything ships in one container image, together with
[pgAdmin](https://www.pgadmin.org/) for working with the same data in SQL:
```
neo4j-browser (static files) peo4c bridge (Python) PostgreSQL + AGE
neo4j-browser (static files) peo bridge (Python) PostgreSQL + AGE
┌──────────────────────┐ Bolt ┌──────────────────────────┐ SQL ┌────────────────┐
│ in your web browser │───────▶│ Bolt server (TCP + WS) │──────▶│ cypher('g',$$ │
│ served on :7474 │ :7687 │ Cypher → AGE translation │ :5432 │ ... $$) │
@@ -18,10 +18,10 @@ Everything ships in one container image, together with
## Quick start
```sh
podman build -t peo4c .
podman run -d --name peo4c -e POSTGRES_PASSWORD=secret \
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 peo4c-data:/var/lib/postgresql -v peo4c-pgadmin:/var/lib/pgadmin peo4c
-v peo-data:/var/lib/postgresql -v peo-pgadmin:/var/lib/pgadmin peo
```
| Port | Service | Log in with |
@@ -31,7 +31,7 @@ podman run -d --name peo4c -e POSTGRES_PASSWORD=secret \
| 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 "peo4c";
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.
@@ -41,9 +41,9 @@ graph as a schema of the same name, with one table per label.
- **Databases are AGE graphs.** Every AGE graph shows up as a Neo4j database, and
`:use <name>` switches between them. A graph named `neo4j` is created on first start
(`PEO4C_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
(`PEO_DEFAULT_GRAPH`). `CREATE DATABASE x` and `DROP DATABASE x` on the `system`
database call `create_graph` / `drop_graph`; AGE requires names of at least 3 characters,
and `peo4c` is reserved for the schema holding peo4c's SQL helpers.
and `peo` is reserved for the schema holding peo's SQL helpers.
- **Queries** are sent to AGE as `SELECT * FROM cypher('<graph>', $$ <query> $$) AS (...)`.
The bridge works out the result columns from the final `RETURN` clause and inlines
query parameters as Cypher literals.
@@ -60,7 +60,7 @@ graph as a schema of the same name, with one table per label.
## 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`. peo4c adds a generated
with all properties in one `agtype` column, `properties`. peo adds a generated
column for every property key it finds, so you can use plain SQL:
```sql
@@ -70,8 +70,8 @@ CREATE INDEX ON neo4j."Person" (name);
AGE's Cypher writes do not fire PostgreSQL triggers, so new keys cannot be caught
the moment they are written. Instead the bridge calls
`CALL peo4c.sync_property_columns()` every few seconds
(`PEO4C_PROPERTY_COLUMNS_INTERVAL`), which you can also run yourself. It only scans
`CALL peo.sync_property_columns()` every few seconds
(`PEO_PROPERTY_COLUMNS_INTERVAL`), which you can also run yourself. It only scans
tables that had inserts or updates since its previous run, and adds all new keys of a
table with a single `ALTER TABLE`. Once a column exists, PostgreSQL keeps it in step
with every `CREATE`, `MERGE`, `SET` and `REMOVE`.
@@ -91,17 +91,17 @@ with every `CREATE`, `MERGE`, `SET` and `REMOVE`.
| Variable | Default | Purpose |
| --- | --- | --- |
| `POSTGRES_PASSWORD` | (required) | Password of the PostgreSQL superuser, see the [postgres image docs](https://hub.docker.com/_/postgres). `POSTGRES_USER` and `POSTGRES_DB` work too. |
| `PEO4C_DEFAULT_GRAPH` | `neo4j` | Graph created at initialisation and used when a client does not pick a database. |
| `PEO4C_PG_DATABASE` | `$POSTGRES_DB` | PostgreSQL database holding the graphs. |
| `PEO4C_ADVERTISED_BOLT_PORT` | `7687` | Bolt port advertised to the browser; set it when you publish 7687 on another host port. |
| `PEO4C_ADVERTISED_HOST` | `localhost` | Host reported in `SHOW DATABASES` and to clients that do not send a routing address. |
| `PEO4C_LOG_LEVEL` | `INFO` | `DEBUG` logs every generated SQL statement. |
| `PEO4C_PROPERTY_COLUMNS` | `true` | Set to `false` to stop adding generated property columns. |
| `PEO4C_PROPERTY_COLUMNS_INTERVAL` | `5` | Seconds between checks for new property keys. |
| `PEO_DEFAULT_GRAPH` | `neo4j` | Graph created at initialisation and used when a client does not pick a database. |
| `PEO_PG_DATABASE` | `$POSTGRES_DB` | PostgreSQL database holding the graphs. |
| `PEO_ADVERTISED_BOLT_PORT` | `7687` | Bolt port advertised to the browser; set it when you publish 7687 on another host 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. |
| `PEO_PROPERTY_COLUMNS` | `true` | Set to `false` to stop adding generated property columns. |
| `PEO_PROPERTY_COLUMNS_INTERVAL` | `5` | Seconds between checks for new property keys. |
| `PGADMIN_DEFAULT_EMAIL` | `admin@example.com` | pgAdmin login, created on first start. |
| `PGADMIN_DEFAULT_PASSWORD` | `$POSTGRES_PASSWORD` | pgAdmin login password, set on first start. |
| `PEO4C_PGADMIN` | `true` | Set to `false` to not start pgAdmin. |
| `PEO4C_PGADMIN_PORT` | `5050` | Port pgAdmin listens on inside the container. |
| `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`).
@@ -125,15 +125,15 @@ This is a first version. Things that do not work (yet):
## Development
The bridge is a dependency-light Python package in [`bridge/peo4c`](bridge/peo4c)
The bridge is a dependency-light Python package in [`bridge/peo`](bridge/peo)
(only `psycopg` 3). To run it outside the container against any AGE-enabled
PostgreSQL:
```sh
pip install 'psycopg[binary]'
cd bridge
PEO4C_PG_HOST=localhost PEO4C_PG_DATABASE=postgres PEO4C_BROWSER_DIR=/path/to/neo4j-browser/dist \
python -m peo4c
PEO_PG_HOST=localhost PEO_PG_DATABASE=postgres PEO_BROWSER_DIR=/path/to/neo4j-browser/dist \
python -m peo
```
[`tests/smoke_test.py`](tests/smoke_test.py) runs end-to-end checks through the official
@@ -146,12 +146,12 @@ python tests/smoke_test.py neo4j://localhost:7687 postgres secret
## License
peo4c is free software, licensed under the GNU General Public License v3.0 or
peo is free software, licensed under the GNU General Public License v3.0 or
later; see [LICENSE](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](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.; peo4c is not affiliated with or endorsed
Neo4j is a trademark of Neo4j, Inc.; peo is not affiliated with or endorsed
by Neo4j.
+10 -10
View File
@@ -1,40 +1,40 @@
# Third-party notices
peo4c's own code (the `bridge/`, `docker/` and `tests/` directories and the
peo's own code (the `bridge/`, `docker/` and `tests/` directories and the
`Dockerfile`) is licensed under the GNU General Public License v3.0 or later;
see [LICENSE](LICENSE).
The container image built from this repository also contains the third-party
software below. None of it is stored in this repository: it is downloaded when
the image is built. Inside the image, license texts are collected under
`/usr/share/doc/peo4c/`.
`/usr/share/doc/peo/`.
| Component | License | Copyright | License text in the image |
| --- | --- | --- | --- |
| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Dockerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo4c/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) |
| JavaScript libraries bundled into neo4j-browser | various (MIT, Apache-2.0, BSD, ...) | their respective authors | `/usr/share/doc/peo4c/neo4j-browser/LICENSES.txt` |
| [Apache AGE](https://github.com/apache/age), from the `apache/age` base image | Apache-2.0 | The Apache Software Foundation; portions Bitnine Co., Ltd. | `/usr/share/doc/peo4c/apache-age/` (`LICENSE`, `NOTICE`) |
| [neo4j-browser](https://github.com/neo4j/neo4j-browser), built unmodified from the commit pinned in the `Dockerfile` | GPL-3.0-or-later | Neo4j Sweden AB | `/usr/share/doc/peo/neo4j-browser/` (`LICENSE`, `NOTICE.txt`) |
| JavaScript libraries bundled into neo4j-browser | various (MIT, Apache-2.0, BSD, ...) | their respective authors | `/usr/share/doc/peo/neo4j-browser/LICENSES.txt` |
| [Apache AGE](https://github.com/apache/age), from the `apache/age` base image | Apache-2.0 | The Apache Software Foundation; portions Bitnine Co., Ltd. | `/usr/share/doc/peo/apache-age/` (`LICENSE`, `NOTICE`) |
| [PostgreSQL](https://www.postgresql.org/), from the `apache/age` base image | PostgreSQL License | The PostgreSQL Global Development Group, The Regents of the University of California | `/usr/share/doc/postgresql-18/copyright` |
| Debian GNU/Linux base system and packages, including Python 3 and `python3-psycopg` (LGPL-3.0) | various | their respective authors | `/usr/share/doc/<package>/copyright` |
| [pgAdmin 4](https://www.pgadmin.org/), installed from PyPI | PostgreSQL License | The pgAdmin Development Team | `/opt/pgadmin/app/LICENSE` |
| Python packages pgAdmin depends on, and `gunicorn` (MIT) | various | their respective authors | `*.dist-info/` under `/opt/pgadmin/lib/python3*/site-packages/`, or `/usr/share/doc/peo4c/pgadmin-python-licenses/` for packages whose wheel lacks a license file |
| Python packages pgAdmin depends on, and `gunicorn` (MIT) | various | their respective authors | `*.dist-info/` under `/opt/pgadmin/lib/python3*/site-packages/`, or `/usr/share/doc/peo/pgadmin-python-licenses/` for packages whose wheel lacks a license file |
## Source code
- **neo4j-browser** is licensed under the GPL, which requires the complete
corresponding source to accompany the built application. The image contains
it as `/usr/share/doc/peo4c/neo4j-browser/neo4j-browser-source.tar.gz`, an
it as `/usr/share/doc/peo/neo4j-browser/neo4j-browser-source.tar.gz`, an
archive of the exact commit that was built. Its dependencies are pinned by
the `yarn.lock` inside that archive, and the build steps are in this
repository's `Dockerfile`.
- **peo4c** itself ships as Python source code in `/opt/peo4c/bridge/`. This
- **peo** itself ships as Python source code in `/opt/peo/bridge/`. This
repository has the rest.
- **Debian packages:** source is available from
[sources.debian.org](https://sources.debian.org/) and the Debian archive.
## Interoperability
The peo4c bridge implements the Bolt protocol and PackStream format from their
The peo bridge implements the Bolt protocol and PackStream format from their
public specifications so that unmodified Neo4j clients can connect. It contains
no source code from Neo4j. To work with those clients it reports itself as
"Neo4j" with a Neo4j version number, and it recognises the exact query strings
@@ -46,5 +46,5 @@ strings to check compatibility.
Neo4j is a trademark of Neo4j, Inc. PostgreSQL is a trademark of the PostgreSQL
Community Association. Apache, Apache AGE and the Apache feather logo are
trademarks of The Apache Software Foundation. pgAdmin is a trademark of the
pgAdmin Development Team. peo4c is not affiliated with or endorsed by any of
pgAdmin Development Team. peo is not affiliated with or endorsed by any of
them; the names are used only to identify the software it works with.
File renamed without changes.
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""peo4c bridge: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
"""peo bridge: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
import asyncio
import functools
@@ -20,19 +20,19 @@ def env(name, default):
async def main():
logging.basicConfig(level=env("PEO4C_LOG_LEVEL", "INFO").upper(),
logging.basicConfig(level=env("PEO_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("peo4c")
log = logging.getLogger("peo")
bolt_port = int(env("PEO4C_BOLT_PORT", "7687"))
http_port = int(env("PEO4C_HTTP_PORT", "7474"))
advertised_port = env("PEO4C_ADVERTISED_BOLT_PORT", str(bolt_port))
bolt_port = int(env("PEO_BOLT_PORT", "7687"))
http_port = int(env("PEO_HTTP_PORT", "7474"))
advertised_port = env("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
config = Config(
pg_host=env("PEO4C_PG_HOST", "127.0.0.1"),
pg_port=int(env("PEO4C_PG_PORT", "5432")),
pg_database=env("PEO4C_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))),
default_graph=env("PEO4C_DEFAULT_GRAPH", "neo4j"),
advertised_bolt=f"{env('PEO4C_ADVERTISED_HOST', 'localhost')}:{advertised_port}",
pg_host=env("PEO_PG_HOST", "127.0.0.1"),
pg_port=int(env("PEO_PG_PORT", "5432")),
pg_database=env("PEO_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))),
default_graph=env("PEO_DEFAULT_GRAPH", "neo4j"),
advertised_bolt=f"{env('PEO_ADVERTISED_HOST', 'localhost')}:{advertised_port}",
)
bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port)
@@ -40,23 +40,23 @@ async def main():
log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port,
config.pg_host, config.pg_port, config.pg_database, config.default_graph)
browser_dir = env("PEO4C_BROWSER_DIR", "")
browser_dir = env("PEO_BROWSER_DIR", "")
if browser_dir:
http = HttpServer(browser_dir, bolt_port)
servers.append(await asyncio.start_server(http.handle, "0.0.0.0", http_port))
log.info("neo4j-browser on http://localhost:%d/browser/", http_port)
tasks = [s.serve_forever() for s in servers]
if env("PEO4C_PROPERTY_COLUMNS", "false") == "true":
if env("PEO_PROPERTY_COLUMNS", "false") == "true":
conninfo = make_conninfo(
host=env("PEO4C_PROPERTY_COLUMNS_PG_HOST", "/var/run/postgresql"),
host=env("PEO_PROPERTY_COLUMNS_PG_HOST", "/var/run/postgresql"),
port=config.pg_port,
dbname=config.pg_database,
user=env("PEO4C_PROPERTY_COLUMNS_PG_USER", env("POSTGRES_USER", "postgres")),
application_name="peo4c property columns",
user=env("PEO_PROPERTY_COLUMNS_PG_USER", env("POSTGRES_USER", "postgres")),
application_name="peo property columns",
)
tasks.append(property_columns.sync_forever(
conninfo, float(env("PEO4C_PROPERTY_COLUMNS_INTERVAL", "5"))))
conninfo, float(env("PEO_PROPERTY_COLUMNS_INTERVAL", "5"))))
await asyncio.gather(*tasks)
File renamed without changes.
@@ -21,14 +21,14 @@ from .cypher import (CypherError, expand_id_in_lists, first_keyword, inline_para
return_columns, rewrite_for_age, strip_statement)
from .packstream import Node, Relationship
log = logging.getLogger("peo4c.backend")
log = logging.getLogger("peo.backend")
NEO4J_VERSION = "5.26.0"
SYSTEM_DB = "system"
SYSTEM_LABELS = ("_ag_label_vertex", "_ag_label_edge")
GRAPH_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{2,62}$")
# Procedures emulated by peo4c, reported through SHOW PROCEDURES.
# Procedures emulated by peo, reported through SHOW PROCEDURES.
PROCEDURES = [
("db.labels", "List all node labels in the graph.", "db.labels() :: (label :: STRING)"),
("db.relationshipTypes", "List all relationship types in the graph.",
@@ -123,7 +123,7 @@ class Session:
self.conn = await psycopg.AsyncConnection.connect(
host=self.config.pg_host, port=self.config.pg_port, dbname=self.config.pg_database,
user=self.user, password=password, autocommit=True,
application_name="peo4c")
application_name="peo")
except psycopg.OperationalError as e:
msg = str(e)
if "password authentication failed" in msg or "role" in msg and "does not exist" in msg:
@@ -201,7 +201,7 @@ class Session:
if db == SYSTEM_DB:
raise BoltFailure("Neo.ClientError.Statement.NotSystemDatabaseCommand",
"This is an administration command and it should be executed "
"against the system database: peo4c only supports a few of them.")
"against the system database: peo only supports a few of them.")
result = await self._run_cypher(query, params or {}, db)
except CypherError as e:
raise BoltFailure(e.code, e.message) from e
@@ -217,9 +217,9 @@ class Session:
f"{keyword} is not supported by Apache AGE.")
text = expand_id_in_lists(inline_parameters(rewrite_for_age(text), params))
columns = return_columns(text)
tag = "peo4c"
tag = "peo"
while f"${tag}$" in text:
tag = "peo4c_" + secrets.token_hex(4)
tag = "peo_" + secrets.token_hex(4)
col_defs = ", ".join(f"c{i} ag_catalog.agtype" for i in range(max(len(columns), 1)))
stmt = (f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"${tag}$ {text} ${tag}$) AS ({col_defs})")
@@ -309,7 +309,7 @@ class Session:
if m:
raise BoltFailure("Neo.ClientError.Procedure.ProcedureNotFound",
f"There is no procedure with the name `{m.group(0)[5:]}` registered "
"for this database instance (peo4c).")
"for this database instance (peo).")
return None
async def _show_databases(self, special, name):
@@ -337,9 +337,9 @@ class Session:
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
f"Invalid graph name '{name}': AGE graph names must be 3-63 characters "
"of letters, digits, '_', '.' or '-', starting with a letter or '_'.")
if name == "peo4c":
if name == "peo":
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
"The name 'peo4c' is reserved: it is the schema of peo4c's own SQL helpers.")
"The name 'peo' is reserved: it is the schema of peo's own SQL helpers.")
if name in await self.graphs():
if if_not_exists:
return Result([], query_type="s")
@@ -369,7 +369,7 @@ class Session:
async def _cypher_values(self, graph, query):
rows = await self._sql(
f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"$peo4c$ {query} $peo4c$) AS (c0 ag_catalog.agtype)")
f"$peo$ {query} $peo$) AS (c0 ag_catalog.agtype)")
return [agtype.parse(r[0]) for r in rows]
async def _property_keys(self, graph):
+5 -5
View File
@@ -18,7 +18,7 @@ from .backend import BoltFailure, Config, Session
from .packstream import Structure
from .web import discovery_document
log = logging.getLogger("peo4c.bolt")
log = logging.getLogger("peo.bolt")
MAGIC = b"\x60\x60\xB0\x17"
SUPPORTED = [(5, 4), (5, 3), (5, 2), (5, 1), (5, 0), (4, 4)]
@@ -248,7 +248,7 @@ class BoltConnection:
except Exception as e: # noqa: BLE001 - report anything to the client
log.exception("%s: internal error", self.id)
self.results.clear()
await self.failure("Neo.DatabaseError.General.UnknownError", f"peo4c internal error: {e}")
await self.failure("Neo.DatabaseError.General.UnknownError", f"peo internal error: {e}")
return True
async def dispatch(self, tag, f):
@@ -281,7 +281,7 @@ class BoltConnection:
await self.success()
elif tag == COMMIT:
await self.require_session().commit()
await self.success({"bookmark": "peo4c:bookmark"})
await self.success({"bookmark": "peo:bookmark"})
elif tag == ROLLBACK:
await self.require_session().rollback()
await self.success()
@@ -296,7 +296,7 @@ class BoltConnection:
async def logon(self, auth):
if auth.get("scheme") != "basic":
raise BoltFailure("Neo.ClientError.Security.Unauthorized",
"peo4c requires basic authentication with a PostgreSQL user name and password.")
"peo requires basic authentication with a PostgreSQL user name and password.")
session = Session(self.config, auth.get("principal", ""))
await session.connect(auth.get("credentials", ""))
self.session = session
@@ -345,7 +345,7 @@ class BoltConnection:
del self.results[qid]
metadata = {"type": result.query_type, "t_last": 0, "db": result.db}
if not self.session.in_transaction:
metadata["bookmark"] = "peo4c:bookmark"
metadata["bookmark"] = "peo:bookmark"
await self.success(metadata)
@@ -112,7 +112,7 @@ def return_columns(text: str) -> list:
expr = text[start:end].strip()
if expr == "*":
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
"RETURN * is not supported by peo4c; list the returned variables explicitly.")
"RETURN * is not supported by peo; list the returned variables explicitly.")
alias = None
for a_start, a_end in _top_level(s, r"(?<![\w.$])AS\s+(`[^`]*`|\w+)\s*$", start, end):
alias = re.match(r"AS\s+(.*)", text[a_start:a_end].strip(), re.IGNORECASE | re.DOTALL).group(1)
@@ -195,7 +195,7 @@ def literal(v) -> str:
return "{" + ", ".join(
"`" + str(k).replace("`", "``") + "`: " + literal(x) for k, x in v.items()) + "}"
raise CypherError("Neo.ClientError.Statement.TypeError",
f"Parameters of type {type(v).__name__} are not supported by peo4c")
f"Parameters of type {type(v).__name__} are not supported by peo")
# neo4j-browser's "expand node" query uses a pattern comprehension, which AGE
@@ -207,8 +207,8 @@ _NEIGHBOUR_COUNT = re.compile(
def rewrite_for_age(text: str) -> str:
return _NEIGHBOUR_COUNT.sub(
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo4c_r]-() "
f"WITH {m.group(1)}, count(peo4c_r) AS {m.group(2)}",
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo_r]-() "
f"WITH {m.group(1)}, count(peo_r) AS {m.group(2)}",
text)
File renamed without changes.
@@ -3,7 +3,7 @@
"""Keeps generated property columns on AGE label tables up to date.
Installs the procedures in ``property_columns.sql`` and calls
``peo4c.sync_property_columns()`` every ``interval`` seconds. This needs a
``peo.sync_property_columns()`` every ``interval`` seconds. This needs a
connection that may alter every graph's tables, so it uses its own
(superuser) connection rather than the credentials of Bolt users.
"""
@@ -14,7 +14,7 @@ from pathlib import Path
import psycopg
log = logging.getLogger("peo4c.property_columns")
log = logging.getLogger("peo.property_columns")
INSTALL_SQL = (Path(__file__).parent / "property_columns.sql").read_text()
@@ -34,7 +34,7 @@ async def sync_forever(conninfo, interval):
log.info("syncing property columns every %gs", interval)
error = None
while True:
await conn.execute("CALL peo4c.sync_property_columns()")
await conn.execute("CALL peo.sync_property_columns()")
await asyncio.sleep(interval)
except psycopg.Error as e:
# PostgreSQL is still starting or restarted, or AGE is not
@@ -13,7 +13,7 @@
--
-- AGE's Cypher writes bypass the executor and fire no triggers, row or
-- statement level, so new keys cannot be caught by a trigger. Instead
-- CALL peo4c.sync_property_columns() looks for them; the peo4c bridge calls
-- CALL peo.sync_property_columns() looks for them; the peo bridge calls
-- it every few seconds. It only scans label tables that saw inserts or updates
-- since its previous run, according to the statistics collector, and commits
-- after each table so a table rewrite never holds locks on other tables.
@@ -22,9 +22,9 @@
-- Columns are text, like ->> on json, because a property can hold values of
-- different types in different rows and a failing cast would make writes fail.
CREATE SCHEMA IF NOT EXISTS peo4c;
CREATE SCHEMA IF NOT EXISTS peo;
CREATE TABLE IF NOT EXISTS peo4c.property_column_state (
CREATE TABLE IF NOT EXISTS peo.property_column_state (
relid oid PRIMARY KEY,
changes bigint NOT NULL -- inserts + updates seen when the table was last scanned
);
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS peo4c.property_column_state (
-- that name yet, and returns the keys it added. Keys that are empty or longer
-- than PostgreSQL's 63-byte identifier limit are skipped, as are keys that
-- clash with an existing column such as id, start_id or end_id.
CREATE OR REPLACE FUNCTION peo4c.add_property_columns(tbl regclass)
CREATE OR REPLACE FUNCTION peo.add_property_columns(tbl regclass)
RETURNS SETOF text LANGUAGE plpgsql AS $$
DECLARE
new_keys text[];
@@ -63,13 +63,13 @@ BEGIN
END
$$;
CREATE OR REPLACE PROCEDURE peo4c.sync_property_columns()
CREATE OR REPLACE PROCEDURE peo.sync_property_columns()
LANGUAGE plpgsql AS $$
DECLARE
t record;
added text[];
BEGIN
DELETE FROM peo4c.property_column_state s
DELETE FROM peo.property_column_state s
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_class WHERE oid = s.relid);
FOR t IN
@@ -77,7 +77,7 @@ BEGIN
FROM ag_catalog.ag_label AS l
CROSS JOIN LATERAL (SELECT pg_stat_get_tuples_inserted(l.relation)
+ pg_stat_get_tuples_updated(l.relation) AS changes) AS c
LEFT JOIN peo4c.property_column_state AS s ON s.relid = l.relation
LEFT JOIN peo.property_column_state AS s ON s.relid = l.relation
WHERE l.name NOT IN ('_ag_label_vertex', '_ag_label_edge')
AND s.changes IS DISTINCT FROM c.changes
LOOP
@@ -85,10 +85,10 @@ BEGIN
-- Adding a column rewrites the table under an exclusive lock;
-- rather than queue behind a long transaction, retry next run.
PERFORM set_config('lock_timeout', '2s', true);
SELECT array_agg(key) INTO added FROM peo4c.add_property_columns(t.tbl) AS key;
SELECT array_agg(key) INTO added FROM peo.add_property_columns(t.tbl) AS key;
-- The rewrite counts as inserts in this transaction; include them
-- so the table is not scanned again for its own rewrite.
INSERT INTO peo4c.property_column_state AS s
INSERT INTO peo.property_column_state AS s
VALUES (t.tbl, t.changes + pg_stat_get_xact_tuples_inserted(t.tbl)
+ pg_stat_get_xact_tuples_updated(t.tbl))
ON CONFLICT (relid) DO UPDATE SET changes = excluded.changes;
+2 -2
View File
@@ -12,7 +12,7 @@ from urllib.parse import unquote, urlsplit
from .backend import NEO4J_VERSION
log = logging.getLogger("peo4c.http")
log = logging.getLogger("peo.http")
class HttpServer:
@@ -77,7 +77,7 @@ def discovery_document(headers, bolt_port) -> bytes:
host = host[:host.index("]") + 1]
else:
host = host.split(":", 1)[0]
port = os.environ.get("PEO4C_ADVERTISED_BOLT_PORT", str(bolt_port))
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
return json.dumps({
"bolt_routing": f"neo4j://{host}:{port}",
"bolt_direct": f"bolt://{host}:{port}",
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
# Prepares a freshly initialised cluster for peo4c.
# Prepares a freshly initialised cluster for peo.
set -euo pipefail
# The bridge connects over 127.0.0.1 with the credentials users log in with, so
@@ -12,7 +12,7 @@ sed -i -E 's/^(host[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]
# Create the default AGE graph that neo4j-browser opens on first connect.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_DB:-$POSTGRES_USER}" \
-v graph="${PEO4C_DEFAULT_GRAPH:-neo4j}" <<'EOSQL'
-v graph="${PEO_DEFAULT_GRAPH:-neo4j}" <<'EOSQL'
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
SELECT ag_catalog.create_graph(:'graph')
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
# Runs PostgreSQL (through the stock postgres entrypoint), the peo4c bridge and
# Runs PostgreSQL (through the stock postgres entrypoint), the peo bridge and
# pgAdmin side by side. The container stops when PostgreSQL or the bridge exits;
# pgAdmin is optional and its failure does not take the others down.
set -euo pipefail
@@ -10,7 +10,7 @@ if [ "${1:-}" != "postgres" ]; then
exec docker-entrypoint.sh "$@"
fi
export PEO4C_PG_DATABASE="${PEO4C_PG_DATABASE:-${POSTGRES_DB:-${POSTGRES_USER:-postgres}}}"
export PEO_PG_DATABASE="${PEO_PG_DATABASE:-${POSTGRES_DB:-${POSTGRES_USER:-postgres}}}"
start_pgadmin() {
local app=/opt/pgadmin/app
@@ -19,8 +19,8 @@ start_pgadmin() {
if [ ! -f /var/lib/pgadmin/pgadmin4.db ]; then
# First start: create the login user and register the bundled PostgreSQL.
cat > /var/lib/pgadmin/servers.json <<EOF
{"Servers": {"1": {"Name": "peo4c", "Group": "Servers", "Host": "127.0.0.1", "Port": 5432,
"MaintenanceDB": "${PEO4C_PG_DATABASE}", "Username": "${POSTGRES_USER:-postgres}", "SSLMode": "prefer"}}}
{"Servers": {"1": {"Name": "peo", "Group": "Servers", "Host": "127.0.0.1", "Port": 5432,
"MaintenanceDB": "${PEO_PG_DATABASE}", "Username": "${POSTGRES_USER:-postgres}", "SSLMode": "prefer"}}}
EOF
chown postgres:postgres /var/lib/pgadmin/servers.json
PGADMIN_SETUP_EMAIL="$email" \
@@ -31,22 +31,22 @@ EOF
|| { rm -f /var/lib/pgadmin/pgadmin4.db; return 1; }
fi
gosu postgres /opt/pgadmin/bin/gunicorn --bind "0.0.0.0:${PEO4C_PGADMIN_PORT}" \
gosu postgres /opt/pgadmin/bin/gunicorn --bind "0.0.0.0:${PEO_PGADMIN_PORT}" \
--workers 1 --threads 25 --timeout 0 --chdir "$app" --log-level warning pgAdmin4:app &
pgadmin_pid=$!
echo "peo4c: pgAdmin on http://localhost:${PEO4C_PGADMIN_PORT}/ (login: $email)"
echo "peo: pgAdmin on http://localhost:${PEO_PGADMIN_PORT}/ (login: $email)"
}
docker-entrypoint.sh "$@" &
pg_pid=$!
cd /opt/peo4c/bridge
gosu postgres python3 -m peo4c &
cd /opt/peo/bridge
gosu postgres python3 -m peo &
bridge_pid=$!
pgadmin_pid=
if [ "${PEO4C_PGADMIN}" = "true" ]; then
start_pgadmin || echo "peo4c: WARNING: pgAdmin setup failed, continuing without it" >&2
if [ "${PEO_PGADMIN}" = "true" ]; then
start_pgadmin || echo "peo: WARNING: pgAdmin setup failed, continuing without it" >&2
fi
shutdown() {
+1 -1
View File
@@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
# pgAdmin settings for the peo4c image, loaded via CONFIG_DISTRO_FILE_PATH.
# pgAdmin settings for the peo image, loaded via CONFIG_DISTRO_FILE_PATH.
# Paths derived from DATA_DIR in pgAdmin's config.py are computed before this
# file is applied, so they are all set explicitly here.
+4 -4
View File
@@ -1,10 +1,10 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""End-to-end smoke test for a running peo4c bridge, using the official Neo4j driver.
"""End-to-end smoke test for a running peo bridge, using the official Neo4j driver.
Usage: python tests/smoke_test.py [bolt-uri] [user] [password]
Requires: pip install neo4j
Set PEO4C_SMOKE_PG_DSN (e.g. "host=localhost dbname=postgres") to also check the
Set PEO_SMOKE_PG_DSN (e.g. "host=localhost dbname=postgres") to also check the
generated property columns over SQL; that needs psycopg.
"""
@@ -18,7 +18,7 @@ from neo4j.graph import Node, Path, Relationship
uri = sys.argv[1] if len(sys.argv) > 1 else "neo4j://localhost:7687"
user = sys.argv[2] if len(sys.argv) > 2 else "postgres"
password = sys.argv[3] if len(sys.argv) > 3 else "postgres"
GRAPH = "peo4c_smoke"
GRAPH = "peo_smoke"
def check(label, condition):
@@ -121,7 +121,7 @@ MATCH ()-[]->() RETURN { name:'relationships', data: count(*)} AS result
check("managed write transaction", s.execute_write(work) == 1)
dsn = os.environ.get("PEO4C_SMOKE_PG_DSN")
dsn = os.environ.get("PEO_SMOKE_PG_DSN")
if dsn:
import psycopg