🗂

duckdb-spatial は(まだ)複数ファイルを読み込めないので Rust でDuckDB 拡張をつくりました

に公開

duckdb-spatial の ST_Read は、単一のファイルしか読めません。メンテナの人によれば、DuckDB は複数のデータソースを扱うための仕組みがあり、duckdb-spatial でもそれを使うようにできるはず、とのことです。なので、将来的には複数ファイルを読めるようになりそうです。

https://github.com/duckdb/duckdb-spatial/issues/191#issuecomment-2935130507

ただ、それは少し先の話です。それまでのつなぎとして、ちょっとした DuckDB 拡張を作りました。以下のように glob パターンでパスを指定してで複数の GeoJSON ファイル、もしくは GeoPackage ファイルを読み込めます。(REPLACE でごにょごにょやってる部分はなんやねん?、という話はあとでします...)

SELECT * REPLACE (ST_GeomFromWkb(geometry) as geometry)
FROM ST_Read_Multi('test/data/*.geojson');
┌─────────────────┬────────┬─────────┬───────────────────────────┐
│    geometry     │  val1  │  val2   │         filename          │
│    geometry     │ double │ varchar │          varchar          │
├─────────────────┼────────┼─────────┼───────────────────────────┤
│ POINT (1 2)     │    1.0 │ a       │ test/data/points.geojson  │
│ POINT (10 20)   │    2.0 │ b       │ test/data/points.geojson  │
│ POINT (100 200) │    5.0 │ c       │ test/data/points2.geojson │
│ POINT (111 222) │    6.0 │ d       │ test/data/points2.geojson │
└─────────────────┴────────┴─────────┴───────────────────────────┘

インストール

Community extension に入れたので、以下のコマンドでインストールできます。

INSTALL st_read_multi FROM community;
LOAD st_read_multi;

制限

この DuckDB 拡張は、上に書いたようにあくまでつなぎなので、かなり割り切ったつくりになっています。

  • 書き込みはない。読むだけ。
  • 効率的なデータアクセス(空間インデックスとか)は諦める。でかいデータならあらかじめ GDAL CLI とかで加工してね、ということで。
  • メタデータ(CRS とか BBox とか)は諦める。中身のデータが読めればそれでいい。

また、GEOMETRY 型ではなく BLOB 型のデータを返します。上では REPLACE でごまかしてたんですが、ST_GeomFromWkb() で明示的に変換しないと、ただの BLOB なのでこう表示されます。これは、別の拡張で定義されたエイリアス型は、たとえ中身が同じでもほかの拡張から使うことはできないっぽく、諦めました。C++ 拡張ならマクロとかでなんとかなるのかもしれませんが、Rust で書いたのでそういうことはできないぽいです。

┌────────────────────────────────────────────────────────────────────────────────┬────────┬─────────┬───────────────────────────┐
│                                    geometry                                    │  val1  │  val2   │         filename          │
│                                      blob                                      │ double │ varchar │          varchar          │
├────────────────────────────────────────────────────────────────────────────────┼────────┼─────────┼───────────────────────────┤
│ \x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xF0?\x00\x00\x00\x00\x00\x00\x00@ │    1.0 │ a       │ test/data/points.geojson  │
│ \x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00$@\x00\x00\x00\x00\x00\x004@       │    2.0 │ b       │ test/data/points.geojson  │
│ \x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00Y@\x00\x00\x00\x00\x00\x00i@       │    5.0 │ c       │ test/data/points2.geojson │
│ \x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\xC0[@\x00\x00\x00\x00\x00\xC0k@       │    6.0 │ d       │ test/data/points2.geojson │
└────────────────────────────────────────────────────────────────────────────────┴────────┴─────────┴───────────────────────────┘

ちなみに、最近いろんなデータ関連のプロダクトで GEOMETRY 型や GEOGRAPHY 型のサポートが進んでいますが、DuckDB も将来的には拡張ではなく本体に GEOMETRY 型や GEOGRAPHY 型が入るっぽいです。待ち遠しいですね。

https://github.com/paleolimbot/duckdb-geography/issues/20

使い方

GeoJSON

上に書いたように、glob パターンで GeoJSON を読み込めます。どのファイルから読み込まれたかの情報は filename カラムに入ります。

なお、スキーマが違うファイルが混ざっているとエラーになります。このあたりも、いい感じにマージするとかできそうですが、考え出すとキリがないのでやめました。

FROM ST_Read_Multi('test/data/different_schema/*.geojson');
Binder Error:
  Schema mismatch in test/data/different_schema/points2.geojson: column 1 has name 'val3', expected 'val2'

GeoPackage

GeoPackage ファイルは複数のレイヤーを持てますが、ST_Read_Multi() はすべてのレイヤーを読み込みます。filename カラムに加えて layer カラムも表示されます。

-- load all layers
SELECT * REPLACE (ST_GeomFromWkb(geom) as geom)
FROM ST_Read_Multi('test/data/*.gpkg');
┌─────────────────┬───────┬─────────┬─────────────────────────────┬───────────────┐
│      geom       │ val1  │  val2   │          filename           │     layer     │
│    geometry     │ int32 │ varchar │           varchar           │    varchar    │
├─────────────────┼───────┼─────────┼─────────────────────────────┼───────────────┤
│ POINT (100 200) │     5 │ c       │ test/data/multi_layers.gpkg │ points2_point │
│ POINT (111 222) │     6 │ d       │ test/data/multi_layers.gpkg │ points2_point │
│ POINT (1 2)     │     1 │ a       │ test/data/multi_layers.gpkg │ points_point  │
│ POINT (10 20)   │     2 │ b       │ test/data/multi_layers.gpkg │ points_point  │
│ POINT (1 2)     │     1 │ a       │ test/data/points.gpkg       │ points        │
│ POINT (10 20)   │     2 │ b       │ test/data/points.gpkg       │ points        │
│ POINT (100 200) │     5 │ c       │ test/data/points2.gpkg      │ points        │
│ POINT (111 222) │     6 │ d       │ test/data/points2.gpkg      │ points        │
└─────────────────┴───────┴─────────┴─────────────────────────────┴───────────────┘

もし、特定のレイヤーだけに絞り込みたい場合は、 layer 引数にレイヤー名を指定すれば絞り込んでくれます。

-- load specific layers
SELECT * REPLACE (ST_GeomFromWkb(geom) as geom)
FROM ST_Read_Multi('test/data/*.gpkg', layer='points');
[WARN] No such layer 'points' in test/data/multi_layers.gpkg
┌─────────────────┬───────┬─────────┬────────────────────────┬─────────┐
│      geom       │ val1  │  val2   │        filename        │  layer  │
│    geometry     │ int32 │ varchar │        varchar         │ varchar │
├─────────────────┼───────┼─────────┼────────────────────────┼─────────┤
│ POINT (1 2)     │     1 │ a       │ test/data/points.gpkg  │ points  │
│ POINT (10 20)   │     2 │ b       │ test/data/points.gpkg  │ points  │
│ POINT (100 200) │     5 │ c       │ test/data/points2.gpkg │ points  │
│ POINT (111 222) │     6 │ d       │ test/data/points2.gpkg │ points  │
└─────────────────┴───────┴─────────┴────────────────────────┴─────────┘

実装

DuckDB 拡張をつくるのに興味がある人も読むかもしれないので、実装についても少しだけ。

DuckDB の Rust 拡張全般の話

DuckDB 拡張を Rust で書く際の仕組みについては以前記事を書いたのでこれを読んでください。というか、私も、すべてを忘れて途方に暮れていたところ、これを読み返してやり方をなんとなく思い出しました。ありがとう過去の自分...

https://zenn.dev/yutannihilation/articles/663c879b74343c

GeoJSON

GeoJSON は、geojson クレートを使いました。

https://docs.rs/geojson/latest/geojson/

geojson::Feature は GeoRust エコシステムの中にあるので、geo-types を介して変換することができます。WKB への変換は定義されてるんですが、.to_wkb() みたいな手ごろなメソッドは生えてないので、こんな感じの struct をつくって変換してました。

pub struct WkbConverter {
    buffer: Vec<u8>,
}

impl WkbConverter {
    pub fn new() -> Self {
        Self { buffer: Vec::new() }
    }

    pub fn convert(&mut self, feature: &Feature) -> Result<&[u8], Box<dyn std::error::Error>> {
        self.buffer.clear();
        match &feature.geometry {
            Some(geojson_geom) => {
                let geometry: geo_types::Geometry = geojson_geom.try_into()?;
                wkb::writer::write_geometry(&mut self.buffer, &geometry, &Default::default())
                    .unwrap();
            }
            None => panic!("Geometry should exist!"),
        }
        Ok(&self.buffer)
    }
}

GeoPackage

これも過去の自分に助けられた案件ですが、GeoPackage というフォーマットは SQLite です。「SQLite を拡張している」のではなく、「SQLite さえあれば読み書きできる」ように作られています。なので、GDAL みたいな GIS に特化したライブラリがなくても、SQLite 用のライブラリさえあれば事足ります。

https://zenn.dev/mierune/articles/6241df45a59dce

今回は、Rust ということで、SQLite を Rust でゼロから実装しなおそうという試み・limbo を使おうとしたんですが、なんか謎のエラーでうまくいきませんでした。コンパイルは通っても読み込むとクラッシュするという感じで、非同期周りが怪しそうな感じするんですが、深追いはしてません。

https://github.com/tursodatabase/limbo/

代わりに、rusqlite というクレートを使いました。こっちは pure Rust ではなく SQLite へのバインディングということで、windows_amd64_gnu ではコンパイルエラーになるのですが(GNU じゃなくて MSVC は大丈夫)、まあそれ以外は特に問題なく動きました。

https://docs.rs/rusqlite/latest/rusqlite/

読むだけできればいいので、pure Rust でもっと軽量な実装ないのかな...、と思うのですが、うまく見つけられませんでした。

最後に

まだ実際のデータでテストできていない experimental な 拡張ですが、よかったら使ってください。何か変な挙動に遭遇したり、要望などあれば、GitHub の issue にお知らせいただけると助かります。

MIERUNEのZennブログ

Discussion