From a4a6b9ef67fa9a601bb72cccf12e0fbb0085d140 Mon Sep 17 00:00:00 2001 From: itowlson Date: Fri, 15 Aug 2025 15:26:13 +1200 Subject: [PATCH 1/5] Spin 3.4 SQLite and PostgreSQL interfaces Signed-off-by: itowlson --- Cargo.lock | 241 +++++- Cargo.toml | 3 + src/lib.rs | 11 +- src/pg.rs | 4 +- src/pg3.rs | 3 +- src/pg4.rs | 862 ++++++++++++++++++++++ src/sqlite3.rs | 345 +++++++++ wit/deps/keyvalue-2024-10-17/atomic.wit | 18 +- wit/deps/keyvalue-2024-10-17/world.wit | 2 +- wit/deps/spin-postgres@4.0.0/postgres.wit | 163 ++++ wit/deps/spin-sqlite@3.0.0/sqlite.wit | 60 ++ wit/deps/spin@3.0.0/world.wit | 15 + wit/deps/spin@3.2.0/world.wit | 16 + wit/world.wit | 4 +- 14 files changed, 1700 insertions(+), 47 deletions(-) create mode 100644 src/pg4.rs create mode 100644 src/sqlite3.rs create mode 100644 wit/deps/spin-postgres@4.0.0/postgres.wit create mode 100644 wit/deps/spin-sqlite@3.0.0/sqlite.wit create mode 100644 wit/deps/spin@3.0.0/world.wit create mode 100644 wit/deps/spin@3.2.0/world.wit diff --git a/Cargo.lock b/Cargo.lock index 16f1f7a..075c297 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,12 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-spin-redis" version = "0.1.0" @@ -169,7 +175,7 @@ dependencies = [ "cap-primitives", "cap-std", "io-lifetimes", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -198,7 +204,7 @@ dependencies = [ "maybe-owned", "rustix 1.0.8", "rustix-linux-procfs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "winx", ] @@ -209,7 +215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40" dependencies = [ "ambient-authority", - "rand", + "rand 0.8.5", ] [[package]] @@ -510,6 +516,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -573,9 +580,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -778,6 +791,18 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + [[package]] name = "gimli" version = "0.28.1" @@ -790,7 +815,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" dependencies = [ - "fallible-iterator", + "fallible-iterator 0.3.0", "indexmap", "stable_deref_trait", ] @@ -871,6 +896,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.11" @@ -1087,7 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" dependencies = [ "io-lifetimes", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1148,10 +1182,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.68" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1239,6 +1274,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.1" @@ -1432,6 +1477,45 @@ dependencies = [ "serde", ] +[[package]] +name = "postgres-protocol" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + +[[package]] +name = "postgres_range" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6dce28dc5ba143d8eb157b62aac01ae5a1c585c40792158b720e86a87642101" +dependencies = [ + "postgres-protocol", + "postgres-types", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1489,6 +1573,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1496,8 +1586,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", ] [[package]] @@ -1507,7 +1607,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", ] [[package]] @@ -1516,7 +1626,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.12", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", ] [[package]] @@ -1554,7 +1673,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" dependencies = [ - "getrandom", + "getrandom 0.2.12", "libredox", "thiserror 1.0.57", ] @@ -1621,7 +1740,7 @@ checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.12", "libc", "untrusted", "windows-sys 0.52.0", @@ -1692,6 +1811,16 @@ dependencies = [ "spin-sdk", ] +[[package]] +name = "rust_decimal" +version = "1.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" +dependencies = [ + "arrayvec", + "num-traits", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1727,7 +1856,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1783,6 +1912,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.17" @@ -2008,14 +2143,17 @@ dependencies = [ "http-body-util", "hyper 1.2.0", "once_cell", + "postgres_range", "reqwest", "routefinder", + "rust_decimal", "serde", "serde_json", "spin-executor", "spin-macro", "thiserror 1.0.57", "tokio", + "uuid", "wasi 0.13.1+wasi-0.2.0", "wasmtime", "wasmtime-wasi", @@ -2065,6 +2203,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2132,7 +2281,7 @@ dependencies = [ "fd-lock", "io-lifetimes", "rustix 0.38.31", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "winx", ] @@ -2404,6 +2553,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + [[package]] name = "unicode-width" version = "0.2.1" @@ -2435,9 +2590,13 @@ dependencies = [ [[package]] name = "uuid" -version = "1.7.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] name = "vcpkg" @@ -2475,6 +2634,15 @@ dependencies = [ "wit-bindgen-rt 0.24.0", ] +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt 0.39.0", +] + [[package]] name = "wasi-http-rust-streaming-outgoing-body" version = "0.1.0" @@ -2489,23 +2657,24 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.91" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.91" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.104", @@ -2526,9 +2695,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.91" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2536,9 +2705,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.91" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -2549,9 +2718,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.91" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-encoder" @@ -3057,7 +3229,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3302,6 +3474,15 @@ dependencies = [ "bitflags 2.4.2", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.4.2", +] + [[package]] name = "wit-bindgen-rt" version = "0.43.0" diff --git a/Cargo.toml b/Cargo.toml index 3dc9cd7..0b43e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ anyhow = "1" async-trait = "0.1.74" chrono = "0.4.38" form_urlencoded = "1.0" +rust_decimal = { version = "1.37.2", default-features = false } spin-executor = { version = "4.0.0", path = "crates/executor" } spin-macro = { version = "4.0.0", path = "crates/macro" } thiserror = "1.0.37" @@ -33,6 +34,8 @@ hyperium = { package = "http", version = "1.0.0" } serde_json = { version = "1.0.96", optional = true } serde = { version = "1.0.163", optional = true } wasi = { workspace = true } +uuid = "1.18.0" +postgres_range = "0.11.1" [features] default = ["export-sdk-language", "json"] diff --git a/src/lib.rs b/src/lib.rs index 710bc78..92022d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,8 +8,11 @@ mod test; /// Key/Value storage. pub mod key_value; -/// SQLite storage. +/// SQLite storage for Spin 2 and earlier. Applications that do not require +/// this backward compatibility should use the [`sqlite3`](crate::sqlite3) module instead. pub mod sqlite; +/// SQLite storage. +pub mod sqlite3; /// Large Language Model (Serverless AI) APIs pub mod llm; @@ -34,7 +37,9 @@ pub mod wit { generate_all, }); pub use fermyon::spin2_0_0 as v2; - pub use spin::postgres::postgres as pg3; + pub use spin::postgres3_0_0::postgres as pg3; + pub use spin::postgres4_0_0::postgres as pg4; + pub use spin::sqlite::sqlite as sqlite3; } #[export_name = concat!("spin-sdk-version-", env!("SDK_VERSION"))] @@ -56,8 +61,8 @@ pub mod mqtt; pub mod redis; pub mod pg; - pub mod pg3; +pub mod pg4; pub mod mysql; diff --git a/src/pg.rs b/src/pg.rs index 2c4df09..143b539 100644 --- a/src/pg.rs +++ b/src/pg.rs @@ -1,5 +1,5 @@ -//! Spin 2 Postgres relational database storage. Applications that do not require -//! Spin 2 support should use the [`pg3`](crate::pg3) module instead. +//! Postgres relational database storage for Spin 2 and earlier. Applications that do not require +//! this backward compatibility should use the [`pg4`](crate::pg4) module instead. //! //! Conversions between Rust, WIT and **Postgres** types. //! diff --git a/src/pg3.rs b/src/pg3.rs index 4a88dd6..e2ca6b9 100644 --- a/src/pg3.rs +++ b/src/pg3.rs @@ -1,4 +1,5 @@ -//! Postgres relational database storage. +//! Postgres relational database storage for Spin 3.3 and earlier. Applications that do not require +//! this backward compatibility should use the [`pg4`](crate::pg4) module instead. //! //! You can use the [`into()`](std::convert::Into) method to convert //! a Rust value into a [`ParameterValue`]. You can use the diff --git a/src/pg4.rs b/src/pg4.rs new file mode 100644 index 0000000..a08bb22 --- /dev/null +++ b/src/pg4.rs @@ -0,0 +1,862 @@ +// pg4 errors can be large, because they now include a breakdown of the PostgreSQL +// error fields instead of just a string +#![allow(clippy::result_large_err)] + +//! Postgres relational database storage. +//! +//! You can use the [`into()`](std::convert::Into) method to convert +//! a Rust value into a [`ParameterValue`]. You can use the +//! [`Decode`] trait to convert a [`DbValue`] to a suitable Rust type. +//! The following table shows available conversions. +//! +//! # Types +//! +//! | Rust type | WIT (db-value) | Postgres type(s) | +//! |-------------------------|-----------------------------------------------|----------------------------- | +//! | `bool` | boolean(bool) | BOOL | +//! | `i16` | int16(s16) | SMALLINT, SMALLSERIAL, INT2 | +//! | `i32` | int32(s32) | INT, SERIAL, INT4 | +//! | `i64` | int64(s64) | BIGINT, BIGSERIAL, INT8 | +//! | `f32` | floating32(float32) | REAL, FLOAT4 | +//! | `f64` | floating64(float64) | DOUBLE PRECISION, FLOAT8 | +//! | `String` | str(string) | VARCHAR, CHAR(N), TEXT | +//! | `Vec` | binary(list\) | BYTEA | +//! | `chrono::NaiveDate` | date(tuple) | DATE | +//! | `chrono::NaiveTime` | time(tuple) | TIME | +//! | `chrono::NaiveDateTime` | datetime(tuple) | TIMESTAMP | +//! | `chrono::Duration` | timestamp(s64) | BIGINT | +//! | `uuid::Uuid` | uuid(string) | UUID | +//! | `serde_json::Value` | jsonb(list\) | JSONB | +//! | `serde::De/Serialize | jsonb(list\) | JSONB | +//! | `rust_decimal::Decimal` | decimal(string) | NUMERIC | +//! | `postgres_range` | range-int32(...), range-int64(...) | INT4RANGE, INT8RANGE | +//! | lower/upper tuple | range-decimal(...) | NUMERICRANGE | +//! | `Vec>` | array-int32(...), array-int64(...), array-str(...), array-decimal(...) | INT4[], INT8[], TEXT[], NUMERIC[] | +//! | `pg4::Interval | interval(interval) | INTERVAL | + +/// An open connection to a PostgreSQL database. +/// +/// # Examples +/// +/// Load a set of rows from a local PostgreSQL database, and iterate over them. +/// +/// ```no_run +/// use spin_sdk::pg4::{Connection, Decode}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 0; +/// let db = Connection::open("host=localhost user=postgres password=my_password dbname=mydb")?; +/// +/// let query_result = db.query( +/// "SELECT * FROM users WHERE age >= $1", +/// &[min_age.into()] +/// )?; +/// +/// let name_index = query_result.columns.iter().position(|c| c.name == "name").unwrap(); +/// +/// for row in &query_result.rows { +/// let name = String::decode(&row[name_index])?; +/// println!("Found user {name}"); +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// Perform an aggregate (scalar) operation over a table. The result set +/// contains a single column, with a single row. +/// +/// ```no_run +/// use spin_sdk::pg4::{Connection, Decode}; +/// +/// # fn main() -> anyhow::Result<()> { +/// let db = Connection::open("host=localhost user=postgres password=my_password dbname=mydb")?; +/// +/// let query_result = db.query("SELECT COUNT(*) FROM users", &[])?; +/// +/// assert_eq!(1, query_result.columns.len()); +/// assert_eq!("count", query_result.columns[0].name); +/// assert_eq!(1, query_result.rows.len()); +/// +/// let count = i64::decode(&query_result.rows[0][0])?; +/// # Ok(()) +/// # } +/// ``` +/// +/// Delete rows from a PostgreSQL table. This uses [Connection::execute()] +/// instead of the `query` method. +/// +/// ```no_run +/// use spin_sdk::pg4::Connection; +/// +/// # fn main() -> anyhow::Result<()> { +/// let db = Connection::open("host=localhost user=postgres password=my_password dbname=mydb")?; +/// +/// let rows_affected = db.execute( +/// "DELETE FROM users WHERE name = $1", +/// &["Baldrick".to_owned().into()] +/// )?; +/// # Ok(()) +/// # } +/// ``` +#[doc(inline)] +pub use super::wit::pg4::Connection; + +/// The result of a database query. +/// +/// # Examples +/// +/// Load a set of rows from a local PostgreSQL database, and iterate over them +/// selecting one field from each. The columns collection allows you to find +/// column indexes for column names; you can bypass this lookup if you name +/// specific columns in the query. +/// +/// ```no_run +/// use spin_sdk::pg4::{Connection, Decode}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 0; +/// let db = Connection::open("host=localhost user=postgres password=my_password dbname=mydb")?; +/// +/// let query_result = db.query( +/// "SELECT * FROM users WHERE age >= $1", +/// &[min_age.into()] +/// )?; +/// +/// let name_index = query_result.columns.iter().position(|c| c.name == "name").unwrap(); +/// +/// for row in &query_result.rows { +/// let name = String::decode(&row[name_index])?; +/// println!("Found user {name}"); +/// } +/// # Ok(()) +/// # } +/// ``` +pub use super::wit::pg4::RowSet; + +#[doc(inline)] +pub use super::wit::pg4::{Error as PgError, *}; + +/// The PostgreSQL INTERVAL data type. +pub use crate::pg4::Interval; + +use chrono::{Datelike, Timelike}; + +/// A Postgres error +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to deserialize [`DbValue`] + #[error("error value decoding: {0}")] + Decode(String), + /// Postgres query failed with an error + #[error(transparent)] + PgError(#[from] PgError), +} + +/// A type that can be decoded from the database. +pub trait Decode: Sized { + /// Decode a new value of this type using a [`DbValue`]. + fn decode(value: &DbValue) -> Result; +} + +impl Decode for Option +where + T: Decode, +{ + fn decode(value: &DbValue) -> Result { + match value { + DbValue::DbNull => Ok(None), + v => Ok(Some(T::decode(v)?)), + } + } +} + +impl Decode for bool { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Boolean(boolean) => Ok(*boolean), + _ => Err(Error::Decode(format_decode_err("BOOL", value))), + } + } +} + +impl Decode for i16 { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Int16(n) => Ok(*n), + _ => Err(Error::Decode(format_decode_err("SMALLINT", value))), + } + } +} + +impl Decode for i32 { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Int32(n) => Ok(*n), + _ => Err(Error::Decode(format_decode_err("INT", value))), + } + } +} + +impl Decode for i64 { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Int64(n) => Ok(*n), + _ => Err(Error::Decode(format_decode_err("BIGINT", value))), + } + } +} + +impl Decode for f32 { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Floating32(n) => Ok(*n), + _ => Err(Error::Decode(format_decode_err("REAL", value))), + } + } +} + +impl Decode for f64 { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Floating64(n) => Ok(*n), + _ => Err(Error::Decode(format_decode_err("DOUBLE PRECISION", value))), + } + } +} + +impl Decode for Vec { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Binary(n) => Ok(n.to_owned()), + _ => Err(Error::Decode(format_decode_err("BYTEA", value))), + } + } +} + +impl Decode for String { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Str(s) => Ok(s.to_owned()), + _ => Err(Error::Decode(format_decode_err( + "CHAR, VARCHAR, TEXT", + value, + ))), + } + } +} + +impl Decode for chrono::NaiveDate { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Date((year, month, day)) => { + let naive_date = + chrono::NaiveDate::from_ymd_opt(*year, (*month).into(), (*day).into()) + .ok_or_else(|| { + Error::Decode(format!( + "invalid date y={}, m={}, d={}", + year, month, day + )) + })?; + Ok(naive_date) + } + _ => Err(Error::Decode(format_decode_err("DATE", value))), + } + } +} + +impl Decode for chrono::NaiveTime { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Time((hour, minute, second, nanosecond)) => { + let naive_time = chrono::NaiveTime::from_hms_nano_opt( + (*hour).into(), + (*minute).into(), + (*second).into(), + *nanosecond, + ) + .ok_or_else(|| { + Error::Decode(format!( + "invalid time {}:{}:{}:{}", + hour, minute, second, nanosecond + )) + })?; + Ok(naive_time) + } + _ => Err(Error::Decode(format_decode_err("TIME", value))), + } + } +} + +impl Decode for chrono::NaiveDateTime { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Datetime((year, month, day, hour, minute, second, nanosecond)) => { + let naive_date = + chrono::NaiveDate::from_ymd_opt(*year, (*month).into(), (*day).into()) + .ok_or_else(|| { + Error::Decode(format!( + "invalid date y={}, m={}, d={}", + year, month, day + )) + })?; + let naive_time = chrono::NaiveTime::from_hms_nano_opt( + (*hour).into(), + (*minute).into(), + (*second).into(), + *nanosecond, + ) + .ok_or_else(|| { + Error::Decode(format!( + "invalid time {}:{}:{}:{}", + hour, minute, second, nanosecond + )) + })?; + let dt = chrono::NaiveDateTime::new(naive_date, naive_time); + Ok(dt) + } + _ => Err(Error::Decode(format_decode_err("DATETIME", value))), + } + } +} + +impl Decode for chrono::Duration { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Timestamp(n) => Ok(chrono::Duration::seconds(*n)), + _ => Err(Error::Decode(format_decode_err("BIGINT", value))), + } + } +} + +impl Decode for uuid::Uuid { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Uuid(s) => uuid::Uuid::parse_str(s).map_err(|e| Error::Decode(e.to_string())), + _ => Err(Error::Decode(format_decode_err("UUID", value))), + } + } +} + +impl Decode for serde_json::Value { + fn decode(value: &DbValue) -> Result { + from_jsonb(value) + } +} + +/// Convert a Postgres JSONB value to a `Deserialize`-able type. +pub fn from_jsonb<'a, T: serde::Deserialize<'a>>(value: &'a DbValue) -> Result { + match value { + DbValue::Jsonb(j) => serde_json::from_slice(j).map_err(|e| Error::Decode(e.to_string())), + _ => Err(Error::Decode(format_decode_err("JSONB", value))), + } +} + +impl Decode for rust_decimal::Decimal { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Decimal(s) => { + rust_decimal::Decimal::from_str_exact(s).map_err(|e| Error::Decode(e.to_string())) + } + _ => Err(Error::Decode(format_decode_err("NUMERIC", value))), + } + } +} + +fn bound_type_from_wit(kind: RangeBoundKind) -> postgres_range::BoundType { + match kind { + RangeBoundKind::Inclusive => postgres_range::BoundType::Inclusive, + RangeBoundKind::Exclusive => postgres_range::BoundType::Exclusive, + } +} + +impl Decode for postgres_range::Range { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::RangeInt32((lbound, ubound)) => { + let lower = lbound.map(|(value, kind)| { + postgres_range::RangeBound::new(value, bound_type_from_wit(kind)) + }); + let upper = ubound.map(|(value, kind)| { + postgres_range::RangeBound::new(value, bound_type_from_wit(kind)) + }); + Ok(postgres_range::Range::new(lower, upper)) + } + _ => Err(Error::Decode(format_decode_err("INT4RANGE", value))), + } + } +} + +impl Decode for postgres_range::Range { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::RangeInt64((lbound, ubound)) => { + let lower = lbound.map(|(value, kind)| { + postgres_range::RangeBound::new(value, bound_type_from_wit(kind)) + }); + let upper = ubound.map(|(value, kind)| { + postgres_range::RangeBound::new(value, bound_type_from_wit(kind)) + }); + Ok(postgres_range::Range::new(lower, upper)) + } + _ => Err(Error::Decode(format_decode_err("INT8RANGE", value))), + } + } +} + +// TODO: NUMERICRANGE + +// TODO: can we return a slice here? It seems like it should be possible but +// I wasn't able to get the lifetimes to work with the trait +impl Decode for Vec> { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::ArrayInt32(a) => Ok(a.to_vec()), + _ => Err(Error::Decode(format_decode_err("INT4[]", value))), + } + } +} + +impl Decode for Vec> { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::ArrayInt64(a) => Ok(a.to_vec()), + _ => Err(Error::Decode(format_decode_err("INT8[]", value))), + } + } +} + +impl Decode for Vec> { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::ArrayStr(a) => Ok(a.to_vec()), + _ => Err(Error::Decode(format_decode_err("TEXT[]", value))), + } + } +} + +fn map_decimal(s: &Option) -> Result, Error> { + s.as_ref() + .map(|s| rust_decimal::Decimal::from_str_exact(s)) + .transpose() + .map_err(|e| Error::Decode(e.to_string())) +} + +impl Decode for Vec> { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::ArrayDecimal(a) => { + let decs = a.iter().map(map_decimal).collect::>()?; + Ok(decs) + } + _ => Err(Error::Decode(format_decode_err("NUMERIC[]", value))), + } + } +} + +impl Decode for Interval { + fn decode(value: &DbValue) -> Result { + match value { + DbValue::Interval(i) => Ok(*i), + _ => Err(Error::Decode(format_decode_err("INTERVAL", value))), + } + } +} + +macro_rules! impl_parameter_value_conversions { + ($($ty:ty => $id:ident),*) => { + $( + impl From<$ty> for ParameterValue { + fn from(v: $ty) -> ParameterValue { + ParameterValue::$id(v) + } + } + )* + }; +} + +impl_parameter_value_conversions! { + i8 => Int8, + i16 => Int16, + i32 => Int32, + i64 => Int64, + f32 => Floating32, + f64 => Floating64, + bool => Boolean, + String => Str, + Vec => Binary, + Vec> => ArrayInt32, + Vec> => ArrayInt64, + Vec> => ArrayStr +} + +impl From for ParameterValue { + fn from(v: chrono::NaiveDateTime) -> ParameterValue { + ParameterValue::Datetime(( + v.year(), + v.month() as u8, + v.day() as u8, + v.hour() as u8, + v.minute() as u8, + v.second() as u8, + v.nanosecond(), + )) + } +} + +impl From for ParameterValue { + fn from(v: chrono::NaiveTime) -> ParameterValue { + ParameterValue::Time(( + v.hour() as u8, + v.minute() as u8, + v.second() as u8, + v.nanosecond(), + )) + } +} + +impl From for ParameterValue { + fn from(v: chrono::NaiveDate) -> ParameterValue { + ParameterValue::Date((v.year(), v.month() as u8, v.day() as u8)) + } +} + +impl From for ParameterValue { + fn from(v: chrono::TimeDelta) -> ParameterValue { + ParameterValue::Timestamp(v.num_seconds()) + } +} + +impl From for ParameterValue { + fn from(v: uuid::Uuid) -> ParameterValue { + ParameterValue::Uuid(v.to_string()) + } +} + +impl TryFrom for ParameterValue { + type Error = serde_json::Error; + + fn try_from(v: serde_json::Value) -> Result { + jsonb(&v) + } +} + +/// Converts a `Serialize` value to a Postgres JSONB SQL parameter. +pub fn jsonb(value: &T) -> Result { + let json = serde_json::to_vec(value)?; + Ok(ParameterValue::Jsonb(json)) +} + +impl From for ParameterValue { + fn from(v: rust_decimal::Decimal) -> ParameterValue { + ParameterValue::Decimal(v.to_string()) + } +} + +// We cannot impl From> because Rust fears that some future +// knave or rogue might one day add RangeBounds to NaiveDateTime. The best we can +// do is therefore a helper function we can call from range Froms. +#[allow( + clippy::type_complexity, + reason = "I sure hope 'blame Alex' works here too" +)] +fn range_bounds_to_wit( + range: impl std::ops::RangeBounds, + f: impl Fn(&T) -> U, +) -> (Option<(U, RangeBoundKind)>, Option<(U, RangeBoundKind)>) { + ( + range_bound_to_wit(range.start_bound(), &f), + range_bound_to_wit(range.end_bound(), &f), + ) +} + +fn range_bound_to_wit( + bound: std::ops::Bound<&T>, + f: &dyn Fn(&T) -> U, +) -> Option<(U, RangeBoundKind)> { + match bound { + std::ops::Bound::Included(v) => Some((f(v), RangeBoundKind::Inclusive)), + std::ops::Bound::Excluded(v) => Some((f(v), RangeBoundKind::Exclusive)), + std::ops::Bound::Unbounded => None, + } +} + +fn pg_range_bound_to_wit( + bound: &postgres_range::RangeBound, +) -> (T, RangeBoundKind) { + let kind = match &bound.type_ { + postgres_range::BoundType::Inclusive => RangeBoundKind::Inclusive, + postgres_range::BoundType::Exclusive => RangeBoundKind::Exclusive, + }; + (bound.value, kind) +} + +impl From> for ParameterValue { + fn from(v: std::ops::Range) -> ParameterValue { + ParameterValue::RangeInt32(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeInclusive) -> ParameterValue { + ParameterValue::RangeInt32(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeFrom) -> ParameterValue { + ParameterValue::RangeInt32(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeTo) -> ParameterValue { + ParameterValue::RangeInt32(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeToInclusive) -> ParameterValue { + ParameterValue::RangeInt32(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: postgres_range::Range) -> ParameterValue { + let lbound = v.lower().map(pg_range_bound_to_wit); + let ubound = v.upper().map(pg_range_bound_to_wit); + ParameterValue::RangeInt32((lbound, ubound)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::Range) -> ParameterValue { + ParameterValue::RangeInt64(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeInclusive) -> ParameterValue { + ParameterValue::RangeInt64(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeFrom) -> ParameterValue { + ParameterValue::RangeInt64(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeTo) -> ParameterValue { + ParameterValue::RangeInt64(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::RangeToInclusive) -> ParameterValue { + ParameterValue::RangeInt64(range_bounds_to_wit(v, |n| *n)) + } +} + +impl From> for ParameterValue { + fn from(v: postgres_range::Range) -> ParameterValue { + let lbound = v.lower().map(pg_range_bound_to_wit); + let ubound = v.upper().map(pg_range_bound_to_wit); + ParameterValue::RangeInt64((lbound, ubound)) + } +} + +impl From> for ParameterValue { + fn from(v: std::ops::Range) -> ParameterValue { + ParameterValue::RangeDecimal(range_bounds_to_wit(v, |d| d.to_string())) + } +} + +impl From> for ParameterValue { + fn from(v: Vec) -> ParameterValue { + ParameterValue::ArrayInt32(v.into_iter().map(Some).collect()) + } +} + +impl From> for ParameterValue { + fn from(v: Vec) -> ParameterValue { + ParameterValue::ArrayInt64(v.into_iter().map(Some).collect()) + } +} + +impl From> for ParameterValue { + fn from(v: Vec) -> ParameterValue { + ParameterValue::ArrayStr(v.into_iter().map(Some).collect()) + } +} + +impl From>> for ParameterValue { + fn from(v: Vec>) -> ParameterValue { + let strs = v + .into_iter() + .map(|optd| optd.map(|d| d.to_string())) + .collect(); + ParameterValue::ArrayDecimal(strs) + } +} + +impl From> for ParameterValue { + fn from(v: Vec) -> ParameterValue { + let strs = v.into_iter().map(|d| Some(d.to_string())).collect(); + ParameterValue::ArrayDecimal(strs) + } +} + +impl From for ParameterValue { + fn from(v: Interval) -> ParameterValue { + ParameterValue::Interval(v) + } +} + +impl> From> for ParameterValue { + fn from(o: Option) -> ParameterValue { + match o { + Some(v) => v.into(), + None => ParameterValue::DbNull, + } + } +} + +fn format_decode_err(types: &str, value: &DbValue) -> String { + format!("Expected {} from the DB but got {:?}", types, value) +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDateTime; + + use super::*; + + #[test] + fn boolean() { + assert!(bool::decode(&DbValue::Boolean(true)).unwrap()); + assert!(bool::decode(&DbValue::Int32(0)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn int16() { + assert_eq!(i16::decode(&DbValue::Int16(0)).unwrap(), 0); + assert!(i16::decode(&DbValue::Int32(0)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn int32() { + assert_eq!(i32::decode(&DbValue::Int32(0)).unwrap(), 0); + assert!(i32::decode(&DbValue::Boolean(false)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn int64() { + assert_eq!(i64::decode(&DbValue::Int64(0)).unwrap(), 0); + assert!(i64::decode(&DbValue::Boolean(false)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn floating32() { + assert!(f32::decode(&DbValue::Floating32(0.0)).is_ok()); + assert!(f32::decode(&DbValue::Boolean(false)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn floating64() { + assert!(f64::decode(&DbValue::Floating64(0.0)).is_ok()); + assert!(f64::decode(&DbValue::Boolean(false)).is_err()); + assert!(Option::::decode(&DbValue::DbNull).unwrap().is_none()); + } + + #[test] + fn str() { + assert_eq!( + String::decode(&DbValue::Str(String::from("foo"))).unwrap(), + String::from("foo") + ); + + assert!(String::decode(&DbValue::Int32(0)).is_err()); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[test] + fn binary() { + assert!(Vec::::decode(&DbValue::Binary(vec![0, 0])).is_ok()); + assert!(Vec::::decode(&DbValue::Boolean(false)).is_err()); + assert!(Option::>::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[test] + fn date() { + assert_eq!( + chrono::NaiveDate::decode(&DbValue::Date((1, 2, 4))).unwrap(), + chrono::NaiveDate::from_ymd_opt(1, 2, 4).unwrap() + ); + assert_ne!( + chrono::NaiveDate::decode(&DbValue::Date((1, 2, 4))).unwrap(), + chrono::NaiveDate::from_ymd_opt(1, 2, 5).unwrap() + ); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[test] + fn time() { + assert_eq!( + chrono::NaiveTime::decode(&DbValue::Time((1, 2, 3, 4))).unwrap(), + chrono::NaiveTime::from_hms_nano_opt(1, 2, 3, 4).unwrap() + ); + assert_ne!( + chrono::NaiveTime::decode(&DbValue::Time((1, 2, 3, 4))).unwrap(), + chrono::NaiveTime::from_hms_nano_opt(1, 2, 4, 5).unwrap() + ); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[test] + fn datetime() { + let date = chrono::NaiveDate::from_ymd_opt(1, 2, 3).unwrap(); + let mut time = chrono::NaiveTime::from_hms_nano_opt(4, 5, 6, 7).unwrap(); + assert_eq!( + chrono::NaiveDateTime::decode(&DbValue::Datetime((1, 2, 3, 4, 5, 6, 7))).unwrap(), + chrono::NaiveDateTime::new(date, time) + ); + + time = chrono::NaiveTime::from_hms_nano_opt(4, 5, 6, 8).unwrap(); + assert_ne!( + NaiveDateTime::decode(&DbValue::Datetime((1, 2, 3, 4, 5, 6, 7))).unwrap(), + chrono::NaiveDateTime::new(date, time) + ); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[test] + fn timestamp() { + assert_eq!( + chrono::Duration::decode(&DbValue::Timestamp(1)).unwrap(), + chrono::Duration::seconds(1), + ); + assert_ne!( + chrono::Duration::decode(&DbValue::Timestamp(2)).unwrap(), + chrono::Duration::seconds(1) + ); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } +} diff --git a/src/sqlite3.rs b/src/sqlite3.rs new file mode 100644 index 0000000..a5efa56 --- /dev/null +++ b/src/sqlite3.rs @@ -0,0 +1,345 @@ +use super::wit::sqlite3; + +#[doc(inline)] +pub use sqlite3::{Error, Value}; + +/// An open connection to a SQLite database. +/// +/// # Examples +/// +/// Load a set of rows from the default SQLite database, and iterate over them. +/// +/// ```no_run +/// use spin_sdk::sqlite::{Connection, Value}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 0; +/// let db = Connection::open_default()?; +/// +/// let query_result = db.execute( +/// "SELECT * FROM users WHERE age >= ?", +/// &[Value::Integer(min_age)] +/// )?; +/// +/// let name_index = query_result.columns.iter().position(|c| c == "name").unwrap(); +/// +/// for row in &query_result.rows { +/// let name: &str = row.get(name_index).unwrap(); +/// println!("Found user {name}"); +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// Use the [QueryResult::rows()] wrapper to access a column by name. This is simpler and +/// more readable but incurs a lookup on each access, so is not recommended when +/// iterating a data set. +/// +/// ```no_run +/// # use spin_sdk::sqlite::{Connection, Value}; +/// # fn main() -> anyhow::Result<()> { +/// # let user_id = 0; +/// let db = Connection::open_default()?; +/// let query_result = db.execute( +/// "SELECT * FROM users WHERE id = ?", +/// &[Value::Integer(user_id)] +/// )?; +/// let name = query_result.rows().next().and_then(|r| r.get::<&str>("name")).unwrap(); +/// # Ok(()) +/// # } +/// ``` +/// +/// Perform an aggregate (scalar) operation over a named SQLite database. The result +/// set contains a single column, with a single row. +/// +/// ```no_run +/// use spin_sdk::sqlite::Connection; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let user_id = 0; +/// let db = Connection::open("customer-data")?; +/// let query_result = db.execute("SELECT COUNT(*) FROM users", &[])?; +/// let count = query_result.rows.first().and_then(|r| r.get::(0)).unwrap(); +/// # Ok(()) +/// # } +/// ``` +/// +/// Delete rows from a SQLite database. The usual [Connection::execute()] syntax +/// is used but the query result is always empty. +/// +/// ```no_run +/// use spin_sdk::sqlite::{Connection, Value}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 18; +/// let db = Connection::open("customer-data")?; +/// db.execute("DELETE FROM users WHERE age < ?", &[Value::Integer(min_age)])?; +/// # Ok(()) +/// # } +/// ``` +#[doc(inline)] +pub use sqlite3::Connection; + +/// The result of a SQLite query issued with [Connection::execute()]. +/// +/// # Examples +/// +/// Load a set of rows from the default SQLite database, and iterate over them. +/// +/// ```no_run +/// use spin_sdk::sqlite::{Connection, Value}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 0; +/// let db = Connection::open_default()?; +/// +/// let query_result = db.execute( +/// "SELECT * FROM users WHERE age >= ?", +/// &[Value::Integer(min_age)] +/// )?; +/// +/// let name_index = query_result.columns.iter().position(|c| c == "name").unwrap(); +/// +/// for row in &query_result.rows { +/// let name: &str = row.get(name_index).unwrap(); +/// println!("Found user {name}"); +/// } +/// # Ok(()) +/// # } +/// ``` +/// +/// Use the [QueryResult::rows()] wrapper to access a column by name. This is simpler and +/// more readable but incurs a lookup on each access, so is not recommended when +/// iterating a data set. +/// +/// ```no_run +/// # use spin_sdk::sqlite::{Connection, Value}; +/// # fn main() -> anyhow::Result<()> { +/// # let user_id = 0; +/// let db = Connection::open_default()?; +/// let query_result = db.execute( +/// "SELECT * FROM users WHERE id = ?", +/// &[Value::Integer(user_id)] +/// )?; +/// let name = query_result.rows().next().and_then(|r| r.get::<&str>("name")).unwrap(); +/// # Ok(()) +/// # } +/// ``` +/// +/// Perform an aggregate (scalar) operation over a named SQLite database. The result +/// set contains a single column, with a single row. +/// +/// ```no_run +/// use spin_sdk::sqlite::Connection; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let user_id = 0; +/// let db = Connection::open("customer-data")?; +/// let query_result = db.execute("SELECT COUNT(*) FROM users", &[])?; +/// let count = query_result.rows.first().and_then(|r| r.get::(0)).unwrap(); +/// # Ok(()) +/// # } +/// ``` +#[doc(inline)] +pub use sqlite3::QueryResult; + +/// A database row result. +/// +/// There are two representations of a SQLite row in the SDK. This type is obtained from +/// the [field@QueryResult::rows] field, and provides index-based lookup or low-level access +/// to row values via a vector. The [Row] type is useful for +/// addressing elements by column name, and is obtained from the [QueryResult::rows()] function. +/// +/// # Examples +/// +/// Load a set of rows from the default SQLite database, and iterate over them selecting one +/// field from each. The example caches the index of the desired field to avoid repeated lookup, +/// making this more efficient than the [Row]-based equivalent at the expense of +/// extra code and inferior readability. +/// +/// ```no_run +/// use spin_sdk::sqlite::{Connection, Value}; +/// +/// # fn main() -> anyhow::Result<()> { +/// # let min_age = 0; +/// let db = Connection::open_default()?; +/// +/// let query_result = db.execute( +/// "SELECT * FROM users WHERE age >= ?", +/// &[Value::Integer(min_age)] +/// )?; +/// +/// let name_index = query_result.columns.iter().position(|c| c == "name").unwrap(); +/// +/// for row in &query_result.rows { +/// let name: &str = row.get(name_index).unwrap(); +/// println!("Found user {name}"); +/// } +/// # Ok(()) +/// # } +/// ``` +#[doc(inline)] +pub use sqlite3::RowResult; + +impl sqlite3::Connection { + /// Open a connection to the default database + pub fn open_default() -> Result { + Self::open("default") + } +} + +impl sqlite3::QueryResult { + /// Get all the rows for this query result + pub fn rows(&self) -> impl Iterator> { + self.rows.iter().map(|r| Row { + columns: self.columns.as_slice(), + result: r, + }) + } +} + +/// A database row result. +/// +/// There are two representations of a SQLite row in the SDK. This type is useful for +/// addressing elements by column name, and is obtained from the [QueryResult::rows()] function. +/// The [RowResult] type is obtained from the [field@QueryResult::rows] field, and provides +/// index-based lookup or low-level access to row values via a vector. +pub struct Row<'a> { + columns: &'a [String], + result: &'a sqlite3::RowResult, +} + +impl<'a> Row<'a> { + /// Get a value by its column name. The value is converted to the target type. + /// + /// * SQLite integers are convertible to Rust integer types (i8, u8, i16, etc. including usize and isize) and bool. + /// * SQLite strings are convertible to Rust &str or &[u8] (encoded as UTF-8). + /// * SQLite reals are convertible to Rust f64. + /// * SQLite blobs are convertible to Rust &[u8] or &str (interpreted as UTF-8). + /// + /// If your code does not know the type in advance, use [RowResult] instead of `Row` to + /// access the underlying [Value] enum. + /// + /// # Examples + /// + /// ```no_run + /// use spin_sdk::sqlite::{Connection, Value}; + /// + /// # fn main() -> anyhow::Result<()> { + /// # let user_id = 0; + /// let db = Connection::open_default()?; + /// let query_result = db.execute( + /// "SELECT * FROM users WHERE id = ?", + /// &[Value::Integer(user_id)] + /// )?; + /// let user_row = query_result.rows().next().unwrap(); + /// + /// let name = user_row.get::<&str>("name").unwrap(); + /// let age = user_row.get::("age").unwrap(); + /// # Ok(()) + /// # } + /// ``` + pub fn get>(&self, column: &str) -> Option { + let i = self.columns.iter().position(|c| c == column)?; + self.result.get(i) + } +} + +impl sqlite3::RowResult { + /// Get a value by its column name. The value is converted to the target type. + /// + /// * SQLite integers are convertible to Rust integer types (i8, u8, i16, etc. including usize and isize) and bool. + /// * SQLite strings are convertible to Rust &str or &[u8] (encoded as UTF-8). + /// * SQLite reals are convertible to Rust f64. + /// * SQLite blobs are convertible to Rust &[u8] or &str (interpreted as UTF-8). + /// + /// To look up by name, you can use `QueryResult::rows()` or obtain the invoice from `QueryResult::columns`. + /// If you do not know the type of a value, access the underlying [Value] enum directly + /// via the [RowResult::values] field + /// + /// # Examples + /// + /// ```no_run + /// use spin_sdk::sqlite::{Connection, Value}; + /// + /// # fn main() -> anyhow::Result<()> { + /// # let user_id = 0; + /// let db = Connection::open_default()?; + /// let query_result = db.execute( + /// "SELECT name, age FROM users WHERE id = ?", + /// &[Value::Integer(user_id)] + /// )?; + /// let user_row = query_result.rows.first().unwrap(); + /// + /// let name = user_row.get::<&str>(0).unwrap(); + /// let age = user_row.get::(1).unwrap(); + /// # Ok(()) + /// # } + /// ``` + pub fn get<'a, T: TryFrom<&'a Value>>(&'a self, index: usize) -> Option { + self.values.get(index).and_then(|c| c.try_into().ok()) + } +} + +impl<'a> TryFrom<&'a Value> for bool { + type Error = (); + + fn try_from(value: &'a Value) -> Result { + match value { + Value::Integer(i) => Ok(*i != 0), + _ => Err(()), + } + } +} + +macro_rules! int_conversions { + ($($t:ty),*) => { + $(impl<'a> TryFrom<&'a Value> for $t { + type Error = (); + + fn try_from(value: &'a Value) -> Result { + match value { + Value::Integer(i) => (*i).try_into().map_err(|_| ()), + _ => Err(()), + } + } + })* + }; +} + +int_conversions!(u8, u16, u32, u64, i8, i16, i32, i64, usize, isize); + +impl<'a> TryFrom<&'a Value> for f64 { + type Error = (); + + fn try_from(value: &'a Value) -> Result { + match value { + Value::Real(f) => Ok(*f), + _ => Err(()), + } + } +} + +impl<'a> TryFrom<&'a Value> for &'a str { + type Error = (); + + fn try_from(value: &'a Value) -> Result { + match value { + Value::Text(s) => Ok(s.as_str()), + Value::Blob(b) => std::str::from_utf8(b).map_err(|_| ()), + _ => Err(()), + } + } +} + +impl<'a> TryFrom<&'a Value> for &'a [u8] { + type Error = (); + + fn try_from(value: &'a Value) -> Result { + match value { + Value::Blob(b) => Ok(b.as_slice()), + Value::Text(s) => Ok(s.as_bytes()), + _ => Err(()), + } + } +} diff --git a/wit/deps/keyvalue-2024-10-17/atomic.wit b/wit/deps/keyvalue-2024-10-17/atomic.wit index 2c3e0d0..4a02c58 100644 --- a/wit/deps/keyvalue-2024-10-17/atomic.wit +++ b/wit/deps/keyvalue-2024-10-17/atomic.wit @@ -13,22 +13,22 @@ interface atomics { /// The error returned by a CAS operation variant cas-error { - /// A store error occurred when performing the operation - store-error(error), + /// A store error occurred when performing the operation + store-error(error), /// The CAS operation failed because the value was too old. This returns a new CAS handle /// for easy retries. Implementors MUST return a CAS handle that has been updated to the /// latest version or transaction. - cas-failed(cas), + cas-failed(cas), } /// A handle to a CAS (compare-and-swap) operation. resource cas { - /// Construct a new CAS operation. Implementors can map the underlying functionality - /// (transactions, versions, etc) as desired. - new: static func(bucket: borrow, key: string) -> result; - /// Get the current value of the key (if it exists). This allows for avoiding reads if all - /// that is needed to ensure the atomicity of the operation - current: func() -> result>, error>; + /// Construct a new CAS operation. Implementors can map the underlying functionality + /// (transactions, versions, etc) as desired. + new: static func(bucket: borrow, key: string) -> result; + /// Get the current value of the key (if it exists). This allows for avoiding reads if all + /// that is needed to ensure the atomicity of the operation + current: func() -> result>, error>; } /// Atomically increment the value associated with the key in the store by the given delta. It diff --git a/wit/deps/keyvalue-2024-10-17/world.wit b/wit/deps/keyvalue-2024-10-17/world.wit index 64eb4e1..e8fb821 100644 --- a/wit/deps/keyvalue-2024-10-17/world.wit +++ b/wit/deps/keyvalue-2024-10-17/world.wit @@ -1,4 +1,4 @@ -package wasi: keyvalue@0.2.0-draft2; +package wasi:keyvalue@0.2.0-draft2; /// The `wasi:keyvalue/imports` world provides common APIs for interacting with key-value stores. /// Components targeting this world will be able to do: diff --git a/wit/deps/spin-postgres@4.0.0/postgres.wit b/wit/deps/spin-postgres@4.0.0/postgres.wit new file mode 100644 index 0000000..652703a --- /dev/null +++ b/wit/deps/spin-postgres@4.0.0/postgres.wit @@ -0,0 +1,163 @@ +package spin:postgres@4.0.0; + +interface postgres { + /// Errors related to interacting with a database. + variant error { + connection-failed(string), + bad-parameter(string), + query-failed(query-error), + value-conversion-failed(string), + other(string) + } + + variant query-error { + /// An error occurred but we do not have structured info for it + text(string), + /// Postgres returned a structured database error + db-error(db-error), + } + + record db-error { + /// Stringised version of the error. This is primarily to facilitate migration of older code. + as-text: string, + severity: string, + code: string, + message: string, + detail: option, + /// Any error information provided by Postgres and not captured above. + extras: list>, + } + + /// Data types for a database column + variant db-data-type { + boolean, + int8, + int16, + int32, + int64, + floating32, + floating64, + str, + binary, + date, + time, + datetime, + timestamp, + uuid, + jsonb, + decimal, + range-int32, + range-int64, + range-decimal, + array-int32, + array-int64, + array-decimal, + array-str, + interval, + other(string), + } + + /// Database values + variant db-value { + boolean(bool), + int8(s8), + int16(s16), + int32(s32), + int64(s64), + floating32(f32), + floating64(f64), + str(string), + binary(list), + date(tuple), // (year, month, day) + time(tuple), // (hour, minute, second, nanosecond) + /// Date-time types are always treated as UTC (without timezone info). + /// The instant is represented as a (year, month, day, hour, minute, second, nanosecond) tuple. + datetime(tuple), + /// Unix timestamp (seconds since epoch) + timestamp(s64), + uuid(string), + jsonb(list), + decimal(string), // I admit defeat. Base 10 + range-int32(tuple>, option>>), + range-int64(tuple>, option>>), + range-decimal(tuple>, option>>), + array-int32(list>), + array-int64(list>), + array-decimal(list>), + array-str(list>), + interval(interval), + db-null, + unsupported(list), + } + + /// Values used in parameterized queries + variant parameter-value { + boolean(bool), + int8(s8), + int16(s16), + int32(s32), + int64(s64), + floating32(f32), + floating64(f64), + str(string), + binary(list), + date(tuple), // (year, month, day) + time(tuple), // (hour, minute, second, nanosecond) + /// Date-time types are always treated as UTC (without timezone info). + /// The instant is represented as a (year, month, day, hour, minute, second, nanosecond) tuple. + datetime(tuple), + /// Unix timestamp (seconds since epoch) + timestamp(s64), + uuid(string), + jsonb(list), + decimal(string), // base 10 + range-int32(tuple>, option>>), + range-int64(tuple>, option>>), + range-decimal(tuple>, option>>), + array-int32(list>), + array-int64(list>), + array-decimal(list>), + array-str(list>), + interval(interval), + db-null, + } + + record interval { + micros: s64, + days: s32, + months: s32, + } + + /// A database column + record column { + name: string, + data-type: db-data-type, + } + + /// A database row + type row = list; + + /// A set of database rows + record row-set { + columns: list, + rows: list, + } + + /// For range types, indicates if each bound is inclusive or exclusive + enum range-bound-kind { + inclusive, + exclusive, + } + + /// A connection to a postgres database. + resource connection { + /// Open a connection to the Postgres instance at `address`. + open: static func(address: string) -> result; + + /// Query the database. + query: func(statement: string, params: list) -> result; + + /// Execute command to the database. + execute: func(statement: string, params: list) -> result; + } +} diff --git a/wit/deps/spin-sqlite@3.0.0/sqlite.wit b/wit/deps/spin-sqlite@3.0.0/sqlite.wit new file mode 100644 index 0000000..6b41696 --- /dev/null +++ b/wit/deps/spin-sqlite@3.0.0/sqlite.wit @@ -0,0 +1,60 @@ +package spin:sqlite@3.0.0; + +interface sqlite { + /// A handle to an open sqlite instance + resource connection { + /// Open a connection to a named database instance. + /// + /// If `database` is "default", the default instance is opened. + /// + /// `error::no-such-database` will be raised if the `name` is not recognized. + open: static func(database: string) -> result; + + /// Execute a statement returning back data if there is any + execute: func(statement: string, parameters: list) -> result; + + /// The SQLite rowid of the most recent successful INSERT on the connection, or 0 if + /// there has not yet been an INSERT on the connection. + last-insert-rowid: func() -> s64; + + /// The number of rows modified, inserted or deleted by the most recently completed + /// INSERT, UPDATE or DELETE statement on the connection. + changes: func() -> u64; + } + + /// The set of errors which may be raised by functions in this interface + variant error { + /// The host does not recognize the database name requested. + no-such-database, + /// The requesting component does not have access to the specified database (which may or may not exist). + access-denied, + /// The provided connection is not valid + invalid-connection, + /// The database has reached its capacity + database-full, + /// Some implementation-specific error has occurred (e.g. I/O) + io(string) + } + + /// A result of a query + record query-result { + /// The names of the columns retrieved in the query + columns: list, + /// the row results each containing the values for all the columns for a given row + rows: list, + } + + /// A set of values for each of the columns in a query-result + record row-result { + values: list + } + + /// A single column's result from a database query + variant value { + integer(s64), + real(f64), + text(string), + blob(list), + null + } +} diff --git a/wit/deps/spin@3.0.0/world.wit b/wit/deps/spin@3.0.0/world.wit new file mode 100644 index 0000000..07da9cd --- /dev/null +++ b/wit/deps/spin@3.0.0/world.wit @@ -0,0 +1,15 @@ +package fermyon:spin@3.0.0; + +/// The full world of a guest targeting an http-trigger +world http-trigger { + include platform; + export wasi:http/incoming-handler@0.2.0; +} + +/// The imports needed for a guest to run on a Spin host +world platform { + include fermyon:spin/platform@2.0.0; + include wasi:keyvalue/imports@0.2.0-draft2; + import spin:postgres/postgres@3.0.0; + import wasi:config/store@0.2.0-draft-2024-09-27; +} diff --git a/wit/deps/spin@3.2.0/world.wit b/wit/deps/spin@3.2.0/world.wit new file mode 100644 index 0000000..4d63b14 --- /dev/null +++ b/wit/deps/spin@3.2.0/world.wit @@ -0,0 +1,16 @@ +package spin:up@3.2.0; + +/// The full world of a guest targeting an http-trigger +world http-trigger { + include platform; + export wasi:http/incoming-handler@0.2.0; +} + +/// The imports needed for a guest to run on a Spin host +world platform { + include fermyon:spin/platform@2.0.0; + include wasi:keyvalue/imports@0.2.0-draft2; + import spin:postgres/postgres@3.0.0; + import spin:sqlite/sqlite@3.0.0; + import wasi:config/store@0.2.0-draft-2024-09-27; +} diff --git a/wit/world.wit b/wit/world.wit index 07da9cd..b5d66b3 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -1,4 +1,4 @@ -package fermyon:spin@3.0.0; +package spin:up@3.4.0; /// The full world of a guest targeting an http-trigger world http-trigger { @@ -11,5 +11,7 @@ world platform { include fermyon:spin/platform@2.0.0; include wasi:keyvalue/imports@0.2.0-draft2; import spin:postgres/postgres@3.0.0; + import spin:postgres/postgres@4.0.0; + import spin:sqlite/sqlite@3.0.0; import wasi:config/store@0.2.0-draft-2024-09-27; } From 009e59415fb271239e5354687ea22cc0cc1b5b96 Mon Sep 17 00:00:00 2001 From: itowlson Date: Mon, 18 Aug 2025 14:19:15 +1200 Subject: [PATCH 2/5] Unit tests, PG4 feature Signed-off-by: itowlson --- Cargo.toml | 9 +-- src/pg4.rs | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0b43e06..08a6066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,10 +21,12 @@ anyhow = "1" async-trait = "0.1.74" chrono = "0.4.38" form_urlencoded = "1.0" -rust_decimal = { version = "1.37.2", default-features = false } +postgres_range = { version = "0.11.1", optional = true } +rust_decimal = { version = "1.37.2", default-features = false, optional = true } spin-executor = { version = "4.0.0", path = "crates/executor" } spin-macro = { version = "4.0.0", path = "crates/macro" } thiserror = "1.0.37" +uuid = { version = "1.18.0", optional = true } wit-bindgen = { workspace = true } routefinder = "0.5.3" once_cell = { workspace = true } @@ -34,13 +36,12 @@ hyperium = { package = "http", version = "1.0.0" } serde_json = { version = "1.0.96", optional = true } serde = { version = "1.0.163", optional = true } wasi = { workspace = true } -uuid = "1.18.0" -postgres_range = "0.11.1" [features] -default = ["export-sdk-language", "json"] +default = ["export-sdk-language", "json", "postgres4-types"] export-sdk-language = [] json = ["dep:serde", "dep:serde_json"] +postgres4-types = ["dep:rust_decimal", "dep:uuid", "dep:postgres_range", "json"] [workspace] resolver = "2" diff --git a/src/pg4.rs b/src/pg4.rs index a08bb22..a76825b 100644 --- a/src/pg4.rs +++ b/src/pg4.rs @@ -328,6 +328,7 @@ impl Decode for chrono::Duration { } } +#[cfg(feature = "postgres4-types")] impl Decode for uuid::Uuid { fn decode(value: &DbValue) -> Result { match value { @@ -337,6 +338,7 @@ impl Decode for uuid::Uuid { } } +#[cfg(feature = "json")] impl Decode for serde_json::Value { fn decode(value: &DbValue) -> Result { from_jsonb(value) @@ -344,6 +346,7 @@ impl Decode for serde_json::Value { } /// Convert a Postgres JSONB value to a `Deserialize`-able type. +#[cfg(feature = "json")] pub fn from_jsonb<'a, T: serde::Deserialize<'a>>(value: &'a DbValue) -> Result { match value { DbValue::Jsonb(j) => serde_json::from_slice(j).map_err(|e| Error::Decode(e.to_string())), @@ -351,6 +354,7 @@ pub fn from_jsonb<'a, T: serde::Deserialize<'a>>(value: &'a DbValue) -> Result Result { match value { @@ -362,6 +366,7 @@ impl Decode for rust_decimal::Decimal { } } +#[cfg(feature = "postgres4-types")] fn bound_type_from_wit(kind: RangeBoundKind) -> postgres_range::BoundType { match kind { RangeBoundKind::Inclusive => postgres_range::BoundType::Inclusive, @@ -369,6 +374,7 @@ fn bound_type_from_wit(kind: RangeBoundKind) -> postgres_range::BoundType { } } +#[cfg(feature = "postgres4-types")] impl Decode for postgres_range::Range { fn decode(value: &DbValue) -> Result { match value { @@ -386,6 +392,7 @@ impl Decode for postgres_range::Range { } } +#[cfg(feature = "postgres4-types")] impl Decode for postgres_range::Range { fn decode(value: &DbValue) -> Result { match value { @@ -403,7 +410,41 @@ impl Decode for postgres_range::Range { } } -// TODO: NUMERICRANGE +// We can't use postgres_range::Range because rust_decimal::Decimal +// is not Normalizable +#[cfg(feature = "postgres4-types")] +impl Decode + for ( + Option<(rust_decimal::Decimal, RangeBoundKind)>, + Option<(rust_decimal::Decimal, RangeBoundKind)>, + ) +{ + fn decode(value: &DbValue) -> Result { + fn parse( + value: &str, + kind: RangeBoundKind, + ) -> Result<(rust_decimal::Decimal, RangeBoundKind), Error> { + let dec = rust_decimal::Decimal::from_str_exact(value) + .map_err(|e| Error::Decode(e.to_string()))?; + Ok((dec, kind)) + } + + match value { + DbValue::RangeDecimal((lbound, ubound)) => { + let lower = lbound + .as_ref() + .map(|(value, kind)| parse(value, *kind)) + .transpose()?; + let upper = ubound + .as_ref() + .map(|(value, kind)| parse(value, *kind)) + .transpose()?; + Ok((lower, upper)) + } + _ => Err(Error::Decode(format_decode_err("NUMERICRANGE", value))), + } + } +} // TODO: can we return a slice here? It seems like it should be possible but // I wasn't able to get the lifetimes to work with the trait @@ -434,6 +475,7 @@ impl Decode for Vec> { } } +#[cfg(feature = "postgres4-types")] fn map_decimal(s: &Option) -> Result, Error> { s.as_ref() .map(|s| rust_decimal::Decimal::from_str_exact(s)) @@ -441,6 +483,7 @@ fn map_decimal(s: &Option) -> Result, Erro .map_err(|e| Error::Decode(e.to_string())) } +#[cfg(feature = "postgres4-types")] impl Decode for Vec> { fn decode(value: &DbValue) -> Result { match value { @@ -526,12 +569,14 @@ impl From for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From for ParameterValue { fn from(v: uuid::Uuid) -> ParameterValue { ParameterValue::Uuid(v.to_string()) } } +#[cfg(feature = "json")] impl TryFrom for ParameterValue { type Error = serde_json::Error; @@ -541,11 +586,13 @@ impl TryFrom for ParameterValue { } /// Converts a `Serialize` value to a Postgres JSONB SQL parameter. +#[cfg(feature = "json")] pub fn jsonb(value: &T) -> Result { let json = serde_json::to_vec(value)?; Ok(ParameterValue::Jsonb(json)) } +#[cfg(feature = "postgres4-types")] impl From for ParameterValue { fn from(v: rust_decimal::Decimal) -> ParameterValue { ParameterValue::Decimal(v.to_string()) @@ -580,6 +627,7 @@ fn range_bound_to_wit( } } +#[cfg(feature = "postgres4-types")] fn pg_range_bound_to_wit( bound: &postgres_range::RangeBound, ) -> (T, RangeBoundKind) { @@ -620,6 +668,7 @@ impl From> for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From> for ParameterValue { fn from(v: postgres_range::Range) -> ParameterValue { let lbound = v.lower().map(pg_range_bound_to_wit); @@ -658,6 +707,7 @@ impl From> for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From> for ParameterValue { fn from(v: postgres_range::Range) -> ParameterValue { let lbound = v.lower().map(pg_range_bound_to_wit); @@ -666,6 +716,7 @@ impl From> for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From> for ParameterValue { fn from(v: std::ops::Range) -> ParameterValue { ParameterValue::RangeDecimal(range_bounds_to_wit(v, |d| d.to_string())) @@ -690,6 +741,7 @@ impl From> for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From>> for ParameterValue { fn from(v: Vec>) -> ParameterValue { let strs = v @@ -700,6 +752,7 @@ impl From>> for ParameterValue { } } +#[cfg(feature = "postgres4-types")] impl From> for ParameterValue { fn from(v: Vec) -> ParameterValue { let strs = v.into_iter().map(|d| Some(d.to_string())).collect(); @@ -859,4 +912,118 @@ mod tests { .unwrap() .is_none()); } + + #[test] + #[cfg(feature = "postgres4-types")] + fn uuid() { + let uuid_str = "12341234-1234-1234-1234-123412341234"; + assert_eq!( + uuid::Uuid::try_parse(uuid_str).unwrap(), + uuid::Uuid::decode(&DbValue::Uuid(uuid_str.to_owned())).unwrap(), + ); + assert!(Option::::decode(&DbValue::DbNull) + .unwrap() + .is_none()); + } + + #[derive(Debug, serde::Deserialize, PartialEq)] + struct JsonTest { + hello: String, + } + + #[test] + #[cfg(feature = "json")] + fn jsonb() { + let json_val = serde_json::json!({ + "hello": "world" + }); + let dbval = DbValue::Jsonb(r#"{"hello":"world"}"#.into()); + + assert_eq!(json_val, serde_json::Value::decode(&dbval).unwrap(),); + + let json_struct = JsonTest { + hello: "world".to_owned(), + }; + assert_eq!(json_struct, from_jsonb(&dbval).unwrap()); + } + + #[test] + #[cfg(feature = "postgres4-types")] + fn ranges() { + let i32_range = postgres_range::Range::::decode(&DbValue::RangeInt32(( + Some((45, RangeBoundKind::Inclusive)), + Some((89, RangeBoundKind::Exclusive)), + ))) + .unwrap(); + assert_eq!(45, i32_range.lower().unwrap().value); + assert_eq!( + postgres_range::BoundType::Inclusive, + i32_range.lower().unwrap().type_ + ); + assert_eq!(89, i32_range.upper().unwrap().value); + assert_eq!( + postgres_range::BoundType::Exclusive, + i32_range.upper().unwrap().type_ + ); + + let i32_range_from = postgres_range::Range::::decode(&DbValue::RangeInt32(( + Some((45, RangeBoundKind::Inclusive)), + None, + ))) + .unwrap(); + assert!(i32_range_from.upper().is_none()); + + let i64_range = postgres_range::Range::::decode(&DbValue::RangeInt64(( + Some((4567456745674567, RangeBoundKind::Inclusive)), + Some((890189018901890189, RangeBoundKind::Exclusive)), + ))) + .unwrap(); + assert_eq!(4567456745674567, i64_range.lower().unwrap().value); + assert_eq!(890189018901890189, i64_range.upper().unwrap().value); + + #[allow(clippy::type_complexity)] + let (dec_lbound, dec_ubound): ( + Option<(rust_decimal::Decimal, RangeBoundKind)>, + Option<(rust_decimal::Decimal, RangeBoundKind)>, + ) = Decode::decode(&DbValue::RangeDecimal(( + Some(("4567.8901".to_owned(), RangeBoundKind::Inclusive)), + Some(("8901.2345678901".to_owned(), RangeBoundKind::Exclusive)), + ))) + .unwrap(); + assert_eq!( + rust_decimal::Decimal::from_i128_with_scale(45678901, 4), + dec_lbound.unwrap().0 + ); + assert_eq!( + rust_decimal::Decimal::from_i128_with_scale(89012345678901, 10), + dec_ubound.unwrap().0 + ); + } + + #[test] + #[cfg(feature = "postgres4-types")] + fn arrays() { + let v32 = vec![Some(123), None, Some(456)]; + let i32_arr = Vec::>::decode(&DbValue::ArrayInt32(v32.clone())).unwrap(); + assert_eq!(v32, i32_arr); + + let v64 = vec![Some(123), None, Some(456)]; + let i64_arr = Vec::>::decode(&DbValue::ArrayInt64(v64.clone())).unwrap(); + assert_eq!(v64, i64_arr); + + let vdec = vec![Some("1.23".to_owned()), None]; + let dec_arr = + Vec::>::decode(&DbValue::ArrayDecimal(vdec)).unwrap(); + assert_eq!( + vec![ + Some(rust_decimal::Decimal::from_i128_with_scale(123, 2)), + None + ], + dec_arr + ); + + let vstr = vec![Some("alice".to_owned()), None, Some("bob".to_owned())]; + let str_arr = Vec::>::decode(&DbValue::ArrayStr(vstr.clone())).unwrap(); + assert_eq!(vstr, str_arr); + } } From 44350d6225a3dc11cc8c0a253d8b4ecb21b1c0b8 Mon Sep 17 00:00:00 2001 From: itowlson Date: Mon, 18 Aug 2025 15:02:52 +1200 Subject: [PATCH 3/5] Check correctness with no or individual features Signed-off-by: itowlson --- .github/workflows/build.yml | 12 ++++++++++++ src/http/conversions.rs | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5a203fd..2a5c395 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,6 +35,18 @@ jobs: cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings + - name: Check with no-default-features + shell: bash + run: cargo check --no-default-features + + - name: Check with `json` feature only + shell: bash + run: cargo check --no-default-features --features json + + - name: Check with `postgres4-types` feature only + shell: bash + run: cargo check --no-default-features --features postgres4-types + - name: Test shell: bash run: cargo test --workspace diff --git a/src/http/conversions.rs b/src/http/conversions.rs index b816ec7..2769c1b 100644 --- a/src/http/conversions.rs +++ b/src/http/conversions.rs @@ -5,9 +5,11 @@ use async_trait::async_trait; use wasi::io::streams; use super::{ - Headers, IncomingRequest, IncomingResponse, Json, JsonBodyError, Method, OutgoingRequest, - OutgoingResponse, RequestBuilder, + Headers, IncomingRequest, IncomingResponse, Method, OutgoingRequest, OutgoingResponse, + RequestBuilder, }; +#[cfg(feature = "json")] +use super::{Json, JsonBodyError}; use super::{responses, NonUtf8BodyError, Request, Response}; From 978a1b3a2153d8fad37dcbb3789489ed75d9d1ef Mon Sep 17 00:00:00 2001 From: itowlson Date: Mon, 18 Aug 2025 15:20:33 +1200 Subject: [PATCH 4/5] Docs fixes Signed-off-by: itowlson --- src/lib.rs | 2 +- src/pg4.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 92022d2..53e115a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ mod test; pub mod key_value; /// SQLite storage for Spin 2 and earlier. Applications that do not require -/// this backward compatibility should use the [`sqlite3`](crate::sqlite3) module instead. +/// this backward compatibility should use the [`sqlite3`] module instead. pub mod sqlite; /// SQLite storage. pub mod sqlite3; diff --git a/src/pg4.rs b/src/pg4.rs index a76825b..ba05ebb 100644 --- a/src/pg4.rs +++ b/src/pg4.rs @@ -27,12 +27,12 @@ //! | `chrono::Duration` | timestamp(s64) | BIGINT | //! | `uuid::Uuid` | uuid(string) | UUID | //! | `serde_json::Value` | jsonb(list\) | JSONB | -//! | `serde::De/Serialize | jsonb(list\) | JSONB | +//! | `serde::De/Serialize` | jsonb(list\) | JSONB | //! | `rust_decimal::Decimal` | decimal(string) | NUMERIC | //! | `postgres_range` | range-int32(...), range-int64(...) | INT4RANGE, INT8RANGE | //! | lower/upper tuple | range-decimal(...) | NUMERICRANGE | //! | `Vec>` | array-int32(...), array-int64(...), array-str(...), array-decimal(...) | INT4[], INT8[], TEXT[], NUMERIC[] | -//! | `pg4::Interval | interval(interval) | INTERVAL | +//! | `pg4::Interval` | interval(interval) | INTERVAL | /// An open connection to a PostgreSQL database. /// From 5804dc0f77b0b8dd32af7a29f35a92000f31b272 Mon Sep 17 00:00:00 2001 From: itowlson Date: Mon, 18 Aug 2025 17:09:39 +1200 Subject: [PATCH 5/5] Example of using PG ranges Signed-off-by: itowlson --- Cargo.lock | 9 ++++++ Cargo.toml | 1 + examples/postgres-v4/.cargo/config.toml | 2 ++ examples/postgres-v4/Cargo.toml | 15 +++++++++ examples/postgres-v4/README.md | 36 +++++++++++++++++++++ examples/postgres-v4/db/testdata.sql | 14 +++++++++ examples/postgres-v4/spin.toml | 17 ++++++++++ examples/postgres-v4/src/lib.rs | 42 +++++++++++++++++++++++++ 8 files changed, 136 insertions(+) create mode 100644 examples/postgres-v4/.cargo/config.toml create mode 100644 examples/postgres-v4/Cargo.toml create mode 100644 examples/postgres-v4/README.md create mode 100644 examples/postgres-v4/db/testdata.sql create mode 100644 examples/postgres-v4/spin.toml create mode 100644 examples/postgres-v4/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 075c297..0fc6859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1803,6 +1803,15 @@ dependencies = [ "spin-sdk", ] +[[package]] +name = "rust-outbound-pg-v4" +version = "0.1.0" +dependencies = [ + "anyhow", + "http 1.0.0", + "spin-sdk", +] + [[package]] name = "rust-outbound-redis" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 08a6066..39f0a51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ "examples/mysql", "examples/postgres", "examples/postgres-v3", + "examples/postgres-v4", "examples/redis-outbound", "examples/mqtt-outbound", "examples/variables", diff --git a/examples/postgres-v4/.cargo/config.toml b/examples/postgres-v4/.cargo/config.toml new file mode 100644 index 0000000..6b509f5 --- /dev/null +++ b/examples/postgres-v4/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/examples/postgres-v4/Cargo.toml b/examples/postgres-v4/Cargo.toml new file mode 100644 index 0000000..cc4ce7c --- /dev/null +++ b/examples/postgres-v4/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rust-outbound-pg-v4" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Useful crate to handle errors. +anyhow = "1" +# General-purpose crate with common HTTP types. +http = "1.0.0" +# The Spin SDK. +spin-sdk = { path = "../.." } diff --git a/examples/postgres-v4/README.md b/examples/postgres-v4/README.md new file mode 100644 index 0000000..7edc037 --- /dev/null +++ b/examples/postgres-v4/README.md @@ -0,0 +1,36 @@ +# Spin Outbound PostgreSQL example + +This example shows how to access a PostgreSQL database from a Spin component. +It shows the new PostgreSQL range support in the v4 interface. + +## Prerequisite: Postgres + +This example assumes postgres is running and accessible locally via its standard 5432 port. + +We suggest running the `postgres` Docker container which has the necessary postgres user permissions +already configured. For example: + +``` +docker run --rm -h 127.0.0.1 -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres +``` + +## Spin up + +Then, run the following from the root of this example: + +``` +createdb -h localhost -U postgres spin_dev +psql -h localhost -U postgres -d spin_dev -f db/testdata.sql +spin build --up +``` + +Curl with a year between 2005 and today as the path: + +``` +$ curl -i localhost:3000/2016 +HTTP/1.1 200 OK +transfer-encoding: chunked +date: Mon, 18 Aug 2025 05:02:29 GMT + +Splodge and Fang and Kiki and Slats +``` diff --git a/examples/postgres-v4/db/testdata.sql b/examples/postgres-v4/db/testdata.sql new file mode 100644 index 0000000..b73198c --- /dev/null +++ b/examples/postgres-v4/db/testdata.sql @@ -0,0 +1,14 @@ +CREATE TABLE cats ( + name text not null, + reign int4range not null +); + +INSERT INTO cats (name, reign) VALUES + ('Smoke', '[2005, 2013]'::int4range), + ('Splodge', '[2005, 2019]'::int4range), + ('Fang', '[2005, 2016]'::int4range), + ('Kiki', '[2005, 2020]'::int4range), + ('Slats', '[2005, 2021]'::int4range), + ('Rosie', '[2021,)'::int4range), + ('Hobbes', '[2021,)'::int4range) +; diff --git a/examples/postgres-v4/spin.toml b/examples/postgres-v4/spin.toml new file mode 100644 index 0000000..274a7b9 --- /dev/null +++ b/examples/postgres-v4/spin.toml @@ -0,0 +1,17 @@ +spin_manifest_version = 2 + +[application] +authors = ["Fermyon Engineering "] +name = "rust-outbound-pg-v4-example" +version = "0.1.0" + +[[trigger.http]] +route = "/:year" +component = "outbound-pg" + +[component.outbound-pg] +environment = { DB_URL = "host=localhost user=postgres dbname=spin_dev" } +source = "../../target/wasm32-wasip1/release/rust_outbound_pg_v4.wasm" +allowed_outbound_hosts = ["postgres://localhost"] +[component.outbound-pg.build] +command = "cargo build --target wasm32-wasip1 --release" diff --git a/examples/postgres-v4/src/lib.rs b/examples/postgres-v4/src/lib.rs new file mode 100644 index 0000000..bc253bd --- /dev/null +++ b/examples/postgres-v4/src/lib.rs @@ -0,0 +1,42 @@ +#![allow(dead_code)] +use anyhow::Result; +use http::{Request, Response}; +use spin_sdk::{http_component, pg3, pg3::Decode}; + +// The environment variable set in `spin.toml` that points to the +// address of the Pg server that the component will write to +const DB_URL_ENV: &str = "DB_URL"; + +#[http_component] +fn process(req: Request<()>) -> Result> { + let address = std::env::var(DB_URL_ENV)?; + let conn = pg3::Connection::open(&address)?; + + let year_header = req + .headers() + .get("spin-path-match-year") + .map(|hv| hv.to_str()) + .transpose()? + .unwrap_or("2025"); + let year: i32 = year_header.parse()?; + + // Due to an ambiguity in the PostgreSQL `<@` operator syntax, we MUST qualify + // the year as an int4 rather than an int4range in the query. + let rulers = conn.query( + "SELECT name FROM cats WHERE $1::int4 <@ reign", + &[year.into()], + )?; + + let response = if rulers.rows.is_empty() { + "it was anarchy".to_owned() + } else { + let ruler_names = rulers + .rows + .into_iter() + .map(|r| Decode::decode(&r[0])) + .collect::, _>>()?; + ruler_names.join(" and ") + }; + + Ok(http::Response::builder().body(format!("{response}\n"))?) +}