Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/MainDistributionPipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ jobs:
extension_name: duck_geoarrow
format_checks: 'format;tidy'

# Canary build against the upcoming DuckDB v2. community-extensions builds every
# extension against this same branch (see its build_next.yml), so a failure here is
# one that would show up there. Artifacts get a postfix so the release job below can
# tell them apart from the v1.5.5 binaries and leave them out of the GitHub release.
duckdb-next-build:
name: Build extension binaries (DuckDB v2 canary)
uses: duckdb/extension-ci-tools/.github/workflows/_extension_distribution.yml@main
with:
duckdb_version: v2.0-cyanoptera
ci_tools_version: main
extension_name: duck_geoarrow
artifact_postfix: -duckdb-next

release:
name: Publish release
needs: duckdb-stable-build
Expand All @@ -43,13 +56,15 @@ jobs:
with:
path: raw-artifacts

# Each artifact dir is named duck_geoarrow-1.5.1-extension-<platform>
# Each artifact dir is named duck_geoarrow-v1.5.5-extension-<platform>
# and contains build/release/extension/duck_geoarrow/duck_geoarrow.duckdb_extension
# Flatten & rename to duck_geoarrow-<platform>.duckdb_extension for the release.
# The v2 canary's artifacts end in -duckdb-next and are not released.
- name: Collect and rename extension binaries
run: |
mkdir -p dist
for artifact_dir in raw-artifacts/duck_geoarrow-*-extension-*/; do
case "$artifact_dir" in *-duckdb-next/) continue ;; esac
platform=$(basename "$artifact_dir" | sed 's/duck_geoarrow-[^-]*-extension-//')
src=$(find "$artifact_dir" -name "duck_geoarrow.duckdb_extension" | head -1)
if [ -n "$src" ]; then
Expand Down
98 changes: 97 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,108 @@ permissions:
contents: write
pull-requests: write

# RELEASE_PAT is a classic personal access token with the `public_repo` scope. It is
# needed for two reasons:
# 1. Tags and releases created with the default GITHUB_TOKEN do not trigger other
# workflows, so the tag-triggered release job in MainDistributionPipeline.yml
# never ran (v0.1.3 shipped without binaries). A PAT-created tag does trigger it.
# 2. Pushing to the community-extensions fork and opening the upstream PR both need
# access outside this repository.
# Until the secret exists, release-please falls back to GITHUB_TOKEN and the
# community-extensions job is skipped.

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: .release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}

# Open a PR against duckdb/community-extensions pointing at the new release. Runs in
# the same workflow run as release-please (rather than on a `release:` trigger) so it
# works regardless of which token created the release.
community-extensions-pr:
name: Open community-extensions PR
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
UPSTREAM: duckdb/community-extensions
FORK: am2222/community-extensions
EXT_NAME: duck_geoarrow
VERSION: ${{ needs.release-please.outputs.version }}
TAG: ${{ needs.release-please.outputs.tag_name }}
SHA: ${{ needs.release-please.outputs.sha }}
steps:
- name: Check for RELEASE_PAT
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::RELEASE_PAT secret is not set; cannot push to $FORK or open a PR on $UPSTREAM."
exit 1
fi

# This repo's description.yml is the source of truth for the listing; only
# repo.ref is stamped with the release commit.
- name: Check out the released commit
uses: actions/checkout@v4
with:
ref: ${{ env.SHA }}
path: source

- name: Check out community-extensions
uses: actions/checkout@v4
with:
repository: ${{ env.UPSTREAM }}
ref: main
path: community-extensions
token: ${{ secrets.RELEASE_PAT }}

- name: Update description.yml
working-directory: community-extensions
run: |
target="extensions/$EXT_NAME/description.yml"
sed -E "s|^([[:space:]]*ref:).*|\1 $SHA|" "../source/description.yml" > "$target"
grep -q "ref: $SHA" "$target"
grep -q "version: $VERSION" "$target" || {
echo "::error::description.yml does not carry version $VERSION; check the x-release-please-version marker"
exit 1
}
git --no-pager diff -- "$target"

- name: Push branch to fork and open PR
working-directory: community-extensions
run: |
gh auth setup-git
branch="$EXT_NAME-$TAG"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
git add "extensions/$EXT_NAME/description.yml"
git commit -m "Update $EXT_NAME to $TAG"
git push --force "https://github.com/$FORK.git" "HEAD:$branch"

# `gh pr list --head` takes the bare branch name; `gh pr create --head` wants owner:branch.
existing=$(gh pr list --repo "$UPSTREAM" --head "$branch" --state open --json number --jq '.[0].number')
if [ -n "$existing" ]; then
echo "PR #$existing already open for $branch; branch updated."
exit 0
fi
gh pr create --repo "$UPSTREAM" --head "${FORK%%/*}:$branch" --base main \
--title "Update $EXT_NAME to $TAG" \
--body "Bumps \`$EXT_NAME\` to $TAG (\`$SHA\`).

Release notes: https://github.com/${{ github.repository }}/releases/tag/$TAG

Opened automatically by the release workflow in ${{ github.repository }}."
3 changes: 2 additions & 1 deletion .release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"extra-files": [
"CMakeLists.txt"
"CMakeLists.txt",
{ "type": "generic", "path": "description.yml" }
]
}
}
Expand Down
141 changes: 95 additions & 46 deletions description.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
extension:
name: duck_geoarrow
description: DuckDB extension for converting GEOMETRY/WKB to and from GeoArrow native encodings, powered by geoarrow-c. Built against DuckDB v1.5.2.
version: 0.1.0
description: DuckDB extension for converting GEOMETRY/WKB to and from GeoArrow native encodings, powered by geoarrow-c.
version: 0.1.3 # x-release-please-version
language: C++
build: cmake
license: MIT
Expand All @@ -10,62 +10,111 @@ extension:

repo:
github: am2222/duck_geoarrow
ref: 3d0e6c597842753591633173826446b50f35b2b3
ref: 954939b3ebcbbcaf07f6a521e519964bbc7770e0

docs:
hello_world: |
INSTALL duck_geoarrow FROM community;
LOAD duck_geoarrow;

-- Convert a WKB point to GeoArrow struct
SELECT st_asgeoarrow('\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xF0\x3F\x00\x00\x00\x00\x00\x00\x00\x40'::BLOB);
-- → {geometry_type: 1, xs: [1.0], ys: [2.0], ring_offsets: [], geom_offsets: []}
-- Read a GeoArrow-encoded GeoParquet file written by geopandas. A geometry column
-- written with encoding="polygon" arrives as STRUCT(x DOUBLE, y DOUBLE)[][].
SELECT county_name, st_geomfromgeoarrow('polygon', geometry) AS geom
FROM read_parquet('county_regions.parquet');

-- With the spatial extension loaded, use GEOMETRY directly
LOAD spatial;
SELECT st_asgeoarrow(ST_Point(1.0, 2.0)::GEOMETRY);
-- Write GEOMETRY back out as a GeoArrow native encoding
COPY (SELECT county_name, st_asgeoarrowpolygon(geom) AS geometry FROM counties)
TO 'counties.parquet';

-- Convert back: GeoArrow struct → GEOMETRY (WKB)
SELECT st_geomfromgeoarrow(st_asgeoarrow(ST_Point(30, 10)::GEOMETRY));
-- GEOMETRY -> GeoArrow native encoding, one function per geometry type
SELECT st_asgeoarrowpoint('POINT(30 10)'::GEOMETRY);
-- {'x': 30.0, 'y': 10.0}

-- Native-typed point extraction (returns STRUCT<x, y>)
SELECT st_asgeoarrowpoint(ST_Point(30, 10)::GEOMETRY);
-- → {x: 30.0, y: 10.0}
SELECT st_asgeoarrowpolygon('POLYGON((0 0, 1 0, 1 1, 0 0))'::GEOMETRY);
-- [[{'x': 0.0, 'y': 0.0}, {'x': 1.0, 'y': 0.0}, {'x': 1.0, 'y': 1.0}, {'x': 0.0, 'y': 0.0}]]

-- Native-typed linestring (returns List<Struct<x, y>>)
SELECT st_asgeoarrowlinestring(ST_GeomFromText('LINESTRING(0 0, 1 1, 2 2)')::GEOMETRY);
-- GeoArrow native encoding -> GEOMETRY; the type name disambiguates colliding shapes
SELECT st_geomfromgeoarrow('linestring', [{'x': 0.0, 'y': 0.0}, {'x': 1.0, 'y': 1.0}]),
st_geomfromgeoarrow('multipoint', [{'x': 0.0, 'y': 0.0}, {'x': 1.0, 'y': 1.0}]);
-- LINESTRING (0 0, 1 1) | MULTIPOINT (0 0, 1 1)

-- Native-typed polygon (returns List<List<Struct<x, y>>>)
SELECT st_asgeoarrowpolygon(ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 4, 0 0))')::GEOMETRY);
-- Z and M: reading infers them from the value, writing takes them as an argument
SELECT st_geomfromgeoarrow('point', {'x': 1.0, 'y': 2.0, 'z': 3.0});
-- POINT Z (1 2 3)
SELECT st_asgeoarrowpoint('POINT Z (1 2 3)'::GEOMETRY, 'xyz');
-- {'x': 1.0, 'y': 2.0, 'z': 3.0}

-- Flat struct form: one struct per geometry with parallel coordinate arrays
SELECT st_asgeoarrow('POINT(30 10)'::GEOMETRY);
-- {'geometry_type': 1, 'xs': [30.0], 'ys': [10.0], 'ring_offsets': [], 'geom_offsets': []}

-- Version info
SELECT duck_geoarrow_version();

extended_description: |
`duck_geoarrow` converts between DuckDB's WKB-based `GEOMETRY`/`BLOB` type and
[GeoArrow](https://geoarrow.org) native struct encodings, powered by
[geoarrow-c](https://github.com/geoarrow/geoarrow-c).

**Generic conversion functions** (accept `GEOMETRY` or `BLOB` containing WKB):

- `st_asgeoarrow(geom)` — WKB → GeoArrow struct with geometry_type, xs, ys, ring_offsets, geom_offsets
- `st_geomfromgeoarrow(struct)` — GeoArrow struct → WKB `GEOMETRY`

**Native-typed functions** (return Arrow-native nested types for direct columnar use):

| Function | Arrow type |
|---|---|
| `st_asgeoarrowpoint` | `Struct<x: DOUBLE, y: DOUBLE>` |
| `st_asgeoarrowlinestring` | `List<Struct<x, y>>` |
| `st_asgeoarrowpolygon` | `List<List<Struct<x, y>>>` |
| `st_asgeoarrowmultipoint` | `List<Struct<x, y>>` |
| `st_asgeoarrowmultilinestring` | `List<List<Struct<x, y>>>` |
| `st_asgeoarrowmultipolygon` | `List<List<List<Struct<x, y>>>>` |

**Utility:**

- `duck_geoarrow_version()` — returns extension and geoarrow-c version info

All functions accept both `GEOMETRY` (from the spatial extension) and raw `BLOB`
containing valid WKB. The native-typed functions validate that the input geometry
matches the expected type and raise an error on mismatch.
`duck_geoarrow` converts between DuckDB `GEOMETRY` (or WKB `BLOB`) and the
[GeoArrow](https://geoarrow.org/) coordinate encodings, powered by the
[geoarrow-c](https://github.com/geoarrow/geoarrow-c) library.

Two GeoArrow representations are supported:

- **Native encodings**: nested lists of separated coordinates (`STRUCT(x, y)[][]` for a
Polygon, and so on). This is what GeoParquet, geoarrow-pyarrow and geopandas produce,
so it is the one to reach for when reading or writing files.
- **A flat struct**: one struct per geometry holding parallel coordinate and offset
arrays (`geometry_type`, `xs`, `ys`, optional `zs` / `ms`, `ring_offsets`,
`geom_offsets`). Convenient for hand-building geometries in SQL.

Both directions support XY, XYZ, XYM and XYZM.

### Functions

| Function | Direction | Notes |
|---|---|---|
| `st_geomfromgeoarrow(type, value)` | GeoArrow → GEOMETRY | Generic: any native encoding, type given as a string |
| `st_geomfromgeoarrow<type>(value)` | GeoArrow → GEOMETRY | One name per geometry type |
| `st_geomfromgeoarrow(struct)` | GeoArrow → GEOMETRY | Flat struct form |
| `st_asgeoarrow<type>(geom[, dims])` | GEOMETRY → GeoArrow | Native encoding for one geometry type |
| `st_asgeoarrow(geom[, dims])` | GEOMETRY → GeoArrow | Flat struct form |
| `duck_geoarrow_version()` | — | Extension and geoarrow-c versions |

`<type>` is one of `point`, `linestring`, `polygon`, `multipoint`, `multilinestring` or
`multipolygon`. All conversion functions accept either `GEOMETRY` or a `BLOB` holding WKB.

### Native encodings

| Geometry type | DuckDB type | GEOMETRY → GeoArrow | GeoArrow → GEOMETRY |
|---|---|---|---|
| Point | `STRUCT(x, y)` | `st_asgeoarrowpoint` | `st_geomfromgeoarrowpoint` |
| LineString | `STRUCT(x, y)[]` | `st_asgeoarrowlinestring` | `st_geomfromgeoarrowlinestring` |
| Polygon | `STRUCT(x, y)[][]` | `st_asgeoarrowpolygon` | `st_geomfromgeoarrowpolygon` |
| MultiPoint | `STRUCT(x, y)[]` | `st_asgeoarrowmultipoint` | `st_geomfromgeoarrowmultipoint` |
| MultiLineString | `STRUCT(x, y)[][]` | `st_asgeoarrowmultilinestring` | `st_geomfromgeoarrowmultilinestring` |
| MultiPolygon | `STRUCT(x, y)[][][]` | `st_asgeoarrowmultipolygon` | `st_geomfromgeoarrowmultipolygon` |

These are the separated-coordinate layouts from the
[GeoArrow specification](https://geoarrow.org/format), so the values interoperate
directly with other GeoArrow implementations.

The generic `st_geomfromgeoarrow(type, value)` exists because the DuckDB types collide:
LineString and MultiPoint are both `STRUCT(x, y)[]`, and Polygon and MultiLineString are
both `STRUCT(x, y)[][]`. The type name is resolved at bind time, so an unknown type or a
mismatched shape is a binder error rather than a runtime one. Names are case- and
separator-insensitive and may carry a `z`, `m` or `zm` suffix.

### Z and M dimensions

GeoArrow carries extra ordinates in the coordinate struct (`STRUCT(x, y, z)`,
`STRUCT(x, y, m)`, `STRUCT(x, y, z, m)`), and the flat struct gains matching `zs` / `ms`
lists. Reading needs no extra argument since the value's type says which ordinates are
present. Writing takes the dimensions as an argument (`xy`, `xyz`, `xym`, `xyzm`) because
a SQL function's return type must be fixed before any data is seen; omitting it means
`xy`. Dropping ordinates is allowed, inventing them is not: asking for `xyz` from a 2D
geometry raises an error rather than filling in NaNs.

### Notes

- `NULL` input produces `NULL` output. A `NULL` inside a list (a null ring, say) is read
as an empty ring, since the GeoArrow native encoding only carries validity at the top
level.
- GeometryCollection, and the `geoarrow.box` and interleaved-coordinate encodings, are
not supported.
Loading
Loading