Mit Vibe-Coding schiessen neue INTERLIS-Werkzeuge (more to come…​) wie Pilze aus dem Boden. Vielleicht sollte man es nicht allzu sehr verteufeln und LLM sind am Ende noch die Rettung für das INTERLIS-Ökosystem. We’ll see. Und manche Dinge macht man einfach auch nur weil sie technisch machbar sind und vielleicht erst später einen wirklichen Nutzen bringen. So wie das hier:

Ganz ohne ein mögliches Einsatzszenario im Kopf zu haben, ist dieses Experiment trotzdem nicht entstanden. Wir stellen unsere Konfigurationsmaschinerie komplett auf file-basiert um (in 5-10 Jahren schreibe ich sicher das Gegenteil). Also kein GUI mehr, wo wir definieren, welche Datenbanktabelle zu welchem WMS-Layer wird etc. Zuerst wollten wir das tatsächlich von Hand mit INTERLIS schreiben. Nun ja…​ Wir haben gemerkt, dass XML halt XML bleibt und es schnell mühsam wird und sind - Überraschung - umgeschwenkt auf JSON. Der nächste Schritt, nach dem manuell Editieren der Konfigurationsdateien, sieht folgendes vor: Wir verarbeiten die JSON-Dateien mit DuckDB. D.h. wir bauen um, validieren den Content und exportieren die Daten in gewünschte Formate und Strukturen für die verschiedenen Anwendungen. DuckDB hat sehr mächtige JSON-Funktionen und kombiniert mit ili2duckdb kann man nette Dinge anstellen. Man kann z.B. direkt JSON-Dateien einlesen (inkl. Globbing) und die Daten in ein mit ili2duckdb vorbereitetes Schema kopieren und in eine valide XTF-Datei exportieren. Piece of cake.

Schön wäre - blieben wir bei der Idee alles mit XTF-Dateien zu machen - wenn wir wie mit XTF gleich wie mit den JSON-Dateien arbeiten könnten. Oder zumindest ähnlich. Die Herausforderung von INTERLIS-XTF ist, dass eine einzige Funktion SELECT * FROM read_xtf('data.xtf'); der Komplexität Mächtigkeit von INTERLIS nicht gerecht wird. Eine XTF-Datei ist keine einfache Tabelle. Sie enthält Baskets, Objekte verschiedener Klassen, Strukturen, Referenzen und Assoziationen.

Eine DuckDB-Extension soll verschiedene Sichten auf die Daten anbieten. Für eine konkrete Klasse könnte beispielsweise eine typisierte Tabelle zurückgegeben werden:

SELECT
  *
FROM
  read_xtf_class(
    'data.xtf',
    'MyModel.MyTopic.Building'
);

Das Resultat verhält sich anschliessend wie jede andere DuckDB-Tabelle.

SELECT
    municipality,
    count(*) AS buildings
FROM read_xtf_class(
    'data.xtf',
    'MyModel.MyTopic.Building'
)
GROUP BY municipality
ORDER BY buildings DESC;

Daneben können generischere Funktionen Zugriff auf die technische Struktur einer Transferdatei geben. Denkbar sind beispielsweise:

SELECT * FROM read_xtf_structures('data.xtf', ...);

SELECT * FROM read_xtf_objects('data.xtf', ...);

SELECT * FROM read_xtf_association('data.xtf', ...);

Noch schöner und eleganter wäre natürlich sowas:

ATTACH 'data.xtf' AS xtf (TYPE INTERLIS);

Danach könnten die enthaltenen Klassen direkt angesprochen werden:

SELECT
  *
FROM
  xtf.MyModel_MyTopic_Building;

Soweit ich es verstehe, funktioniert das allenfalls noch nicht wirklich mit dem Plugin Ansatz über die C-API. Aber man müsste es ausprobieren.

Wie installiere ich das Plugin nun? Das ist minimal umständlich, da es weder im Core-Repo ist noch im offiziellen 3rd Party Repo vorhanden ist. Das 3rd Party Repo wäre genau der Ort, um es zu publizieren. Aber leider bedingt das genau einen vorgegeben Weg wie es entstehen muss und das ist bei uns im Mix mit den Java-Tools nicht möglich (später mehr).

Das Plugin funktioniert mit DuckDB Version 1.5.3. Genauer gesagt: Funktionieren würde es mit vielen anderen Versionen aber mein Plugin-Repo bietet es nur für Version 1.5.3 an. Ich müsste noch den Reverse Proxy so konfigurieren, dass er in einem bestimmten Range immer auf die gleiche Datei zeigt. Für macOS kann man diesen Link zum Herunterladen der DuckDB Version 1.5.3 verwenden.

DuckDB muss wie folgt gestartet werden:

./duckdb -unsigned

Und danach:

INSTALL interlis FROM 'https://duckdb-ext.interlis.guru';
LOAD interlis;

Mit einer Test-Query schauen, ob es funktioniert hat:

SELECT ili_extension_version();

Die Antwort sollte folgendes sein:

memory D SELECT ili_extension_version();
┌─────────────────────────┐
│ ili_extension_version() │
│         varchar         │
├─────────────────────────┤
│ 0.1.0-dev               │
└─────────────────────────┘
memory D

Will ich Informationen über alle Klassen in einem Modell:

SELECT
  topic_name, class_name, is_abstract, is_extended
FROM
  ili_classes('SO_ARP_SEin_Konfiguration_20250115');

Die Query liefert folgendes Resultat:

┌────────────┬────────────┬─────────────┬─────────────┐
│ topic_name │ class_name │ is_abstract │ is_extended │
│  varchar   │  varchar   │   varchar   │   varchar   │
├────────────┼────────────┼─────────────┼─────────────┤
│ Auswertung │ Thema      │ false       │ true        │
│ Auswertung │ Gruppe     │ false       │ true        │
│ Auswertung │ Gemeinde   │ false       │ true        │
│ Grundlagen │ Gemeinde   │ false       │ true        │
│ Grundlagen │ Gruppe     │ false       │ true        │
│ Grundlagen │ Objektinfo │ false       │ true        │
│ Grundlagen │ Thema      │ false       │ true        │
└────────────┴────────────┴─────────────┴─────────────┘

Jetzt möchte ich gerne die Klasse SO_AGI_AV_GB_Administrative_Einteilungen_Publikation_20180822.Grundbuchkreise.Grundbuchkreis aus einer INTERLIS-Transferdatei lesen:

SELECT
  *
FROM
  read_xtf_class('/Users/stefan/Downloads/ch.so.agi.av.administrative_einteilung.xtf',
    class := 'SO_AGI_AV_GB_Administrative_Einteilungen_Publikation_20180822.Grundbuchkreise.Grundbuchkreis');
;

Das ergibt folgendes (unbefriedigendes) Resultat:

┌────────────────────────────┬──────────────────────┬────────────────┬────────────────────┬───┬────────────────┬──────────────────────┬─────────────────┬──────────────────┐
│          xtf_bid           │       xtf_tid        │   xtf_class    │        Name        │ … │     Email      │         Web          │       UID       │ unsupported_json │
│          varchar           │       varchar        │    varchar     │      varchar       │ … │    varchar     │       varchar        │     varchar     │     varchar      │
├────────────────────────────┼──────────────────────┼────────────────┼────────────────────┼───┼────────────────┼──────────────────────┼─────────────────┼──────────────────┤
│ SO_AGI_AV_GB_Administrati… │ bc1c8598-da36-4552-… │ Grundbuchkreis │ Langendorf         │ … │ gb.so@fd.so.ch │ http://www.so.ch/ve… │ CHE-470.051.358 │ NULL             │
│ SO_AGI_AV_GB_Administrati… │ 259025b5-8463-41e7-… │ Grundbuchkreis │ Solothurn          │ … │ gb.so@fd.so.ch │ http://www.so.ch/ve… │ CHE-470.051.358 │ NULL             │
│ SO_AGI_AV_GB_Administrati… │ c10a8149-a03c-410d-… │ Grundbuchkreis │ Balsthal           │ … │ gb.tg@fd.so.ch │ http://www.so.ch/ve… │ CHE-202.378.999 │ NULL             │
│ SO_AGI_AV_GB_Administrati… │ 0fbf4001-16cb-4e07-… │ Grundbuchkreis │ Matzendorf         │ … │ gb.tg@fd.so.ch │ http://www.so.ch/ve… │ CHE-202.378.999 │ NULL             │
│ SO_AGI_AV_GB_Administrati… │ b8622212-6c42-4065-… │ Grundbuchkreis │ Egerkingen         │ … │ gb.tg@fd.so.ch │ http://www.so.ch/ve… │ CHE-202.378.999 │ NULL             │
│ SO_AGI_AV_GB_Administrati… │ fa4bc561-2df2-4fa3-… │ Grundbuchkreis │ Härkingen          │ … │ gb.tg@fd.so.ch │ http://www.so.ch/ve… │ CHE-202.378.999 │ NULL             │

Soweit nicht schlecht. Komisch ist unsupported_json und eigentlich sind wir ja wegen den Geometrien hier. Das Geometrie-Attribut im Modell heisst Perimeter. Geometrie-Attribute werden mit einem _geom-Suffix versehen. Das gibt es bei mir nicht, dafür aber Perimeter_json:

┌─────────────────────────────────┬───────────────────┐
│         Amtschreiberei          │  Perimeter_json   │
│             varchar             │      varchar      │
├─────────────────────────────────┼───────────────────┤
│ Amtschreiberei Region Solothurn │ {"Surfaces":null} │
│ Amtschreiberei Region Solothurn │ {"Surfaces":null} │
│ Amtschreiberei Thal-Gäu         │ {"Surfaces":null} │
│ Amtschreiberei Thal-Gäu         │ {"Surfaces":null} │
│ Amtschreiberei Thal-Gäu         │ {"Surfaces":null} │
│ Amtschreiberei Thal-Gäu         │ {"Surfaces":null} │

Das Problem ist der Umgang mit Strukturen und Listen. Und in meinem konkreten Fall mit INTERLIS 2.3. Es werden momentan nur die nativen INTERLIS 2.4 Geometrien unterstützt. Versuchen wir es mit einem Testdatensatz:

SELECT
  *
FROM
  read_xtf_class('/Users/stefan/sources/duckdb-interlis/testdata/synthetic/geometries/valid.xtf',
    class := 'SO_AGI_Geometries_20260605.Topic.MultiFlaechenObjekt',
    model_sources := '/Users/stefan/sources/duckdb-interlis/testdata/synthetic/geometries/SO_AGI_Geometries_20260605.ili');
;

Das sieht doch schon viel besser aus:

┌─────────┬─────────┬─────────────────────┬───────────┬─────────────────────────────────────────────────────────────────────────────────────────────────┬──────────────────┐
│ xtf_bid │ xtf_tid │      xtf_class      │   Name    │                                          Flaechen_geom                                          │ unsupported_json │
│ varchar │ varchar │       varchar       │  varchar  │                                            geometry                                             │     varchar      │
├─────────┼─────────┼─────────────────────┼───────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┼──────────────────┤
│ basket1 │ mf1     │ MultiFlaechenObjekt │ Parzellen │ MULTIPOLYGON (((2605000 1203000, 2605100 1203000, 2605100 1203100, 2605000 1203100, 2605000 120 │ NULL             │
│         │         │                     │           │ 3000)), ((2605200 1203000, 2605250 1203000, 2605250 1203050, 2605200 1203050, 2605200 1203000)) │                  │
│         │         │                     │           │ )                                                                                               │                  │
└─────────┴─────────┴─────────────────────┴───────────┴─────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────┘

Woher das unsupported_json kommt, weiss ich momentan echt nicht. Aber: es sind korrekte DuckDB-Geometrien!

Verlassen wir die Spielereien und wenden uns der Implementierung zu: Technisch besteht die Extension aus zwei sehr unterschiedlichen Welten. Die eigentliche INTERLIS-Logik bleibt in good old Java und verwendet die bestehenden Bibliotheken rund um ili2c, iox-ili und ilivalidator. Mit GraalVM Native Image wird dieser Java-Code jedoch nicht als JAR ausgeliefert, sondern als native Shared Library kompiliert – unter Linux beispielsweise als libduckdb_interlis_native.so, unter macOS als .dylib und unter Windows als .dll. Über mit GraalVM exportierte C-Einstiegspunkte erhält diese Library eine kleine, stabile C-API:

Java
  │
  │ GraalVM native-image --shared
  ▼
libduckdb_interlis_native.so
  │
  │ C ABI
  ▼
DuckDB C Extension
  │
  ▼
interlis.duckdb_extension

Die DuckDB Extension selbst ist in C implementiert und basiert bewusst auf der stabilen DuckDB C Extension API statt auf der internen C++-API von DuckDB und ist darum auch für unterschiedliche Versionen lauffähig ohen sie für jede Version zu kompilieren. Auch die Schnittstelle zur mit GraalVM erzeugten Native Library ist eine kleine, projektspezifische C-ABI. Vereinfacht sieht diese Grenze etwa so aus:

typedef struct ili_api_v1 {
  uint32_t struct_size;
  uint32_t abi_version;
  uint64_t capabilities;

  ili_status_code (*validate)(...);
  ili_status_code (*model_info)(...);
  ili_status_code (*read_xtf)(...);
  ili_status_code (*free_result)(...);
} ili_api_v1;

int ili_get_api(
  uint32_t requested_abi_version,
  ili_api_v1 *api
);

Damit kennt die DuckDB-Seite weder Java-Klassen noch JNI o.ä. Aus ihrer Sicht handelt es sich um eine gewöhnliche native Bibliothek mit einer C-Schnittstelle. Der ili_get_api()-Aufruf dient gleichzeitig als ABI-Handshake: Extension und Native-Library prüfen beim Start, ob sie tatsächlich zueinander passen, bevor die erste INTERLIS-Funktion ausgeführt wird.

Eine zusätzliche Herausforderung entsteht allerdings beim Deployment. Normalerweise müsste man in unserem Fall nämlich zwei Dateien ausliefern:

interlis.duckdb_extension
libduckdb_interlis_native.so

Das passt schlecht zum Extension-Modell von DuckDB. Aus Sicht des Anwenders soll eine Extension schliesslich einfach geladen werden können:

INSTALL interlis;
LOAD interlis;

und nicht zuerst noch eine zweite, plattformspezifische Shared Library irgendwo korrekt installiert werden müssen.

Deshalb machen wir an dieser Stelle etwas eher Ungewöhnliches: Die von GraalVM erzeugte Shared Library wird bereits beim Build als Binärblob in die interlis.duckdb_extension eingebettet. Das veröffentlichte Artefakt enthält damit faktisch beide Komponenten, obwohl nach aussen nur eine einzige Datei sichtbar ist:

interlis.duckdb_extension
┌─────────────────────────────────────┐
│ DuckDB C Extension                  │
│                                     │
│ embedded:                           │
│ libduckdb_interlis_native.so        │
│ ┌─────────────────────────────────┐ │
│ │ GraalVM Native Image            │ │
│ │                                 │ │
│ │ ili2c                           │ │
│ │ iox-ili                         │ │
│ │ ilivalidator                    │ │
│ │ INTERLIS Java Code              │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘

Eine Shared Library kann allerdings nicht ohne Weiteres direkt aus einem Speicherbereich mit dlopen() oder LoadLibrary() geladen werden. Beim ersten Laden der DuckDB Extension wird die eingebettete Library deshalb aus dem Extension-Binary extrahiert und in einen lokalen Cache geschrieben. Erst diese Datei wird anschliessend vom Betriebssystem als Shared Library geladen:

LOAD interlis
      │
      ▼
interlis.duckdb_extension
      │
      │ extract embedded native library
      ▼
~/.cache/.../libduckdb_interlis_native-<version>-<hash>.so
      │
      │ dlopen()
      ▼
GraalVM Native Image
      │
      │ create isolate
      ▼
INTERLIS Java Code

Dabei ist die Extraktion absichtlich etwas aufwendiger implementiert. Die eingebettete Library erhält bereits beim Build einen SHA-256-Hash; Version, ABI-Version, Plattform und Hash bestimmen den Cache-Eintrag. Eine neue Library wird zunächst in eine temporäre Datei geschrieben, überprüft und anschliessend atomar an ihren endgültigen Ort verschoben. Existiert bereits eine Datei mit dem erwarteten Hash, kann sie direkt wiederverwendet werden. Dadurch funktionieren auch mehrere DuckDB-Prozesse, die gleichzeitig dieselbe Extension laden, ohne sich gegenseitig eine teilweise geschriebene Shared Library unterzuschieben.

Der scheinbar etwas komplizierte Weg (Hallo? Es ist immer noch INTERLIS…​):

Java
  → GraalVM Native Image
    → eingebettete Shared Library
      → DuckDB Extension
        → Extraktion
          → dlopen()
            → C ABI
              → ursprünglicher Java-Code

hat einen entscheidenden Vorteil: Für den Anwender bleibt davon nichts sichtbar. DuckDB lädt genau eine plattformspezifische interlis.duckdb_extension. Dass darin eine komplette, mit GraalVM kompilierte Java-basierte INTERLIS-Laufzeit steckt, ist ein internes Implementierungsdetail. Damit können wir das umfangreiche und über Jahre gewachsene INTERLIS-Java-Ökosystem praktisch unverändert weiterverwenden und gleichzeitig nach aussen eine normale, (noch nicht so) einfach installierbare DuckDB Extension anbieten.

Es gibt natürlich noch einiges (oder eher vieles), das überhaupt nicht production-ready ist, z.B. werden alle Daten in den Speicher gelesen. Das funktioniert bei kleinen XTF sicher noch ganz gut aber bei sehr grossen XTF sicher nicht mehr. Oder ein Geometrie-Index wirkt meistens Wunder bei Queries. Zudem muss man das Logging noch anschauen, damit das Terminal nicht vollgespamt wird. Mein momentaner Lieblings-Bug: DuckDB verabschiedet sich, wenn ein Modell in einem Repo nicht gefunden wird.

Links: