Repository: stac-utils/stac-rs (language: python)

Issue:
Add duckdb backend



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 21d298da..c6a2fd89 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,7 @@ jobs:
           - "-p stac -p stac-api -F geo"
           - "-p stac-async"
           - "-p stac-cli --no-default-features"
+          - "-p stac-duckdb -F duckdb/bundled"
           - "-p stac-server"
           - "-p stac-server -F axum"
           - "-p stac-validate"
diff --git a/Cargo.toml b/Cargo.toml
index 80fd60e2..cf9033c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,12 +1,13 @@
 [workspace]
 resolver = "2"
 members = [
-    "core",
-    "pgstac",
-    "python",
     "api",
     "async",
     "cli",
+    "core",
+    "duckdb",
+    "pgstac",
+    "python",
     "server",
     "validate",
 ]
diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index fe038ede..d46e0ffc 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
+### Added
+
+- `From<Vec<Item>>` for `ItemCollection`, `From<Items>` for `Search`, `Direction` to public API ([#328](https://github.com/stac-utils/stac-rs/pull/328))
+
+### Changed
+
+- Use `stac::Bbox` for `Items`, renamed `Items::into_search` to `Items::search_collection` ([#328](https://github.com/stac-utils/stac-rs/pull/328))
+
 ## [0.4.1] - 2024-08-12
 
 ### Added
diff --git a/api/src/item_collection.rs b/api/src/item_collection.rs
index 472bfeab..d757fdd9 100644
--- a/api/src/item_collection.rs
+++ b/api/src/item_collection.rs
@@ -149,6 +149,15 @@ impl Default for ItemCollection {
     }
 }
 
+impl From<Vec<Item>> for ItemCollection {
+    fn from(items: Vec<Item>) -> Self {
+        ItemCollection {
+            items,
+            ..Default::default()
+        }
+    }
+}
+
 fn deserialize_type<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
 where
     D: serde::de::Deserializer<'de>,
diff --git a/api/src/items.rs b/api/src/items.rs
index e5e87540..16131ce8 100644
--- a/api/src/items.rs
+++ b/api/src/items.rs
@@ -2,7 +2,7 @@ use crate::{Error, Fields, Filter, Result, Search, Sortby};
 use chrono::{DateTime, FixedOffset};
 use serde::{Deserialize, Serialize};
 use serde_json::{Map, Value};
-use stac::Item;
+use stac::{Bbox, Item};
 use std::collections::HashMap;
 
 /// Parameters for the items endpoint from STAC API - Features.
@@ -14,7 +14,7 @@ pub struct Items {
 
     /// Requested bounding box.
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub bbox: Option<Vec<f64>>,
+    pub bbox: Option<Bbox>,
 
     /// Single date+time, or a range ('/' separator), formatted to [RFC 3339,
     /// section 5.6](https://tools.ietf.org/html/rfc3339#section-5.6).
@@ -114,25 +114,8 @@ impl Items {
     /// ```
     pub fn valid(self) -> Result<Items> {
         if let Some(bbox) = self.bbox.as_ref() {
-            if bbox.len() == 4 {
-                if bbox[1] > bbox[3] {
-                    return Err(Error::InvalidBbox(
-                        bbox.clone(),
-                        "min latitude is greater than max latitude",
-                    ));
-                }
-            } else if bbox.len() == 6 {
-                if bbox[1] > bbox[4] {
-                    return Err(Error::InvalidBbox(
-                        bbox.clone(),
-                        "min latitude is greater than max latitude",
-                    ));
-                }
-            } else {
-                return Err(Error::InvalidBbox(
-                    bbox.clone(),
-                    "invalid number of coordinates",
-                ));
+            if !bbox.is_valid() {
+                return Err(Error::from(stac::Error::InvalidBbox((*bbox).into())));
             }
         }
         if let Some(datetime) = self.datetime.as_deref() {
@@ -190,7 +173,7 @@ impl Items {
     /// let mut search = Search::new();
     /// let mut item = Item::new("item-id");
     /// assert!(search.bbox_matches(&item).unwrap());
-    /// search.bbox = Some(vec![-110.0, 40.0, -100.0, 50.0]);
+    /// search.bbox = Some(vec![-110.0, 40.0, -100.0, 50.0].try_into().unwrap());
     /// assert!(!search.bbox_matches(&item).unwrap());
     /// item.set_geometry(Geometry::new(Value::Point(vec![-105.1, 41.1])));
     /// assert!(search.bbox_matches(&item).unwrap());
@@ -201,7 +184,7 @@ impl Items {
         if let Some(bbox) = self.bbox.as_ref() {
             #[cfg(feature = "geo")]
             {
-                let bbox = stac::geo::bbox(bbox)?;
+                let bbox: geo::Rect = (*bbox).into();
                 item.intersects(&bbox).map_err(Error::from)
             }
             #[cfg(not(feature = "geo"))]
@@ -297,10 +280,10 @@ impl Items {
     ///     datetime: Some("2023".to_string()),
     ///     ..Default::default()
     /// };
-    /// let search = items.into_search("collection-id");
+    /// let search = items.search_collection("collection-id");
     /// assert_eq!(search.collections.unwrap(), vec!["collection-id"]);
     /// ```
-    pub fn into_search(self, collection_id: impl ToString) -> Search {
+    pub fn search_collection(self, collection_id: impl ToString) -> Search {
         Search {
             items: self,
             intersects: None,
@@ -328,7 +311,8 @@ impl TryFrom<Items> for GetItems {
         Ok(GetItems {
             limit: items.limit.map(|n| n.to_string()),
             bbox: items.bbox.map(|bbox| {
-                bbox.into_iter()
+                Vec::from(bbox)
+                    .into_iter()
                     .map(|n| n.to_string())
                     .collect::<Vec<_>>()
                     .join(",")
@@ -363,7 +347,7 @@ impl TryFrom<GetItems> for Items {
             for s in value.split(',') {
                 bbox.push(s.parse()?)
             }
-            Some(bbox)
+            Some(bbox.try_into()?)
         } else {
             None
         };
@@ -443,7 +427,10 @@ mod tests {
 
         let items: Items = get_items.try_into().unwrap();
         assert_eq!(items.limit.unwrap(), 42);
-        assert_eq!(items.bbox.unwrap(), vec![-1.0, -2.0, 1.0, 2.0]);
+        assert_eq!(
+            items.bbox.unwrap(),
+            vec![-1.0, -2.0, 1.0, 2.0].try_into().unwrap()
+        );
         assert_eq!(items.datetime.unwrap(), "2023");
         assert_eq!(
             items.fields.unwrap(),
@@ -473,7 +460,7 @@ mod tests {
 
         let items = Items {
             limit: Some(42),
-            bbox: Some(vec![-1.0, -2.0, 1.0, 2.0]),
+            bbox: Some(vec![-1.0, -2.0, 1.0, 2.0].try_into().unwrap()),
             datetime: Some("2023".to_string()),
             fields: Some(Fields {
                 include: vec!["foo".to_string()],
diff --git a/api/src/lib.rs b/api/src/lib.rs
index 77e9d02c..4f8fe606 100644
--- a/api/src/lib.rs
+++ b/api/src/lib.rs
@@ -88,7 +88,7 @@ pub use {
     items::{GetItems, Items},
     root::Root,
     search::{GetSearch, Search},
-    sort::Sortby,
+    sort::{Direction, Sortby},
     url_builder::UrlBuilder,
 };
 
diff --git a/api/src/search.rs b/api/src/search.rs
index b7a27fe2..14c608e8 100644
--- a/api/src/search.rs
+++ b/api/src/search.rs
@@ -88,7 +88,7 @@ impl Search {
     /// use geojson::{Geometry, Value};
     ///
     /// let mut search = Search::default();
-    /// search.items.bbox =  Some(vec![-180.0, -90.0, 180.0, 80.0]);
+    /// search.items.bbox =  Some(vec![-180.0, -90.0, 180.0, 80.0].try_into().unwrap());
     /// search = search.valid().unwrap();
     /// search.intersects = Some(Geometry::new(Value::Point(vec![0.0, 0.0])));
     /// search.valid().unwrap_err();
@@ -257,6 +257,15 @@ impl TryFrom<GetSearch> for Search {
     }
 }
 
+impl From<Items> for Search {
+    fn from(items: Items) -> Self {
+        Search {
+            items,
+            ..Default::default()
+        }
+    }
+}
+
 impl stac::Fields for Search {
     fn fields(&self) -> &Map<String, Value> {
         &self.items.additional_fields
diff --git a/api/src/sort.rs b/api/src/sort.rs
index 20b76dea..c06253f2 100644
--- a/api/src/sort.rs
+++ b/api/src/sort.rs
@@ -15,10 +15,14 @@ pub struct Sortby {
     pub direction: Direction,
 }
 
+/// The direction of sorting.
 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
 pub enum Direction {
+    /// Ascending
     #[serde(rename = "asc")]
     Ascending,
+
+    /// Descending
     #[serde(rename = "desc")]
     Descending,
 }
diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md
index 0d65e41e..1287e2a8 100644
--- a/core/CHANGELOG.md
+++ b/core/CHANGELOG.md
@@ -8,10 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Added
 
-- `Bbox` ([#303](https://github.com/stac-utils/stac-rs/pull/303))
+- `Bbox` ([#303](https://github.com/stac-utils/stac-rs/pull/303), [#328](https://github.com/stac-utils/stac-rs/pull/328))
 - Functions to create collections from items ([#304](https://github.com/stac-utils/stac-rs/pull/304))
 - Default implementation for `Version` ([#309](https://github.com/stac-utils/stac-rs/pull/309))
-- Experimental GeoParquet and GeoArrow support ([#316](https://github.com/stac-utils/stac-rs/pull/316), [#319](https://github.com/stac-utils/stac-rs/pull/319))
+- Experimental GeoParquet and GeoArrow support ([#316](https://github.com/stac-utils/stac-rs/pull/316), [#319](https://github.com/stac-utils/stac-rs/pull/319), [#328](https://github.com/stac-utils/stac-rs/pull/328))
 - Public `stac::io` module ([#319](https://github.com/stac-utils/stac-rs/pull/319))
 
 ### Changed
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 - `stac::read_json` from the public API ([#301](https://github.com/stac-utils/stac-rs/pull/301))
 - `Bounds` ([#303](https://github.com/stac-utils/stac-rs/pull/303))
+- `stac::item::FlatItem` ([#328](https://github.com/stac-utils/stac-rs/pull/328))
 
 ## [0.8.0] - 2024-08-12
 
diff --git a/core/src/bbox.rs b/core/src/bbox.rs
index 55776932..3882d62f 100644
--- a/core/src/bbox.rs
+++ b/core/src/bbox.rs
@@ -225,6 +225,16 @@ impl From<geo::Rect> for Bbox {
     }
 }
 
+#[cfg(feature = "geo")]
+impl From<Bbox> for geo::Rect {
+    fn from(bbox: Bbox) -> geo::Rect {
+        geo::Rect::new(
+            geo::coord! {x: bbox.xmin(), y: bbox.ymin()},
+            geo::coord! {x: bbox.xmax(), y: bbox.ymax()},
+        )
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::Bbox;
diff --git a/core/src/geoarrow/json.rs b/core/src/geoarrow/json.rs
index b04eda1f..1222cd97 100644
--- a/core/src/geoarrow/json.rs
+++ b/core/src/geoarrow/json.rs
@@ -15,6 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
+//! Utilities for working with JSON and (geo)arrow.
+//!
 //! Taken from v51.0.0 of
 //! [arrow-json](https://docs.rs/arrow-json/51.0.0/arrow_json/index.html), we've
 //! lifted this code to convert record batches to vectors of
@@ -23,12 +25,31 @@
 
 #![allow(unused_results)]
 
+const TOP_LEVEL_KEYS: [&str; 10] = [
+    "type",
+    "stac_version",
+    "stac_extensions",
+    "id",
+    "geometry",
+    "bbox",
+    "properties",
+    "links",
+    "assets",
+    "collection",
+];
+
 use arrow_array::cast::*;
 use arrow_array::types::*;
 use arrow_array::*;
 use arrow_cast::display::{ArrayFormatter, FormatOptions};
 use arrow_json::JsonSerializable;
 use arrow_schema::*;
+use geoarrow::table::Table;
+use geoarrow::{
+    array::AsGeometryArray,
+    datatypes::{Dimension, GeoDataType},
+    trait_::GeometryArrayAccessor,
+};
 use serde_json::json;
 use serde_json::map::Map as JsonMap;
 use serde_json::Value;
@@ -400,9 +421,158 @@ fn set_column_for_json_rows(
     Ok(())
 }
 
-pub(crate) fn record_batches_to_json_rows(
+/// Converts a table to json rows.
+pub fn from_table(table: Table) -> Result<Vec<serde_json::Map<String, Value>>, crate::Error> {
+    use geojson::Value;
+    use GeoDataType::*;
+
+    let index = table
+        .schema()
+        .column_with_name("geometry")
+        .map(|(index, _)| index);
+    let mut json_rows = record_batches_to_json_rows(table.batches(), index)?;
+    let mut items = Vec::new();
+    if let Some(index) = index {
+        for chunk in table.geometry_column(Some(index))?.geometry_chunks() {
+            for i in 0..chunk.len() {
+                let value = match chunk.data_type() {
+                    Point(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_point_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_point_3d().value_as_geo(i)),
+                    },
+                    LineString(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_line_string_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_line_string_3d().value_as_geo(i)),
+                    },
+                    LargeLineString(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_large_line_string_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_large_line_string_3d().value_as_geo(i))
+                        }
+                    },
+                    Polygon(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_polygon_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_polygon_3d().value_as_geo(i)),
+                    },
+                    LargePolygon(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_large_polygon_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_large_polygon_3d().value_as_geo(i)),
+                    },
+                    MultiPoint(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_multi_point_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_multi_point_3d().value_as_geo(i)),
+                    },
+                    LargeMultiPoint(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_large_multi_point_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_large_multi_point_3d().value_as_geo(i))
+                        }
+                    },
+                    MultiLineString(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_multi_line_string_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_multi_line_string_3d().value_as_geo(i))
+                        }
+                    },
+                    LargeMultiLineString(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_large_multi_line_string_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_large_multi_line_string_3d().value_as_geo(i))
+                        }
+                    },
+                    MultiPolygon(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_multi_polygon_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_multi_polygon_3d().value_as_geo(i)),
+                    },
+                    LargeMultiPolygon(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_large_multi_polygon_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_large_multi_polygon_3d().value_as_geo(i))
+                        }
+                    },
+                    Mixed(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_mixed_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_mixed_3d().value_as_geo(i)),
+                    },
+                    LargeMixed(_, dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_large_mixed_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_large_mixed_3d().value_as_geo(i)),
+                    },
+                    GeometryCollection(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_geometry_collection_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_geometry_collection_3d().value_as_geo(i))
+                        }
+                    },
+                    LargeGeometryCollection(_, dimension) => match dimension {
+                        Dimension::XY => {
+                            Value::from(&chunk.as_large_geometry_collection_2d().value_as_geo(i))
+                        }
+                        Dimension::XYZ => {
+                            Value::from(&chunk.as_large_geometry_collection_3d().value_as_geo(i))
+                        }
+                    },
+                    WKB => Value::from(&chunk.as_wkb().value_as_geo(i)),
+                    LargeWKB => Value::from(&chunk.as_large_wkb().value_as_geo(i)),
+                    Rect(dimension) => match dimension {
+                        Dimension::XY => Value::from(&chunk.as_rect_2d().value_as_geo(i)),
+                        Dimension::XYZ => Value::from(&chunk.as_rect_3d().value_as_geo(i)),
+                    },
+                };
+                let mut row = json_rows
+                    .next()
+                    .expect("we shouldn't run out of rows before we're done");
+                let _ = row.insert(
+                    "geometry".into(),
+                    serde_json::to_value(geojson::Geometry::new(value))?,
+                );
+                items.push(unflatten(row));
+            }
+        }
+    } else {
+        items = json_rows.map(unflatten).collect();
+    }
+    Ok(items)
+}
+
+fn unflatten(mut item: serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
+    let mut properties = serde_json::Map::new();
+    let keys: Vec<_> = item
+        .keys()
+        .filter_map(|key| {
+            if TOP_LEVEL_KEYS.contains(&key.as_str()) {
+                None
+            } else {
+                Some(key.to_string())
+            }
+        })
+        .collect();
+    for key in keys {
+        if let Some(value) = item.remove(&key) {
+            let _ = properties.insert(key, value);
+        }
+    }
+    if !properties.is_empty() {
+        let _ = item.insert("properties".to_string(), Value::Object(properties));
+    }
+    item
+}
+
+fn record_batches_to_json_rows(
     batches: &[RecordBatch],
-    geometry_index: usize,
+    geometry_index: Option<usize>,
 ) -> Result<impl Iterator<Item = JsonMap<String, Value>>, ArrowError> {
     // For backwards compatibility, default to skip nulls
     // Skip converting the geometry index, we'll do that later.
@@ -412,7 +582,7 @@ pub(crate) fn record_batches_to_json_rows(
 fn record_batches_to_json_rows_internal(
     batches: &[RecordBatch],
     explicit_nulls: bool,
-    geometry_index: usize,
+    geometry_index: Option<usize>,
 ) -> Result<impl Iterator<Item = JsonMap<String, Value>>, ArrowError> {
     let mut rows: Vec<Option<JsonMap<String, Value>>> = iter::repeat(Some(JsonMap::new()))
         .take(batches.iter().map(|b| b.num_rows()).sum())
@@ -425,7 +595,7 @@ fn record_batches_to_json_rows_internal(
             let row_count = batch.num_rows();
             let row_slice = &mut rows[base..base + batch.num_rows()];
             for (j, col) in batch.columns().iter().enumerate() {
-                if j == geometry_index {
+                if geometry_index.map(|v| v == j).unwrap_or_default() {
                     continue;
                 }
                 let col_name = schema.field(j).name();
diff --git a/core/src/geoarrow/mod.rs b/core/src/geoarrow/mod.rs
index 4461516d..6ca033c0 100644
--- a/core/src/geoarrow/mod.rs
+++ b/core/src/geoarrow/mod.rs
@@ -2,20 +2,14 @@
 //!
 //!  ⚠️ geoarrow support is currently experimental, and may break on any release.
 
-mod json;
+pub mod json;
 
-use crate::{Error, FlatItem, Item, ItemCollection, Result};
+use crate::{Error, ItemCollection, Result};
 use arrow_json::ReaderBuilder;
 use arrow_schema::{DataType, Field, SchemaBuilder, TimeUnit};
 use geo_types::Geometry;
-use geoarrow::{
-    array::{AsGeometryArray, MixedGeometryBuilder},
-    datatypes::{Dimension, GeoDataType},
-    table::Table,
-    trait_::GeometryArrayAccessor,
-};
-use geojson::Value;
-use serde_json::json;
+use geoarrow::{array::MixedGeometryBuilder, table::Table};
+use serde_json::{json, Value};
 use std::sync::Arc;
 
 const DATETIME_COLUMNS: [&str; 8] = [
@@ -136,112 +130,11 @@ pub fn to_table(item_collection: ItemCollection) -> Result<Table> {
 /// # }
 /// ```
 pub fn from_table(table: Table) -> Result<ItemCollection> {
-    use GeoDataType::*;
-
-    let (index, _) = table
-        .schema()
-        .column_with_name("geometry")
-        .ok_or(Error::MissingGeometry)?;
-    let mut json_rows = json::record_batches_to_json_rows(table.batches(), index)?;
-    let mut items = Vec::new();
-    for chunk in table.geometry_column(Some(index))?.geometry_chunks() {
-        for i in 0..chunk.len() {
-            let value = match chunk.data_type() {
-                Point(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_point_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_point_3d().value_as_geo(i)),
-                },
-                LineString(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_line_string_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_line_string_3d().value_as_geo(i)),
-                },
-                LargeLineString(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_large_line_string_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_large_line_string_3d().value_as_geo(i)),
-                },
-                Polygon(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_polygon_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_polygon_3d().value_as_geo(i)),
-                },
-                LargePolygon(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_large_polygon_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_large_polygon_3d().value_as_geo(i)),
-                },
-                MultiPoint(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_multi_point_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_multi_point_3d().value_as_geo(i)),
-                },
-                LargeMultiPoint(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_large_multi_point_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_large_multi_point_3d().value_as_geo(i)),
-                },
-                MultiLineString(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_multi_line_string_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_multi_line_string_3d().value_as_geo(i)),
-                },
-                LargeMultiLineString(_, dimension) => match dimension {
-                    Dimension::XY => {
-                        Value::from(&chunk.as_large_multi_line_string_2d().value_as_geo(i))
-                    }
-                    Dimension::XYZ => {
-                        Value::from(&chunk.as_large_multi_line_string_3d().value_as_geo(i))
-                    }
-                },
-                MultiPolygon(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_multi_polygon_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_multi_polygon_3d().value_as_geo(i)),
-                },
-                LargeMultiPolygon(_, dimension) => match dimension {
-                    Dimension::XY => {
-                        Value::from(&chunk.as_large_multi_polygon_2d().value_as_geo(i))
-                    }
-                    Dimension::XYZ => {
-                        Value::from(&chunk.as_large_multi_polygon_3d().value_as_geo(i))
-                    }
-                },
-                Mixed(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_mixed_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_mixed_3d().value_as_geo(i)),
-                },
-                LargeMixed(_, dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_large_mixed_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_large_mixed_3d().value_as_geo(i)),
-                },
-                GeometryCollection(_, dimension) => match dimension {
-                    Dimension::XY => {
-                        Value::from(&chunk.as_geometry_collection_2d().value_as_geo(i))
-                    }
-                    Dimension::XYZ => {
-                        Value::from(&chunk.as_geometry_collection_3d().value_as_geo(i))
-                    }
-                },
-                LargeGeometryCollection(_, dimension) => match dimension {
-                    Dimension::XY => {
-                        Value::from(&chunk.as_large_geometry_collection_2d().value_as_geo(i))
-                    }
-                    Dimension::XYZ => {
-                        Value::from(&chunk.as_large_geometry_collection_3d().value_as_geo(i))
-                    }
-                },
-                WKB => Value::from(&chunk.as_wkb().value_as_geo(i)),
-                LargeWKB => Value::from(&chunk.as_large_wkb().value_as_geo(i)),
-                Rect(dimension) => match dimension {
-                    Dimension::XY => Value::from(&chunk.as_rect_2d().value_as_geo(i)),
-                    Dimension::XYZ => Value::from(&chunk.as_rect_3d().value_as_geo(i)),
-                },
-            };
-            let mut row = json_rows
-                .next()
-                .expect("we shouldn't run out of rows before we're done");
-            let _ = row.insert(
-                "geometry".into(),
-                serde_json::to_value(geojson::Geometry::new(value))?,
-            );
-            let flat_item: FlatItem = serde_json::from_value(serde_json::Value::Object(row))?;
-            items.push(Item::try_from(flat_item)?);
-        }
-    }
-    Ok(items.into())
+    json::from_table(table)?
+        .into_iter()
+        .map(|item| serde_json::from_value(Value::Object(item)).map_err(Error::from))
+        .collect::<Result<Vec<_>>>()
+        .map(ItemCollection::from)
 }
 
 // We only run tests when the geoparquet feature is enabled so that we don't
diff --git a/core/src/item.rs b/core/src/item.rs
index 9557d51d..c947fe9d 100644
--- a/core/src/item.rs
+++ b/core/src/item.rs
@@ -611,27 +611,6 @@ impl Item {
     }
 }
 
-impl TryFrom<FlatItem> for Item {
-    type Error = Error;
-
-    fn try_from(flat_item: FlatItem) -> Result<Item> {
-        Ok(Item {
-            r#type: flat_item.r#type,
-            version: flat_item.version,
-            extensions: flat_item.extensions,
-            id: flat_item.id,
-            geometry: flat_item.geometry,
-            bbox: flat_item.bbox,
-            links: flat_item.links,
-            assets: flat_item.assets,
-            collection: flat_item.collection,
-            properties: serde_json::from_value(flat_item.properties.into())?,
-            additional_fields: Default::default(),
-            href: None,
-        })
-    }
-}
-
 impl Href for Item {
     fn href(&self) -> Option<&str> {
         self.href.as_deref()
@@ -773,7 +752,7 @@ mod tests {
     use super::{Builder, FlatItem, Item};
     use crate::{
         extensions::{Projection, Raster},
-        Asset, Extensions, Version, STAC_VERSION,
+        Asset, Extensions, STAC_VERSION,
     };
     use geojson::{feature::Id, Feature};
     use serde_json::Value;
@@ -992,25 +971,6 @@ mod tests {
         let _ = item.clone().into_flat_item(false).unwrap_err();
     }
 
-    #[test]
-    fn flat_item_into_item() {
-        use geojson::{Geometry, Value};
-
-        let flat_item = FlatItem {
-            r#type: "Feature".to_string(),
-            version: Version::v1_0_0,
-            extensions: Vec::new(),
-            id: "an-id".to_string(),
-            geometry: Some(Geometry::new(Value::Point(vec![-105.1, 41.1]))),
-            bbox: Some(vec![-105., 41., -105., 41.].try_into().unwrap()),
-            links: Vec::new(),
-            assets: Default::default(),
-            collection: None,
-            properties: Default::default(),
-        };
-        let _ = Item::try_from(flat_item).unwrap();
-    }
-
     #[test]
     fn flat_item_without_geometry() {
         let mut item = Item::new("an-item");
diff --git a/duckdb/Cargo.toml b/duckdb/Cargo.toml
new file mode 100644
index 00000000..e3c0546b
--- /dev/null
+++ b/duckdb/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "stac-duckdb"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+arrow = "52"
+duckdb = "1"
+geoarrow = { git = "https://github.com/geoarrow/geoarrow-rs", rev = "6b877486bf98f280bc04b589eb7ce25b20e629f0" }
+parquet = "52"
+serde_json = "1"
+stac = { version = "0.8", path = "../core", features = ["geoarrow"] }
+stac-api = { version = "0.4", path = "../api" }
+thiserror = "1"
+
+[dev-dependencies]
+duckdb-test = { path = "duckdb-test" }
+geo = "0.28"
diff --git a/duckdb/data/100-landsat-items.parquet b/duckdb/data/100-landsat-items.parquet
new file mode 100644
index 00000000..76ae0652
Binary files /dev/null and b/duckdb/data/100-landsat-items.parquet differ
diff --git a/duckdb/data/100-sentinel-2-items.parquet b/duckdb/data/100-sentinel-2-items.parquet
new file mode 100644
index 00000000..2ba25db8
Binary files /dev/null and b/duckdb/data/100-sentinel-2-items.parquet differ
diff --git a/duckdb/src/lib.rs b/duckdb/src/lib.rs
new file mode 100644
index 00000000..b57ea1fe
--- /dev/null
+++ b/duckdb/src/lib.rs
@@ -0,0 +1,501 @@
+//! Use [duckdb](https://duckdb.org/) with [STAC](https://stacspec.org).
+
+use arrow::array::{AsArray, RecordBatch};
+use duckdb::{types::Value, Connection};
+use geoarrow::table::Table;
+use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+use stac_api::{Direction, Search};
+use std::{
+    collections::{HashMap, HashSet},
+    fmt::Debug,
+    fs::File,
+    path::{Path, PathBuf},
+};
+use thiserror::Error;
+
+/// A crate-specific error enum.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Error {
+    /// [arrow::error::ArrowError]
+    #[error(transparent)]
+    Arrow(#[from] arrow::error::ArrowError),
+
+    /// [duckdb::Error]
+    #[error(transparent)]
+    DuckDB(#[from] duckdb::Error),
+
+    /// [geoarrow::error::GeoArrowError]
+    #[error(transparent)]
+    GeoArrow(#[from] geoarrow::error::GeoArrowError),
+
+    /// [std::io::Error]
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+
+    /// [parquet::errors::ParquetError]
+    #[error(transparent)]
+    Parquet(#[from] parquet::errors::ParquetError),
+
+    /// [stac::Error]
+    #[error(transparent)]
+    Stac(#[from] stac::Error),
+
+    /// [std::num::TryFromIntError]
+    #[error(transparent)]
+    TryFromInt(#[from] std::num::TryFromIntError),
+
+    /// Unimplemented feature.
+    #[error("unimplemented: {0}")]
+    Unimplemented(&'static str),
+}
+
+/// A crate-specific result type.
+pub type Result<T> = std::result::Result<T, Error>;
+
+/// A client for making DuckDB requests for STAC objects.
+#[derive(Debug)]
+pub struct Client {
+    connection: Connection,
+    collections: HashMap<String, Vec<PathBuf>>,
+}
+
+/// A SQL query.
+#[derive(Debug)]
+pub struct Sql {
+    /// The select.
+    pub select: Option<String>,
+
+    /// The query.
+    pub query: String,
+
+    /// The query parameters.
+    pub params: Vec<Value>,
+}
+
+struct Metadata {
+    collections: HashSet<String>,
+}
+
+impl Client {
+    /// Creates a new client with no data sources.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    ///
+    /// let client = Client::new().unwrap();
+    /// ```
+    pub fn new() -> Result<Client> {
+        let connection = Connection::open_in_memory()?;
+        connection.execute("INSTALL spatial", [])?;
+        connection.execute("LOAD spatial", [])?;
+        Ok(Client {
+            connection,
+            collections: HashMap::new(),
+        })
+    }
+
+    /// Adds a [stac-geoparquet](https://github.com/stac-utils/stac-geoparquet) path to this client.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    ///
+    /// let mut client = Client::new().unwrap();
+    /// client.add_path("data/100-sentinel-2-items.parquet").unwrap();
+    /// ```
+    pub fn add_path(&mut self, path: impl AsRef<Path>) -> Result<()> {
+        let path = path.as_ref();
+        let metadata = Metadata::from_path(path)?;
+        for collection in metadata.collections {
+            let entry = self.collections.entry(collection).or_default();
+            entry.push(path.to_path_buf());
+        }
+        Ok(())
+    }
+
+    /// Creates a new client from a path.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    ///
+    /// let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+    /// ```
+    pub fn from_path(path: impl AsRef<Path>) -> Result<Client> {
+        let mut client = Client::new()?;
+        client.add_path(path)?;
+        Ok(client)
+    }
+
+    /// Searches this client, returning a [stac::ItemCollection].
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    /// use stac_api::Search;
+    ///
+    /// let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+    /// let item_collection = client.search(Search::default()).unwrap();
+    /// assert_eq!(item_collection.items.len(), 100);
+    /// ```
+    pub fn search(&self, search: impl Into<Search>) -> Result<stac::ItemCollection> {
+        let mut items = Vec::new();
+        for record_batches in self
+            .search_to_arrow(search)?
+            .into_iter()
+            .filter(|r| !r.is_empty())
+        {
+            let schema = record_batches[0].schema();
+            let table = Table::try_new(record_batches, schema)?;
+            items.extend(stac::geoarrow::from_table(table)?.items);
+        }
+        Ok(items.into())
+    }
+
+    /// Searches this client, returning a vector of vectors of all matched record batches.
+    ///
+    /// # Examples
+    ///
+    /// Each inner grouping of record batches comes from the same source table:
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    /// use stac_api::Search;
+    ///
+    /// let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+    /// for record_batches in client.search_to_arrow(Search::default()).unwrap() {
+    ///     // Schema can be different between groups of record batches
+    ///     for record_batch in record_batches {
+    ///         // Each record batch in this scope will have the same schema
+    ///     }
+    /// }
+    /// ```
+    pub fn search_to_arrow(&self, search: impl Into<Search>) -> Result<Vec<Vec<RecordBatch>>> {
+        let mut record_batches = Vec::new();
+        let search = search.into();
+        let collections = search.collections.clone();
+        let sql = Sql::new(search)?;
+        for collection in collections.unwrap_or_else(|| self.collections.keys().cloned().collect())
+        {
+            if let Some(paths) = self.collections.get(&collection) {
+                for path in paths {
+                    let mut statement = format!(
+                        "SELECT {} FROM read_parquet('{}')",
+                        sql.select.as_deref().unwrap_or("*"),
+                        path.display()
+                    );
+                    if !sql.is_empty() {
+                        statement.push(' ');
+                        statement.push_str(&sql.query);
+                    }
+                    let mut statement = self.connection.prepare(&statement)?;
+                    record_batches.push(
+                        statement
+                            .query_arrow(duckdb::params_from_iter(&sql.params))?
+                            .collect(),
+                    );
+                }
+            }
+        }
+        Ok(record_batches)
+    }
+
+    /// Searches this client, returning a [stac_api::ItemCollection].
+    ///
+    /// Use this method if you want JSON that might not be valid STAC items,
+    /// e.g. if you've excluded required fields from the response.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use stac_duckdb::Client;
+    /// use stac_api::Search;
+    ///
+    /// let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+    /// let item_collection = client.search_to_json(Search::default()).unwrap();
+    /// assert_eq!(item_collection.items.len(), 100);
+    /// ```
+    pub fn search_to_json(&self, search: impl Into<Search>) -> Result<stac_api::ItemCollection> {
+        let mut items = Vec::new();
+        for record_batches in self
+            .search_to_arrow(search)?
+            .into_iter()
+            .filter(|r| !r.is_empty())
+        {
+            let schema = record_batches[0].schema();
+            let table = Table::try_new(record_batches, schema)?;
+            items.extend(stac::geoarrow::json::from_table(table)?);
+        }
+        Ok(items.into())
+    }
+}
+
+impl Metadata {
+    fn from_path(path: impl AsRef<Path>) -> Result<Metadata> {
+        let file = File::open(path)?;
+        let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
+        let reader = builder.build()?;
+        let mut collections = HashSet::new();
+        for result in reader {
+            let record_batch = result?;
+            if let Some(column) = record_batch
+                .column_by_name("collection")
+                .and_then(|column| column.as_string_opt::<i32>())
+            {
+                collections.extend(
+                    column
+                        .iter()
+                        .filter_map(|collection| collection.map(String::from)),
+                );
+            }
+        }
+        Ok(Metadata { collections })
+    }
+}
+
+impl Sql {
+    fn new(search: Search) -> Result<Sql> {
+        let mut wheres = Vec::new();
+        let mut params = Vec::new();
+        if let Some(ids) = search
+            .ids
+            .and_then(|ids| if ids.is_empty() { None } else { Some(ids) })
+        {
+            wheres.push(format!("id IN ({})", repeat_vars(ids.len())));
+            params.extend(ids.into_iter().map(Value::from));
+        }
+        if let Some(collections) =
+            search
+                .collections
+                .and_then(|c| if c.is_empty() { None } else { Some(c) })
+        {
+            wheres.push(format!(
+                "collection IN ({})",
+                repeat_vars(collections.len())
+            ));
+            params.extend(collections.into_iter().map(Value::from));
+        }
+        if let Some(intersects) = search.intersects {
+            wheres.push(format!(
+                "ST_Intersects(ST_GeomFromWKB(geometry), ST_GeomFromGeoJSON(?))"
+            ));
+            params.push(Value::from(intersects.to_string()));
+        }
+        if let Some(bbox) = search.items.bbox {
+            wheres.push(format!(
+                "ST_Intersects(ST_GeomFromWKB(geometry), ST_GeomFromGeoJSON(?))"
+            ));
+            params.push(Value::from(bbox.to_geometry().to_string()));
+        }
+        if let Some(datetime) = search.items.datetime {
+            // TODO support start and end datetimes
+            let (start, end) = stac::datetime::parse(&datetime)?;
+            if let Some(start) = start {
+                wheres.push(format!("datetime >= make_timestamp(?)"));
+                params.push(Value::from(start.timestamp_micros()));
+            }
+            if let Some(end) = end {
+                wheres.push(format!("datetime <= make_timestamp(?)"));
+                params.push(Value::from(end.timestamp_micros()));
+            }
+        }
+        let mut query = String::new();
+        if !wheres.is_empty() {
+            query.push_str("WHERE ");
+            query.push_str(&wheres.join(" AND "));
+        }
+        let mut select = None;
+        if let Some(fields) = search.items.fields {
+            // TODO protect against injection
+            if !fields.include.is_empty() {
+                select = Some(fields.include.join(","));
+            }
+            // TODO implement
+            if !fields.exclude.is_empty() {
+                return Err(Error::Unimplemented("fields.exclude"));
+            }
+        }
+        if let Some(sortby) = search.items.sortby {
+            query.push_str(" ORDER BY ");
+            let sortby: Vec<_> = sortby
+                .into_iter()
+                .map(|sortby| {
+                    format!(
+                        "{} {}",
+                        sortby.field,
+                        match sortby.direction {
+                            Direction::Ascending => "ASC",
+                            Direction::Descending => "DESC",
+                        }
+                    )
+                })
+                .collect();
+            query.push_str(&sortby.join(","));
+        }
+        if let Some(limit) = search.items.limit {
+            query.push_str(" LIMIT ");
+            query.push_str(&limit.to_string());
+        }
+        if search.items.filter.is_some() {
+            return Err(Error::Unimplemented("filter"));
+        }
+        if search.items.filter_crs.is_some() {
+            return Err(Error::Unimplemented("filter_crs"));
+        }
+        if search.items.query.is_some() {
+            return Err(Error::Unimplemented("query"));
+        }
+        Ok(Sql {
+            select,
+            query,
+            params,
+        })
+    }
+
+    fn is_empty(&self) -> bool {
+        self.query.is_empty()
+    }
+}
+
+fn repeat_vars(count: usize) -> String {
+    assert_ne!(count, 0);
+    let mut s = "?,".repeat(count);
+    s.pop();
+    s
+}
+
+#[cfg(test)]
+mod tests {
+    use super::Client;
+    use duckdb_test::duckdb_test;
+    use stac_api::{Direction, Fields, Items, Search, Sortby};
+    use std::sync::Mutex;
+
+    // This is an absolutely heinous way to ensure that only one test is hitting
+    // the DB at a time -- the MUTEX is used in the duckdb-test crate as part of
+    // the code generated by `duckdb_test`.
+    //
+    // There's got to be a better way.
+    static MUTEX: Mutex<()> = Mutex::new(());
+
+    #[duckdb_test]
+    fn search_all() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let item_collection = client.search(Search::default()).unwrap();
+        assert_eq!(item_collection.items.len(), 100);
+    }
+
+    #[duckdb_test]
+    fn search_ids() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let search = Search {
+            ids: Some(vec![
+                "S2B_MSIL2A_20240828T174909_R141_T13SEB_20240828T214916".to_string(),
+                "S2B_MSIL2A_20240828T174909_R141_T13SDD_20240828T214916".to_string(),
+            ]),
+            ..Default::default()
+        };
+        let item_collection = client.search(search).unwrap();
+        assert_eq!(item_collection.items.len(), 2);
+    }
+
+    #[duckdb_test]
+    fn search_collections() {
+        let mut client = Client::new().unwrap();
+        client
+            .add_path("data/100-sentinel-2-items.parquet")
+            .unwrap();
+        client.add_path("data/100-landsat-items.parquet").unwrap();
+        let search = Search {
+            collections: Some(vec!["sentinel-2-l2a".to_string()]),
+            ..Default::default()
+        };
+        let item_collection = client.search(search).unwrap();
+        assert_eq!(item_collection.items.len(), 100);
+    }
+
+    #[duckdb_test]
+    fn search_intersects() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let search = Search {
+            intersects: Some((&geo::point!(x: -105., y: 41.)).into()),
+            ..Default::default()
+        };
+        let item_collection = client.search(search).unwrap();
+        assert_eq!(item_collection.items.len(), 2);
+    }
+
+    #[duckdb_test]
+    fn search_limit() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let items = Items {
+            limit: Some(10),
+            ..Default::default()
+        };
+        let item_collection = client.search(items).unwrap();
+        assert_eq!(item_collection.items.len(), 10);
+    }
+
+    #[duckdb_test]
+    fn search_bbox() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let items = Items {
+            bbox: Some(vec![-105., 41., -104., 42.].try_into().unwrap()),
+            ..Default::default()
+        };
+        let item_collection = client.search(items).unwrap();
+        assert_eq!(item_collection.items.len(), 4);
+    }
+
+    #[duckdb_test]
+    fn search_datetime() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let items = Items {
+            datetime: Some("2024-08-29T00:00:00Z/2024-09-01T00:00:00Z".to_string()),
+            ..Default::default()
+        };
+        let item_collection = client.search(items).unwrap();
+        assert_eq!(item_collection.items.len(), 53);
+    }
+
+    #[duckdb_test]
+    fn search_fields() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let items = Items {
+            fields: Some(Fields {
+                include: vec!["id".to_string()],
+                exclude: Vec::new(),
+            }),
+            ..Default::default()
+        };
+        let item_collection = client.search_to_json(items).unwrap();
+        assert_eq!(item_collection.items.len(), 100);
+        assert_eq!(item_collection.items[0].keys().len(), 1);
+    }
+
+    #[duckdb_test]
+    fn search_sortby() {
+        let client = Client::from_path("data/100-sentinel-2-items.parquet").unwrap();
+        let items = Items {
+            sortby: Some(vec![Sortby {
+                field: "datetime".to_string(),
+                direction: Direction::Ascending,
+            }]),
+            ..Default::default()
+        };
+        let item_collection = client.search(items).unwrap();
+        for (a, b) in item_collection
+            .items
+            .iter()
+            .zip(item_collection.items.iter().skip(1))
+        {
+            assert!(a.properties.datetime <= b.properties.datetime);
+        }
+    }
+}
diff --git a/pgstac/src/client.rs b/pgstac/src/client.rs
index 3cb3594f..19fbc51d 100644
--- a/pgstac/src/client.rs
+++ b/pgstac/src/client.rs
@@ -560,12 +560,12 @@ mod tests {
         item.geometry = Some(longmont());
         client.add_item(item.clone()).await.unwrap();
         let mut search = Search::default();
-        search.items.bbox = Some(vec![-106., 40., -105., 41.]);
+        search.items.bbox = Some(vec![-106., 40., -105., 41.].try_into().unwrap());
         assert_eq!(
             client.search(search.clone()).await.unwrap().features.len(),
             1
         );
-        search.items.bbox = Some(vec![-106., 41., -105., 42.]);
+        search.items.bbox = Some(vec![-106., 41., -105., 42.].try_into().unwrap());
         assert!(client.search(search).await.unwrap().features.is_empty());
     }
 
diff --git a/server/src/backend/memory.rs b/server/src/backend/memory.rs
index 9c69f835..73923d74 100644
--- a/server/src/backend/memory.rs
+++ b/server/src/backend/memory.rs
@@ -83,7 +83,7 @@ impl Backend for MemoryBackend {
                 return Ok(None);
             }
         };
-        let search = items.into_search(collection_id);
+        let search = items.search_collection(collection_id);
         self.search(search).await.map(Some)
     }
 
diff --git a/server/src/backend/pgstac.rs b/server/src/backend/pgstac.rs
index be5b6f63..2e7957b1 100644
--- a/server/src/backend/pgstac.rs
+++ b/server/src/backend/pgstac.rs
@@ -68,7 +68,7 @@ impl Backend for PgstacBackend {
 
     async fn items(&self, collection_id: &str, items: Items) -> Result<Option<ItemCollection>> {
         // TODO should we check for collection existence?
-        let search = items.into_search(collection_id);
+        let search = items.search_collection(collection_id);
         self.search(search).await.map(Some)
     }
 


Test patch:
diff --git a/duckdb/duckdb-test/Cargo.toml b/duckdb/duckdb-test/Cargo.toml
new file mode 100644
index 00000000..5fe565a9
--- /dev/null
+++ b/duckdb/duckdb-test/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "duckdb-test"
+version = "0.0.0"
+edition = "2021"
+publish = false
+
+[lib]
+proc-macro = true
+test = false
+doctest = false
+
+[dependencies]
+quote = "1"
+syn = { version = "2", features = ["full", "extra-traits"] }
diff --git a/duckdb/duckdb-test/src/lib.rs b/duckdb/duckdb-test/src/lib.rs
new file mode 100644
index 00000000..8b905eaf
--- /dev/null
+++ b/duckdb/duckdb-test/src/lib.rs
@@ -0,0 +1,22 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::ItemFn;
+
+#[proc_macro_attribute]
+pub fn duckdb_test(_args: TokenStream, input: TokenStream) -> TokenStream {
+    let ast = syn::parse(input).unwrap();
+    impl_duckdb_test(ast)
+}
+
+fn impl_duckdb_test(ast: ItemFn) -> TokenStream {
+    let ident = &ast.sig.ident;
+    let gen = quote! {
+        #[test]
+        fn #ident() {
+            let _mutex = MUTEX.lock().unwrap();
+            #ast
+            #ident();
+        }
+    };
+    gen.into()
+}