Das Problem
Ein BI-Werkzeug spricht meist mit genau einer Datenbank. Wünsche, das zu ändern, gehören im Tracker von Metabase zu den beliebtesten: Joins über mehrere Datenbanken haben 2110 Reaktionen und sind seit 2016 offen, ein REST-API-Treiber hat 889. Die Anleitung von Metabase nennt als Umwege ein Data Warehouse, Foreign Data Wrappers, Views und föderierte Query-Engines. Das funktioniert, verlangt aber einen Trino-Cluster oder einen PostgreSQL-Server, den man ändern darf, dazu Einrichtung pro Quelle.
Auch Projekte, die ein PostgreSQL-Protokoll vor DuckDB setzen, gibt es. PostHog hat duckgres im Oktober 2026 eingestellt, buenavista wurde seit Dezember 2024 nicht mehr geändert. Ich wollte den kleinen Fall abdecken: ein Prozess, den ein Team ohne Datenplattform neben Metabase startet.
Wie es arbeitet
pgfederate liest eine YAML-Datei. Jede
Quelle wird zu einem Schema: PostgreSQL, MySQL und SQLite werden über die
Erweiterungen von DuckDB angebunden, Dateien werden bei der Abfrage gelesen, und
JSON-APIs werden nach Zeitplan geladen, mit Paginierung, Wiederholungen und dem
Auflösen verschachtelter Objekte. Ein Eintrag unter relations erscheint bei
Clients als Fremdschlüssel, sodass der Query Builder von Metabase Joins über
Quellen hinweg ohne SQL anbietet.
Drei Teile machten die meiste Arbeit:
- Der Server implementiert das PostgreSQL-Protokoll 3: SCRAM-Login, Prepared Statements, Portale, Abbruchanfragen und Ergebnisse im Text- und Binärformat.
- Clients finden Tabellen über
pg_catalogundinformation_schema. Die eigenen Views von DuckDB beschreiben DuckDB, deshalb führt pgfederate separate Tabellen, die eine Datenbank mit einem Schema pro Quelle zeigen, und baut sie nach jeder Aktualisierung neu. - Jede Anweisung wird vor der Ausführung mit sqlglot geparst. Nur Lesezugriffe auf
freigegebene Tabellen kommen durch, DuckDB-Funktionen wie
read_csvundquerywerden abgelehnt, und DuckDB selbst läuft mit gesperrtem externem Zugriff.
Beispiel
Der Schnellstart verbindet eine SQLite-Datenbank und eine CSV-Datei über psql:
pgfederate=> select c.name, sum(i.amount) as billed
from shop.customers c join billing.invoices i on i.customer_id = c.id
group by c.name order by billed desc;
name | billed
--------+--------
Acme | 30.5
Globex | 5.25
(2 rows)
Das ist die Ausgabe des End-to-End-Laufs in der CI, in dem das echte Metabase über seinen PostgreSQL-Treiber mit dem Gateway spricht:
ok: metabase synced every source
ok: metabase sees the cross-source foreign key
ok: native query joins Postgres, MySQL and REST: [['Acme', 53.5, 2], ['Globex', 50.0, 1], ['Initech', 9.5, 0]]
ok: query builder join across MySQL and Postgres: {'Acme': 4, 'Globex': 2, 'Initech': 1}
ok: month bucketing: [['2026-03-01T00:00:00Z', 2], ['2026-04-01T00:00:00Z', 2]]
ok: filter with a bound parameter
Wie es getestet ist
Die Testsuite lässt psycopg im Text- und Binärmodus, asyncpg und psql gegen einen
echten Server laufen und spielt die Katalogabfragen von Metabase, pgjdbc und
Grafana ab, die ich aus deren Quellcode übernommen habe. Etwa 35 Tests versuchen
Schreibzugriffe und Ausbrüche, zum Beispiel das direkte Lesen von Dateien oder
angebundenen Datenbanken. PostgreSQL- und MySQL-Quellen laufen in der CI gegen
echte Server, einschließlich einer Prüfung, dass Filter im Scan-Plan stehen. Ein
zweiter CI-Job startet einen Compose-Stack und steuert Metabase und Grafana über
ihre HTTP-APIs.
Der erste CI-Lauf fand keinen Fehler im Gateway, sondern nur einen in meiner
Testabfrage, die Zeilen durch einen Join vervielfachte. Ein lokaler Lauf mit
breiteren Abfragen fand dann einen echten: substring(x from 'muster') ist eine
Regex-Suche, und Metabase bindet das Muster als Parameter. Rund 80 Fälle aus dem
Query Builder laufen jetzt in der CI.
Grenzen
pgfederate ist nur lesend und läuft in einem Prozess, ersetzt also Trino bei großen
Datenmengen nicht. Filter werden an PostgreSQL und MySQL weitergereicht,
Aggregationen nicht, und ein Join über Quellen hinweg holt die benötigten Zeilen
nach DuckDB. REST-Daten sind ein Stand der letzten Aktualisierung. SQL, das DuckDB
nicht ausführen kann, schlägt fehl, auch wenn es gültiges PostgreSQL ist, und die
Ausdrücke host und domain von Metabase scheitern, weil sie Regex-Lookaround
nutzen. DECLARE und FETCH werden abgelehnt, benannte Cursor in psycopg funktionieren also nicht.
Quoten pro Nutzer gibt es nicht. Getestet sind nur die oben genannten Clients.
Der Code liegt unter github.com/Jwrede/pgfederate.