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 to the 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 "] +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/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 @@ +/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() +} 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 { + 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 { + 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 { + 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> { + self.vec("all_collections", &[]).await + } + + /// Fetches a collection by id. + pub async fn collection(&self, id: &str) -> Result> { + 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> { + 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 { + 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 { + let param_string = (0..params.len()) + .map(|i| format!("${}", i + 1)) + .collect::>() + .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 { + let row = self.query_one(function, params).await?; + row.try_get(function).map_err(Error::from) + } + + async fn vec(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result> + where + T: DeserializeOwned, + { + if let Some(value) = self.opt(function, params).await? { + Ok(value) + } else { + Ok(Vec::new()) + } + } + + async fn opt(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result> + 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::().is_some() { + Ok(None) + } else { + Err(Error::from(err)) + } + } else { + Err(Error::Unknown) + } + } + _ => Err(err), + }, + } + } + + async fn value(&self, function: &str, params: &[&(dyn ToSql + Sync)]) -> Result + 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), + + /// [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 = std::result::Result; 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, + + /// The next id. + pub next: Option, + + /// The previous id. + pub prev: Option, + + /// 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 { + 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 { + self.prev.as_ref().map(|prev| format!("prev:{}", prev)) + } +}