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

Issue:
stac-server



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2e78bf89..c175f86a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,26 @@ jobs:
           sudo apt-get install libgdal-dev
       - name: Test
         run: cargo test -p stac -p stac-cli  # gdal is default for stac-cli
+  test-ubuntu-with-pgstac:
+    runs-on: ubuntu-latest
+    services:
+      pgstac:
+        image: ghcr.io/stac-utils/pgstac:v0.8.5
+        env:
+          POSTGRES_USER: username
+          POSTGRES_PASSWORD: password
+          POSTGRES_DB: postgis
+          PGUSER: username
+          PGPASSWORD: password
+          PGDATABASE: postgis
+        ports:
+          - 5432:5432
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set up Rust cache
+        uses: Swatinem/rust-cache@v2
+      - name: Test
+        run: cargo test -p pgstac
   test-windows:
     runs-on: windows-latest
     steps:
diff --git a/Cargo.toml b/Cargo.toml
index 29da4d0e..b65e50ee 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,14 @@
 [workspace]
 resolver = "2"
 members = [
+    "stac",
+    "pgstac",
+    "stac-api",
+    "stac-async",
+    "stac-cli",
+    "stac-validate",
+]
+default-members = [
     "stac",
     "stac-api",
     "stac-async",
diff --git a/README.md b/README.md
index b231c7bf..6ac6b2dc 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,6 @@ See [RELEASING.md](./RELEASING.md) for a checklist to use when releasing a new v
 
 Here's some related projects that use this repo:
 
-- [pgstac-rs](https://github.com/stac-utils/pgstac-rs): Rust interface for [pgstac](https://github.com/stac-utils/pgstac), PostgreSQL schema and functions for STAC
 - [stac-server-rs](https://github.com/gadomski/stac-server-rs): A STAC API server implementation
 
 ## License
diff --git a/pgstac/CHANGELOG.md b/pgstac/CHANGELOG.md
new file mode 100644
index 00000000..bc654241
--- /dev/null
+++ b/pgstac/CHANGELOG.md
@@ -0,0 +1,53 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Changed
+
+- Moved from <https://github.com/stac-utils/pgstac-rs> to the <https://github.com/stac-utils/stac-rs> monorepo ([#246](https://github.com/stac-utils/stac-rs/pull/246))
+
+## [0.0.6] - 2024-04-20
+
+- Bump **stac** version to v0.6
+- Bump **pgstac** version to v0.8.5
+
+## [0.0.5] - 2023-09-25
+
+- Bump **stac-api** version to v0.3.0
+
+## [0.0.4] - 2023-07-07
+
+- Bump **stac** version to v0.5
+- Bump **pgstac** version to v0.6.13 ([#2](https://github.com/stac-utils/pgstac-rs/pull/2))
+
+## [0.0.3] - 2023-01-08
+
+### Changed
+
+- `Client` now takes a reference to a generic client, instead of owning it
+
+### Removed
+
+- `Client::into_inner`
+
+## [0.0.2] - 2023-01-08
+
+### Changed
+
+- Make `Error`, `Result`, and `Context` publicly visible
+
+## [0.0.1] - 2023-01-07
+
+Initial release
+
+[unreleased]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.6...HEAD
+[0.0.6]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.5...v0.0.6
+[0.0.5]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.4...v0.0.5
+[0.0.4]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.3...v0.0.4
+[0.0.3]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.2...v0.0.3
+[0.0.2]: https://github.com/stac-utils/pgstac-rs/compare/v0.0.1...v0.0.2
+[0.0.1]: https://github.com/stac-utils/pgstac-rs/tree/v0.0.1
diff --git a/pgstac/Cargo.toml b/pgstac/Cargo.toml
new file mode 100644
index 00000000..75342421
--- /dev/null
+++ b/pgstac/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pgstac"
+version = "0.0.6"
+authors = ["Pete Gadomski <pete.gadomski@gmail.com>"]
+edition = "2021"
+description = "Rust interface for pgstac"
+homepage = "https://github.com/stac-utils/stac-rs"
+repository = "https://github.com/stac-utils/stac-rs"
+license = "MIT OR Apache-2.0"
+keywords = ["geospatial", "stac", "metadata", "raster", "database"]
+categories = ["database", "data-structures", "science"]
+
+[dependencies]
+geojson = "0.24"
+serde = "1"
+serde_json = "1"
+stac = { version = "0.6", path = "../stac" }
+stac-api = { version = "0.3", path = "../stac-api" }
+thiserror = "1"
+tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
+
+[dev-dependencies]
+pgstac-test = { path = "pgstac-test" }
+tokio = { version = "1.23", features = ["rt-multi-thread", "macros"] }
+tokio-test = "0.4"
diff --git a/pgstac/README.md b/pgstac/README.md
new file mode 100644
index 00000000..e7685ad5
--- /dev/null
+++ b/pgstac/README.md
@@ -0,0 +1,45 @@
+# pgstac
+
+[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/stac-utils/stac-rs/ci.yml?branch=main&style=for-the-badge)](https://github.com/stac-utils/stac-rs/actions/workflows/ci.yml)
+[![docs.rs](https://img.shields.io/docsrs/pgstac?style=for-the-badge)](https://docs.rs/pgstac/latest/pgstac/)
+[![Crates.io](https://img.shields.io/crates/v/pgstac?style=for-the-badge)](https://crates.io/crates/pgstac)
+[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg?style=for-the-badge)](./CODE_OF_CONDUCT)
+
+Rust interface for [pgstac](https://github.com/stac-utils/pgstac).
+
+## Usage
+
+In your `Cargo.toml`:
+
+```toml
+[dependencies]
+pgstac = "0.0.6"
+```
+
+See the [documentation](https://docs.rs/pgstac) for more.
+
+## Testing
+
+**pgstac** needs a blank **pgstac** database for testing, so is not part of the default workspace build.
+To test:
+
+```shell
+docker-compose -f pgstac/docker-compose.yml up -d
+cargo test -p pgstac
+docker-compose -f pgstac/docker-compose.yml down
+```
+
+Each test is run in its own transaction, which is rolled back after the test.
+
+### Customizing the test database connection
+
+By default, the tests will connect to the database at `postgresql://username:password@localhost:5432/postgis`.
+If you need to customize the connection information for whatever reason, set your `PGSTAC_RS_TEST_DB` environment variable:
+
+```shell
+PGSTAC_RS_TEST_DB=postgresql://otherusername:otherpassword@otherhost:7822/otherdbname cargo test
+```
+
+## Other info
+
+This crate is part of the [stac-rs](https://github.com/stac-utils/stac-rs) monorepo, see its README for contributing and license information.
diff --git a/pgstac/docker-compose.yml b/pgstac/docker-compose.yml
new file mode 100644
index 00000000..2f02418a
--- /dev/null
+++ b/pgstac/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+  database:
+    container_name: stac-rs
+    image: ghcr.io/stac-utils/pgstac:v0.8.5
+    environment:
+      - POSTGRES_USER=username
+      - POSTGRES_PASSWORD=password
+      - POSTGRES_DB=postgis
+      - PGUSER=username
+      - PGPASSWORD=password
+      - PGDATABASE=postgis
+    ports:
+      - "5432:5432"
+    command: postgres -N 500
diff --git a/pgstac/src/client.rs b/pgstac/src/client.rs
new file mode 100644
index 00000000..57c1b484
--- /dev/null
+++ b/pgstac/src/client.rs
@@ -0,0 +1,777 @@
+use crate::{Error, Page, Result};
+use serde::de::DeserializeOwned;
+use stac::{Collection, Item};
+use stac_api::Search;
+use tokio_postgres::{
+    types::{ToSql, WasNull},
+    GenericClient, Row,
+};
+
+/// A **pgstac** client.
+///
+/// Not every **pgstac** function is provided, and some names are changed to
+/// match Rust conventions.
+///
+/// We don't own the inner client because we want to be able to work with
+/// references, e.g. those returned by
+/// [bb8_postgres](https://github.com/djc/bb8).
+#[derive(Debug)]
+pub struct Client<'a, C>(&'a C)
+where
+    C: GenericClient;
+
+impl<'a, C: GenericClient> Client<'a, C> {
+    /// Creates a new client.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    ///
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (mut client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// # });
+    /// ```
+    pub fn new(client: &C) -> Client<C> {
+        Client(client)
+    }
+
+    /// Returns the **pgstac** version.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (mut client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// let version = client.version().await.unwrap();
+    /// # });
+    /// ```
+    pub async fn version(&self) -> Result<String> {
+        self.string("get_version", &[]).await
+    }
+
+    /// Returns the value of the `context` **pgstac** setting.
+    ///
+    /// This setting defaults to "off".  See [the **pgstac**
+    /// docs](https://github.com/stac-utils/pgstac/blob/main/docs/src/pgstac.md#pgstac-settings)
+    /// for more information on the settings and their meaning.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (mut client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// assert!(!client.context().await.unwrap());
+    /// # });
+    /// ```
+    pub async fn context(&self) -> Result<bool> {
+        self.string("get_setting", &[&"context"])
+            .await
+            .map(|value| value == "on")
+    }
+
+    /// Sets the value of the `context` **pgstac** setting.
+    ///
+    /// This setting defaults to "off".  See [the **pgstac**
+    /// docs](https://github.com/stac-utils/pgstac/blob/main/docs/src/pgstac.md#pgstac-settings)
+    /// for more information on the settings and their meaning.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (mut client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// client.set_context(true).await.unwrap();
+    /// # });
+    /// ```
+    pub async fn set_context(&self, enable: bool) -> Result<()> {
+        let value = if enable { "on" } else { "off" };
+        self.0.execute(
+            "INSERT INTO pgstac_settings (name, value) VALUES ('context', $1) ON CONFLICT ON CONSTRAINT pgstac_settings_pkey DO UPDATE SET value = excluded.value;",
+            &[&value],
+        ).await.map(|_| ()).map_err(Error::from)
+    }
+
+    /// Fetches all collections.
+    pub async fn collections(&self) -> Result<Vec<Collection>> {
+        self.vec("all_collections", &[]).await
+    }
+
+    /// Fetches a collection by id.
+    pub async fn collection(&self, id: &str) -> Result<Option<Collection>> {
+        self.opt("get_collection", &[&id]).await
+    }
+
+    /// Adds a collection.
+    pub async fn add_collection(&self, collection: Collection) -> Result<()> {
+        let collection = serde_json::to_value(collection)?;
+        self.void("create_collection", &[&collection]).await
+    }
+
+    /// Adds or updates a collection.
+    pub async fn upsert_collection(&self, collection: Collection) -> Result<()> {
+        let collection = serde_json::to_value(collection)?;
+        self.void("upsert_collection", &[&collection]).await
+    }
+
+    /// Updates a collection.
+    pub async fn update_collection(&self, collection: Collection) -> Result<()> {
+        let collection = serde_json::to_value(collection)?;
+        self.void("update_collection", &[&collection]).await
+    }
+
+    /// Deletes a collection.
+    pub async fn delete_collection(&self, id: &str) -> Result<()> {
+        self.void("delete_collection", &[&id]).await
+    }
+
+    /// Fetches an item.
+    pub async fn item(&self, id: &str, collection: &str) -> Result<Option<Item>> {
+        self.opt("get_item", &[&id, &collection]).await
+    }
+
+    /// Adds an item.
+    pub async fn add_item(&self, item: Item) -> Result<()> {
+        let item = serde_json::to_value(item)?;
+        self.void("create_item", &[&item]).await
+    }
+
+    /// Adds items.
+    pub async fn add_items(&self, items: &[Item]) -> Result<()> {
+        let items = serde_json::to_value(items)?;
+        self.void("create_items", &[&items]).await
+    }
+
+    /// Updates an item.
+    pub async fn update_item(&self, item: Item) -> Result<()> {
+        let item = serde_json::to_value(item)?;
+        self.void("update_item", &[&item]).await
+    }
+
+    /// Upserts an item.
+    pub async fn upsert_item(&self, item: Item) -> Result<()> {
+        let item = serde_json::to_value(item)?;
+        self.void("upsert_item", &[&item]).await
+    }
+
+    /// Upserts items.
+    pub async fn upsert_items(&self, items: &[Item]) -> Result<()> {
+        let items = serde_json::to_value(items)?;
+        self.void("upsert_items", &[&items]).await
+    }
+
+    /// Searches for items.
+    pub async fn search(&self, search: Search) -> Result<Page> {
+        let search = serde_json::to_value(search)?;
+        self.value("search", &[&search]).await
+    }
+
+    async fn query_one(
+        &self,
+        function: &str,
+        params: &[&(dyn ToSql + Sync)],
+    ) -> std::result::Result<Row, tokio_postgres::Error> {
+        let param_string = (0..params.len())
+            .map(|i| format!("${}", i + 1))
+            .collect::<Vec<_>>()
+            .join(", ");
+        let query = format!("SELECT * from pgstac.{}({})", function, param_string);
+        self.0.query_one(&query, params).await
+    }
+
+    async fn string(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result<String> {
+        let row = self.query_one(function, params).await?;
+        row.try_get(function).map_err(Error::from)
+    }
+
+    async fn vec<T>(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result<Vec<T>>
+    where
+        T: DeserializeOwned,
+    {
+        if let Some(value) = self.opt(function, params).await? {
+            Ok(value)
+        } else {
+            Ok(Vec::new())
+        }
+    }
+
+    async fn opt<T>(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result<Option<T>>
+    where
+        T: DeserializeOwned,
+    {
+        match self.value(function, params).await {
+            Ok(value) => Ok(value),
+            Err(err) => match err {
+                Error::TokioPostgres(err) => {
+                    if let Some(err) = err.into_source() {
+                        if err.downcast_ref::<WasNull>().is_some() {
+                            Ok(None)
+                        } else {
+                            Err(Error::from(err))
+                        }
+                    } else {
+                        Err(Error::Unknown)
+                    }
+                }
+                _ => Err(err),
+            },
+        }
+    }
+
+    async fn value<T>(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result<T>
+    where
+        T: DeserializeOwned,
+    {
+        let row = self.query_one(function, params).await?;
+        let value = row.try_get(function)?;
+        serde_json::from_value(value).map_err(Error::from)
+    }
+
+    async fn void(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result<()> {
+        let _ = self.query_one(function, params).await?;
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::Client;
+    use geojson::{Geometry, Value};
+    use pgstac_test::pgstac_test;
+    use serde_json::{json, Map};
+    use stac::{Collection, Item};
+    use stac_api::{Fields, Filter, Search, Sortby};
+    use std::sync::Mutex;
+    use tokio_postgres::Transaction;
+
+    // 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 pgstac-test crate as part of
+    // the code generated by `pgstac_test`.
+    //
+    // There's got to be a better way.
+    static MUTEX: Mutex<()> = Mutex::new(());
+
+    fn longmont() -> Geometry {
+        Geometry::new(Value::Point(vec![-105.1019, 40.1672]))
+    }
+
+    #[pgstac_test]
+    async fn version(client: &Client<'_, Transaction<'_>>) {
+        let _ = client.version().await.unwrap();
+    }
+
+    #[pgstac_test]
+    async fn context(client: &Client<'_, Transaction<'_>>) {
+        assert!(!client.context().await.unwrap());
+    }
+
+    #[pgstac_test]
+    async fn set_context(client: &Client<'_, Transaction<'_>>) {
+        client.set_context(true).await.unwrap();
+        assert!(client.context().await.unwrap());
+    }
+
+    #[pgstac_test]
+    async fn collections(client: &Client<'_, Transaction<'_>>) {
+        assert!(client.collections().await.unwrap().is_empty());
+        client
+            .add_collection(Collection::new("an-id", "a description"))
+            .await
+            .unwrap();
+        assert_eq!(client.collections().await.unwrap().len(), 1);
+    }
+
+    #[pgstac_test]
+    async fn add_collection_duplicate(client: &Client<'_, Transaction<'_>>) {
+        assert!(client.collections().await.unwrap().is_empty());
+        let collection = Collection::new("an-id", "a description");
+        client.add_collection(collection.clone()).await.unwrap();
+        assert!(client.add_collection(collection).await.is_err());
+    }
+
+    #[pgstac_test]
+    async fn upsert_collection(client: &Client<'_, Transaction<'_>>) {
+        assert!(client.collections().await.unwrap().is_empty());
+        let mut collection = Collection::new("an-id", "a description");
+        client.upsert_collection(collection.clone()).await.unwrap();
+        collection.title = Some("a title".to_string());
+        client.upsert_collection(collection).await.unwrap();
+        assert_eq!(
+            client
+                .collection("an-id")
+                .await
+                .unwrap()
+                .unwrap()
+                .title
+                .unwrap(),
+            "a title"
+        );
+    }
+
+    #[pgstac_test]
+    async fn update_collection(client: &Client<'_, Transaction<'_>>) {
+        let mut collection = Collection::new("an-id", "a description");
+        client.add_collection(collection.clone()).await.unwrap();
+        assert!(client
+            .collection("an-id")
+            .await
+            .unwrap()
+            .unwrap()
+            .title
+            .is_none());
+        collection.title = Some("a title".to_string());
+        client.update_collection(collection).await.unwrap();
+        assert_eq!(client.collections().await.unwrap().len(), 1);
+        assert_eq!(
+            client
+                .collection("an-id")
+                .await
+                .unwrap()
+                .unwrap()
+                .title
+                .unwrap(),
+            "a title"
+        );
+    }
+
+    #[pgstac_test]
+    async fn update_collection_does_not_exit(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("an-id", "a description");
+        assert!(client.update_collection(collection).await.is_err());
+    }
+
+    #[pgstac_test]
+    async fn collection_not_found(client: &Client<'_, Transaction<'_>>) {
+        assert!(client.collection("not-an-id").await.unwrap().is_none());
+    }
+
+    #[pgstac_test]
+    async fn delete_collection(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("an-id", "a description");
+        client.add_collection(collection.clone()).await.unwrap();
+        assert!(client.collection("an-id").await.unwrap().is_some());
+        client.delete_collection("an-id").await.unwrap();
+        assert!(client.collection("an-id").await.unwrap().is_none());
+    }
+
+    #[pgstac_test]
+    async fn delete_collection_does_not_exist(client: &Client<'_, Transaction<'_>>) {
+        assert!(client.delete_collection("not-an-id").await.is_err());
+    }
+
+    #[pgstac_test]
+    async fn item(client: &Client<'_, Transaction<'_>>) {
+        assert!(client
+            .item("an-id", "collection-id")
+            .await
+            .unwrap()
+            .is_none());
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        assert_eq!(
+            client
+                .item("an-id", "collection-id")
+                .await
+                .unwrap()
+                .unwrap(),
+            item
+        );
+    }
+
+    #[pgstac_test]
+    async fn item_without_collection(client: &Client<'_, Transaction<'_>>) {
+        let item = Item::new("an-id");
+        assert!(client.add_item(item.clone()).await.is_err());
+    }
+
+    #[pgstac_test]
+    async fn update_item(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        item.properties
+            .additional_fields
+            .insert("foo".into(), "bar".into());
+        client.update_item(item).await.unwrap();
+        assert_eq!(
+            client
+                .item("an-id", "collection-id")
+                .await
+                .unwrap()
+                .unwrap()
+                .properties
+                .additional_fields["foo"],
+            "bar"
+        );
+    }
+
+    #[pgstac_test]
+    async fn upsert_item(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.upsert_item(item.clone()).await.unwrap();
+        client.upsert_item(item).await.unwrap();
+    }
+
+    #[pgstac_test]
+    async fn add_items(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        let mut other_item = item.clone();
+        other_item.id = "other-id".to_string();
+        client.add_items(&[item, other_item]).await.unwrap();
+        assert!(client
+            .item("an-id", "collection-id")
+            .await
+            .unwrap()
+            .is_some());
+        assert!(client
+            .item("other-id", "collection-id")
+            .await
+            .unwrap()
+            .is_some());
+    }
+
+    #[pgstac_test]
+    async fn upsert_items(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        let mut other_item = item.clone();
+        other_item.id = "other-id".to_string();
+        let items = vec![item, other_item];
+        client.upsert_items(&items).await.unwrap();
+        client.upsert_items(&items).await.unwrap();
+    }
+
+    #[pgstac_test]
+    async fn search_everything(client: &Client<'_, Transaction<'_>>) {
+        assert!(client
+            .search(Search::default())
+            .await
+            .unwrap()
+            .features
+            .is_empty());
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        assert_eq!(
+            client.search(Search::default()).await.unwrap().features[0],
+            *serde_json::to_value(item).unwrap().as_object().unwrap()
+        );
+    }
+
+    #[pgstac_test]
+    async fn search_ids(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        let search = Search {
+            ids: Some(vec!["an-id".to_string()]),
+            ..Default::default()
+        };
+        assert_eq!(client.search(search).await.unwrap().features.len(), 1);
+        let search = Search {
+            ids: Some(vec!["not-an-id".to_string()]),
+            ..Default::default()
+        };
+        assert!(client.search(search).await.unwrap().features.is_empty());
+    }
+
+    #[pgstac_test]
+    async fn search_collections(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        let search = Search {
+            collections: Some(vec!["collection-id".to_string()]),
+            ..Default::default()
+        };
+        assert_eq!(client.search(search).await.unwrap().features.len(), 1);
+        let search = Search {
+            collections: Some(vec!["not-an-id".to_string()]),
+            ..Default::default()
+        };
+        assert!(client.search(search).await.unwrap().features.is_empty());
+    }
+
+    #[pgstac_test]
+    async fn search_limit(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        item.id = "another-id".to_string();
+        client.add_item(item).await.unwrap();
+        let search = Search {
+            limit: Some(1),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features.len(), 1);
+        assert_eq!(page.context.limit.unwrap(), 1);
+    }
+
+    #[pgstac_test]
+    async fn search_bbox(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        let search = Search {
+            bbox: Some(vec![-106., 40., -105., 41.]),
+            ..Default::default()
+        };
+        assert_eq!(client.search(search).await.unwrap().features.len(), 1);
+        let search = Search {
+            bbox: Some(vec![-106., 41., -105., 42.]),
+            ..Default::default()
+        };
+        assert!(client.search(search).await.unwrap().features.is_empty());
+    }
+
+    #[pgstac_test]
+    async fn search_datetime(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        item.properties.datetime = Some("2023-01-07T00:00:00Z".to_string());
+        client.add_item(item.clone()).await.unwrap();
+        let search = Search {
+            datetime: Some("2023-01-07T00:00:00Z".to_string()),
+            ..Default::default()
+        };
+        assert_eq!(client.search(search).await.unwrap().features.len(), 1);
+        let search = Search {
+            datetime: Some("2023-01-08T00:00:00Z".to_string()),
+            ..Default::default()
+        };
+        assert!(client.search(search).await.unwrap().features.is_empty());
+    }
+
+    #[pgstac_test]
+    async fn search_intersects(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        let search = Search {
+            intersects: Some(
+                serde_json::from_value(
+                    serde_json::to_value(Geometry::new(Value::Polygon(vec![vec![
+                        vec![-106., 40.],
+                        vec![-106., 41.],
+                        vec![-105., 41.],
+                        vec![-105., 40.],
+                        vec![-106., 40.],
+                    ]])))
+                    .unwrap(),
+                )
+                .unwrap(),
+            ),
+            ..Default::default()
+        };
+        assert_eq!(client.search(search).await.unwrap().features.len(), 1);
+        let search = Search {
+            intersects: Some(
+                serde_json::from_value(
+                    serde_json::to_value(Geometry::new(Value::Polygon(vec![vec![
+                        vec![-104., 40.],
+                        vec![-104., 41.],
+                        vec![-103., 41.],
+                        vec![-103., 40.],
+                        vec![-104., 40.],
+                    ]])))
+                    .unwrap(),
+                )
+                .unwrap(),
+            ),
+            ..Default::default()
+        };
+        assert!(client.search(search).await.unwrap().features.is_empty());
+    }
+
+    #[pgstac_test]
+    async fn pagination(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.properties.datetime = Some("2023-01-08T00:00:00Z".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        item.id = "another-id".to_string();
+        item.properties.datetime = Some("2023-01-07T00:00:00Z".to_string());
+        client.add_item(item).await.unwrap();
+        let mut search = Search {
+            limit: Some(1),
+            ..Default::default()
+        };
+        let page = client.search(search.clone()).await.unwrap();
+        assert_eq!(page.features[0]["id"], "an-id");
+        search
+            .additional_fields
+            .insert("token".to_string(), page.next_token().into());
+        let page = client.search(search.clone()).await.unwrap();
+        assert_eq!(page.features[0]["id"], "another-id");
+        search
+            .additional_fields
+            .insert("token".to_string(), page.prev_token().into());
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features[0]["id"], "an-id");
+    }
+
+    #[pgstac_test]
+    async fn fields(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("an-id");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        item.properties
+            .additional_fields
+            .insert("foo".into(), 42.into());
+        item.properties
+            .additional_fields
+            .insert("bar".into(), 43.into());
+        client.add_item(item).await.unwrap();
+        let search = Search {
+            fields: Some(Fields {
+                include: vec!["properties.foo".to_string()],
+                exclude: vec!["properties.bar".to_string()],
+            }),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        let item = &page.features[0];
+        assert!(item["properties"].as_object().unwrap().get("foo").is_some());
+        assert!(item["properties"].as_object().unwrap().get("bar").is_none());
+    }
+
+    #[pgstac_test]
+    async fn sortby(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("a");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        client.add_item(item.clone()).await.unwrap();
+        item.id = "b".to_string();
+        client.add_item(item).await.unwrap();
+        let search = Search {
+            sortby: Some(vec![Sortby::asc("id")]),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features[0]["id"], "a");
+        assert_eq!(page.features[1]["id"], "b");
+
+        let search = Search {
+            sortby: Some(vec![Sortby::desc("id")]),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features[0]["id"], "b");
+        assert_eq!(page.features[1]["id"], "a");
+    }
+
+    #[pgstac_test]
+    async fn filter(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("a");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        item.properties
+            .additional_fields
+            .insert("foo".into(), 42.into());
+        client.add_item(item.clone()).await.unwrap();
+        item.id = "b".to_string();
+        item.properties
+            .additional_fields
+            .insert("foo".into(), 43.into());
+        client.add_item(item).await.unwrap();
+        let mut filter = Map::new();
+        filter.insert("op".into(), "=".into());
+        filter.insert("args".into(), json!([{"property": "foo"}, 42]));
+        let search = Search {
+            filter: Some(Filter::Cql2Json(filter)),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features.len(), 1);
+    }
+
+    #[pgstac_test]
+    async fn query(client: &Client<'_, Transaction<'_>>) {
+        let collection = Collection::new("collection-id", "a description");
+        client.add_collection(collection).await.unwrap();
+        let mut item = Item::new("a");
+        item.collection = Some("collection-id".to_string());
+        item.geometry = Some(longmont());
+        item.properties
+            .additional_fields
+            .insert("foo".into(), 42.into());
+        client.add_item(item.clone()).await.unwrap();
+        item.id = "b".to_string();
+        item.properties
+            .additional_fields
+            .insert("foo".into(), 43.into());
+        client.add_item(item).await.unwrap();
+        let mut query = Map::new();
+        query.insert("foo".into(), json!({"eq": 42}));
+        let search = Search {
+            query: Some(query),
+            ..Default::default()
+        };
+        let page = client.search(search).await.unwrap();
+        assert_eq!(page.features.len(), 1);
+    }
+}
diff --git a/pgstac/src/lib.rs b/pgstac/src/lib.rs
new file mode 100644
index 00000000..504e232a
--- /dev/null
+++ b/pgstac/src/lib.rs
@@ -0,0 +1,67 @@
+//! Rust interface for [pgstac](https://github.com/stac-utils/pgstac)
+//!
+//! # Examples
+//!
+//! [Client] provides an interface to query a **pgstac** database.
+//! It can be created from anything that implements [tokio_postgres::GenericClient].
+//!
+//! ```
+//! use pgstac::Client;
+//! use tokio_postgres::NoTls;
+//!
+//! # tokio_test::block_on(async {
+//! let config = "postgresql://username:password@localhost:5432/postgis";
+//! let (client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+//! let client = Client::new(&client);
+//! # })
+//! ```
+//!
+//! If you want to work in a transaction, you can do that too:
+//!
+//! ```no_run
+//! use stac::Collection;
+//! # use pgstac::Client;
+//! # use tokio_postgres::NoTls;
+//! # tokio_test::block_on(async {
+//! # let config = "postgresql://username:password@localhost:5432/postgis";
+//! let (mut client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+//! let transaction = client.transaction().await.unwrap();
+//! let client = Client::new(&transaction);
+//! client.add_collection(Collection::new("an-id", "a description")).await.unwrap();
+//! transaction.commit().await.unwrap();
+//! # })
+//! ```
+
+#![deny(missing_docs)]
+
+mod client;
+mod page;
+
+pub use {client::Client, page::Page};
+
+/// Crate-specific error enum.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    /// A boxed error.
+    ///
+    /// Used to capture generic errors from [tokio_postgres::types::FromSql].
+    #[error(transparent)]
+    Boxed(#[from] Box<dyn std::error::Error + Sync + Send>),
+
+    /// [serde_json::Error]
+    #[error(transparent)]
+    SerdeJson(#[from] serde_json::Error),
+
+    /// [tokio_postgres::Error]
+    #[error(transparent)]
+    TokioPostgres(#[from] tokio_postgres::Error),
+
+    /// An unknown error.
+    ///
+    /// Used when [tokio_postgres::types::FromSql] doesn't have a source.
+    #[error("unknown error")]
+    Unknown,
+}
+
+/// Crate-specific result type.
+pub type Result<T> = std::result::Result<T, Error>;
diff --git a/pgstac/src/page.rs b/pgstac/src/page.rs
new file mode 100644
index 00000000..67876473
--- /dev/null
+++ b/pgstac/src/page.rs
@@ -0,0 +1,59 @@
+use serde::Deserialize;
+use stac_api::{Context, Item};
+
+/// A page of search results.
+#[derive(Debug, Deserialize)]
+pub struct Page {
+    /// These are the out features, usually STAC items, but maybe not legal STAC
+    /// items if fields are excluded.
+    pub features: Vec<Item>,
+
+    /// The next id.
+    pub next: Option<String>,
+
+    /// The previous id.
+    pub prev: Option<String>,
+
+    /// The search context.
+    pub context: Context,
+}
+
+impl Page {
+    /// Returns this page's next token, if it has one.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// let page = client.search(Default::default()).await.unwrap();
+    /// let next_token = page.next_token().unwrap();
+    /// # });
+    /// ```
+    pub fn next_token(&self) -> Option<String> {
+        self.next.as_ref().map(|next| format!("next:{}", next))
+    }
+
+    /// Returns this page's prev token, if it has one.
+    ///
+    /// # Examples
+    ///
+    /// ```no_run
+    /// use pgstac::Client;
+    /// use tokio_postgres::NoTls;
+    /// let config = "postgresql://username:password@localhost:5432/postgis";
+    /// # tokio_test::block_on(async {
+    /// let (client, connection) = tokio_postgres::connect(config, NoTls).await.unwrap();
+    /// let client = Client::new(&client);
+    /// let page = client.search(Default::default()).await.unwrap();
+    /// let prev_token = page.prev_token().unwrap();
+    /// # });
+    /// ```
+    pub fn prev_token(&self) -> Option<String> {
+        self.prev.as_ref().map(|prev| format!("prev:{}", prev))
+    }
+}


Test patch:
diff --git a/pgstac/pgstac-test/.gitignore b/pgstac/pgstac-test/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/pgstac/pgstac-test/.gitignore
@@ -0,0 +1,1 @@
+/target
diff --git a/pgstac/pgstac-test/Cargo.toml b/pgstac/pgstac-test/Cargo.toml
new file mode 100644
index 00000000..43bc32db
--- /dev/null
+++ b/pgstac/pgstac-test/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "pgstac-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"] }
+tokio-postgres = { version = "0.7" }
diff --git a/pgstac/pgstac-test/src/lib.rs b/pgstac/pgstac-test/src/lib.rs
new file mode 100644
index 00000000..a2e2ee5b
--- /dev/null
+++ b/pgstac/pgstac-test/src/lib.rs
@@ -0,0 +1,31 @@
+use proc_macro::TokenStream;
+use quote::quote;
+use syn::ItemFn;
+
+#[proc_macro_attribute]
+pub fn pgstac_test(_args: TokenStream, input: TokenStream) -> TokenStream {
+    let ast = syn::parse(input).unwrap();
+    impl_pgstac_test(ast)
+}
+
+fn impl_pgstac_test(ast: ItemFn) -> TokenStream {
+    let ident = &ast.sig.ident;
+    let gen = quote! {
+        #[tokio::test]
+        async fn #ident() {
+            let _mutex = MUTEX.lock().unwrap();
+            let config = std::env::var("PGSTAC_RS_TEST_DB")
+                .unwrap_or("postgresql://username:password@localhost:5432/postgis".to_string());
+            let (mut client, connection) = tokio_postgres::connect(&config, tokio_postgres::NoTls).await.unwrap();
+            tokio::spawn(async move {
+                connection.await.unwrap()
+            });
+            let transaction = client.transaction().await.unwrap();
+            let client = Client::new(&transaction);
+            #ast
+            #ident(&client).await;
+            transaction.rollback().await.unwrap();
+        }
+    };
+    gen.into()
+}