diff --git a/Dockerfile b/Dockerfile index 9504ad3..d914c1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index bf8ed1f..96e6102 100644 --- a/README.md +++ b/README.md @@ -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: | `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 ` 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('', $$ $$) 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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0386a72..546cfd5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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//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. diff --git a/bridge/peo4c/__init__.py b/bridge/peo/__init__.py similarity index 100% rename from bridge/peo4c/__init__.py rename to bridge/peo/__init__.py diff --git a/bridge/peo4c/__main__.py b/bridge/peo/__main__.py similarity index 55% rename from bridge/peo4c/__main__.py rename to bridge/peo/__main__.py index 8f5bd0a..047ae8b 100644 --- a/bridge/peo4c/__main__.py +++ b/bridge/peo/__main__.py @@ -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) diff --git a/bridge/peo4c/agtype.py b/bridge/peo/agtype.py similarity index 100% rename from bridge/peo4c/agtype.py rename to bridge/peo/agtype.py diff --git a/bridge/peo4c/backend.py b/bridge/peo/backend.py similarity index 97% rename from bridge/peo4c/backend.py rename to bridge/peo/backend.py index d1f82a0..12b9bfb 100644 --- a/bridge/peo4c/backend.py +++ b/bridge/peo/backend.py @@ -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): diff --git a/bridge/peo4c/bolt.py b/bridge/peo/bolt.py similarity index 97% rename from bridge/peo4c/bolt.py rename to bridge/peo/bolt.py index f85d54a..310644f 100644 --- a/bridge/peo4c/bolt.py +++ b/bridge/peo/bolt.py @@ -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) diff --git a/bridge/peo4c/cypher.py b/bridge/peo/cypher.py similarity index 96% rename from bridge/peo4c/cypher.py rename to bridge/peo/cypher.py index 2ce200b..80e8c4d 100644 --- a/bridge/peo4c/cypher.py +++ b/bridge/peo/cypher.py @@ -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"(? 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) diff --git a/bridge/peo4c/packstream.py b/bridge/peo/packstream.py similarity index 100% rename from bridge/peo4c/packstream.py rename to bridge/peo/packstream.py diff --git a/bridge/peo4c/property_columns.py b/bridge/peo/property_columns.py similarity index 89% rename from bridge/peo4c/property_columns.py rename to bridge/peo/property_columns.py index d65098e..81a2857 100644 --- a/bridge/peo4c/property_columns.py +++ b/bridge/peo/property_columns.py @@ -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 diff --git a/bridge/peo4c/property_columns.sql b/bridge/peo/property_columns.sql similarity index 87% rename from bridge/peo4c/property_columns.sql rename to bridge/peo/property_columns.sql index 512643e..c8ee77f 100644 --- a/bridge/peo4c/property_columns.sql +++ b/bridge/peo/property_columns.sql @@ -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; diff --git a/bridge/peo4c/web.py b/bridge/peo/web.py similarity index 96% rename from bridge/peo4c/web.py rename to bridge/peo/web.py index 345db08..0762a7b 100644 --- a/bridge/peo4c/web.py +++ b/bridge/peo/web.py @@ -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}", diff --git a/docker/initdb/10-peo4c.sh b/docker/initdb/10-peo.sh similarity index 89% rename from docker/initdb/10-peo4c.sh rename to docker/initdb/10-peo.sh index 6008b7a..0e4ccb9 100755 --- a/docker/initdb/10-peo4c.sh +++ b/docker/initdb/10-peo.sh @@ -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') diff --git a/docker/peo4c-entrypoint.sh b/docker/peo-entrypoint.sh similarity index 74% rename from docker/peo4c-entrypoint.sh rename to docker/peo-entrypoint.sh index 5bbe82a..e5a5835 100755 --- a/docker/peo4c-entrypoint.sh +++ b/docker/peo-entrypoint.sh @@ -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 <&2 +if [ "${PEO_PGADMIN}" = "true" ]; then + start_pgadmin || echo "peo: WARNING: pgAdmin setup failed, continuing without it" >&2 fi shutdown() { diff --git a/docker/pgadmin/config_distro.py b/docker/pgadmin/config_distro.py index b56a2b1..82f0912 100644 --- a/docker/pgadmin/config_distro.py +++ b/docker/pgadmin/config_distro.py @@ -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. diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 191ae80..6f22bae 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -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