Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
katsumi143 committed May 6, 2024
1 parent 838a1ef commit da01673
Show file tree
Hide file tree
Showing 12 changed files with 703 additions and 1 deletion.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ Cargo.lock

# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

.cargo/
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"rust-analyzer.check.command": "clippy",
"rust-analyzer.linkedProjects": ["./Cargo.toml"]
}
17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "postgrest"
authors = ["HAKUMI", "katsumi143 <katsumi@hakumi.cafe>"]
version = "0.1.0"
edition = "2021"

[dependencies]
url = "2.5"
bytes = "1.6"
serde = { version = "1.0", features = ["derive"] }
reqwest = "0.12"
simd-json = { version = "0.13", optional = true }
thiserror = "1.0"
serde_json = "1.0"

[dev-dependencies]
tokio = { version = "1.37.0", features = ["macros"] }
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,60 @@
# postgrest-rs
# postgrest-rs
another rust client library for [PostgREST](https://postgrest.org)!

> [!WARNING]
> This library is *not* production-ready or feature-complete, it currently provides a very limited query filter and only JSON results.
## Examples
Add the library to your project in `Cargo.toml`:
```toml
[dependencies]
postgrest = { git = "https://github.com/hakusoda/postgrest-rs.git" }
```

Basic usage:
```rs
use serde::Deserialize;
use postgrest::PostgrestClient;

#[derive(Debug, Deserialize)]
struct User {
id: String,
username: String
}

let client = PostgrestClient::new("https://your.postgrest.endpoint.lgbt")?;
let result = client
.from("users")
.select::<User>("id, username")
.await?;
println!("{result:?}");
```

Usage for Supabase projects:
```rs
use postgrest::PostgrestClient;

let client = PostgrestClient::new("https://your.postgrest.endpoint.lgbt")?
// in a real scenario, you should use an environment variable instead of hardcoding your API key.
.with_supabase_key("YOUR_SUPABASE_KEY")?;
/// ...your other code here!
```

## Features
### `simd-json`
This feature enables [simd-json](https://crates.io/crates/simd-json) support to utilise SIMD features of modern CPUs to deserialise responses faster, it is disabled by default.
<br/><br/>
To use this feature you must first enable the library feature in your `Cargo.toml`:
```toml
[dependencies]
postgrest = { git = "https://github.com/hakusoda/postgrest-rs.git", features = ["simd-json"] }
```

Additionally, you'll need to add this to `<project root>/.cargo/config.toml`:
```toml
[build]
rustflags = ["-C", "target-cpu=native"]
```

## Contributing
feel free to do whatever! (within acceptable bounds)
233 changes: 233 additions & 0 deletions src/builder/filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
use std::{
pin::Pin,
task::{ Poll, Context },
future::Future,
marker::PhantomData
};
use bytes::Bytes;
use serde::{
de::DeserializeOwned,
Deserialize, Deserializer
};
use reqwest::{
header::{ HeaderMap, HeaderName },
Method
};

use super::QueryBuilder;
use crate::{
json,
error::Error,
result::PostgrestResult,
Count, Result, PostgrestClient
};

pub struct FilterBuilder<'a, T: DeserializeOwned, F: DeserializeOwned> {
url: Option<String>,
#[allow(clippy::type_complexity)]
fut: Option<Pin<Box<dyn Future<Output = Result<PostgrestResult<F>>> + Send + 'a>>>,
body: Option<json::Value>,
count: Option<Count>,
query: Option<Vec<(&'static str, String)>>,
method: Option<Method>,
client: Option<&'a PostgrestClient>,
schema: Option<String>,
headers: Option<HeaderMap>,
phantom: PhantomData<(T, F)>,
is_maybe_single: bool
}

impl<'a, T: DeserializeOwned, F: DeserializeOwned> FilterBuilder<'a, T, F> {
pub fn new(query: QueryBuilder<'a>, method: Method, body: Option<json::Value>) -> Self {
Self {
fut: None,
url: Some(query.url),
body,
count: None,
query: Some(query.query),
method: Some(method),
client: Some(query.client),
schema: Some(query.schema),
headers: Some(query.headers),
phantom: Default::default(),
is_maybe_single: false
}
}

/// Match only rows where `column` is equal to `value`.
///
/// To check if the value of `column` is NULL, you should use `.is()` instead.
pub fn eq(mut self, column: &'static str, value: impl ToString) -> Self {
self.query.as_mut().unwrap().push((column, format!("eq.{}", value.to_string())));
self
}

pub fn head(self) -> FilterBuilder<'a, (), ()> {
FilterBuilder {
url: self.url,
fut: None,
body: self.body,
count: self.count,
query: self.query,
client: self.client,
method: Some(Method::HEAD),
schema: self.schema,
headers: self.headers,
phantom: PhantomData,
is_maybe_single: false
}
}

pub fn count(mut self, count: Count) -> Self {
self = self.header("prefer", format!("count={}", count.to_string()));
self.count.replace(count);
self
}

pub fn header(mut self, key: impl ToString, value: impl ToString) -> Self {
self.headers.as_mut().unwrap().insert(HeaderName::try_from(key.to_string()).unwrap(), value.to_string().parse().unwrap());
self
}

/// Limit the query result by `count`.
pub fn limit(mut self, count: usize) -> Self {
self.query.as_mut().unwrap().push(("limit", count.to_string()));
self
}

/// Return `data` as a single object instead of an array of objects.
pub fn single(mut self) -> FilterBuilder<'a, T, T> {
self.headers.as_mut().unwrap().insert("accept", "application/vnd.pgrst.object+json".parse().unwrap());
FilterBuilder {
url: self.url,
fut: None,
body: self.body,
count: self.count,
query: self.query,
method: self.method,
client: self.client,
schema: self.schema,
headers: self.headers,
phantom: PhantomData,
is_maybe_single: false
}
}

/// Return `data` as a single object instead of an array of objects.
///
/// Query result must be zero or one row (e.g. using `.limit(1)`), otherwise this returns an error.
pub fn maybe_single(mut self) -> FilterBuilder<'a, T, Option<T>> {
self.headers.as_mut().unwrap().insert("accept", match self.method.as_ref().unwrap() {
&Method::GET => "application/json",
_ => "application/vnd.pgrst.object+json"
}.parse().unwrap());
FilterBuilder {
url: self.url,
fut: None,
body: self.body,
count: self.count,
query: self.query,
method: self.method,
client: self.client,
schema: self.schema,
headers: self.headers,
phantom: PhantomData,
is_maybe_single: true
}
}
}

impl<'a, T: DeserializeOwned + Unpin, F: DeserializeOwned + Unpin> Future for FilterBuilder<'a, T, F> {
type Output = Result<PostgrestResult<F>>;

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.fut.is_none() {
let client = self.client.take().unwrap();
let mut headers = client.default_headers.clone();
for (key, value) in self.headers.take().unwrap().iter() {
headers.insert(key, value.clone());
}

let method = self.method.take().unwrap();
let is_head = matches!(method, Method::HEAD);
let is_select = is_head || matches!(method, Method::GET);
let mut builder = client.http
.request(method, self.url.take().unwrap())
.query(&self.query.take().unwrap())
.headers(headers)
.header(if is_select { "accept-profile" } else { "content-profile" }, self.schema.take().unwrap());
if let Some(body) = self.body.take() {
builder = builder
.body(json::to_string(&body)?)
.header("content-type", "application/json");
}

let response = builder.send();

let count = self.count.take();
let is_maybe_single = self.is_maybe_single;
self.fut = Some(Box::pin(async move {
let response = response.await?;
let (mut item_count, total_item_count) = {
if let Some(range) = response.headers().get("content-range") {
let mut split = range
.to_str()
.unwrap()
.split('/');
let item_count = if let Some(mut range) = split.next().map(|x| x.split('-')) {
let from = range.next().and_then(|x| x.parse::<isize>().ok()).unwrap_or(0);
let to = range.next().and_then(|x| x.parse::<isize>().ok()).unwrap_or(-1);
(to - from + 1) as usize
} else { 0 };
(item_count, count.map(|_| split.next().unwrap().parse::<usize>().unwrap()))
} else { (0, None) }
};

let is_success = response.status().is_success();
let mut bytes = if is_head || !is_select { Bytes::from("null") } else { response.bytes().await? };
if bytes.is_empty() {
bytes = Bytes::from("[]");
}

match is_success {
true => Ok(PostgrestResult {
value: match is_maybe_single {
true => {
let (item, is_some) = json::from_bytes::<MaybeSingleWrapper<F>>(&bytes)?.0;
if is_some {
item_count = 1;
}

item
},
false => json::from_bytes(&bytes)?
},
item_count,
total_item_count
}),
false => Err(Error::PostgrestError(
json::from_bytes(&bytes)?
))
}
}));
}

self.fut.as_mut().unwrap().as_mut().poll(cx)
}
}


// honestly kind of silly, but it's a classic katsumi workaround, so blehhhhhh!!!
#[derive(Deserialize)]
struct MaybeSingleWrapper<T: DeserializeOwned>(#[serde(deserialize_with = "deserialize_maybe_single_wrapper")] (T, bool));

fn deserialize_maybe_single_wrapper<'de, D: Deserializer<'de>, T: DeserializeOwned>(deserializer: D) -> core::result::Result<(T, bool), D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;

let item = value.as_array().unwrap().first();
let is_some = item.is_some();
Ok((serde_json::from_value(match item {
Some(x) => x.clone(),
None => serde_json::Value::Null
}).unwrap(), is_some))
}
5 changes: 5 additions & 0 deletions src/builder/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod query;
pub mod filter;

pub use query::QueryBuilder;
pub use filter::FilterBuilder;
Loading

0 comments on commit da01673

Please sign in to comment.