mirror of
https://github.com/Phundrak/georm.git
synced 2025-08-30 22:25:35 +00:00
Compare commits
4 Commits
8468c3cd61
...
5d8a1b1917
Author | SHA1 | Date | |
---|---|---|---|
5d8a1b1917 | |||
49c7d86102 | |||
3307aa679d | |||
545dfa066d |
27
Cargo.lock
generated
27
Cargo.lock
generated
@ -136,6 +136,19 @@ version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
|
||||
|
||||
[[package]]
|
||||
name = "bigdecimal"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a22f228ab7a1b23027ccc6c350b72868017af7ea8356fbdf19f8d991c690013"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@ -1021,6 +1034,16 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.4"
|
||||
@ -1515,6 +1538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bigdecimal",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
@ -1589,6 +1613,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"bigdecimal",
|
||||
"bitflags 2.9.1",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@ -1632,6 +1657,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"bigdecimal",
|
||||
"bitflags 2.9.1",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
@ -1649,6 +1675,7 @@ dependencies = [
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
|
@ -32,7 +32,7 @@ georm-macros = { version = "=0.2.1", path = "georm-macros" }
|
||||
[workspace.dependencies.sqlx]
|
||||
version = "0.8.6"
|
||||
default-features = false
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate"]
|
||||
features = ["postgres", "runtime-tokio", "macros", "migrate", "bigdecimal"]
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
|
133
README.md
133
README.md
@ -38,6 +38,7 @@ Georm is a lightweight, opinionated Object-Relational Mapping (ORM) library buil
|
||||
### Key Features
|
||||
|
||||
- **Type Safety**: Compile-time verified SQL queries using SQLx macros
|
||||
- **Flexible Executor**: Works with both `PgPool` and `Transaction` for atomic operations.
|
||||
- **Zero Runtime Cost**: No reflection or runtime query building
|
||||
- **Simple API**: Intuitive derive macros for common operations
|
||||
- **Relationship Support**: One-to-one, one-to-many, and many-to-many relationships
|
||||
@ -116,13 +117,16 @@ pub struct Post {
|
||||
use sqlx::PgPool;
|
||||
|
||||
async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
||||
// Start a transaction
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Create an author
|
||||
let author = Author {
|
||||
id: 0, // Will be auto-generated
|
||||
name: "Jane Doe".to_string(),
|
||||
email: "jane@example.com".to_string(),
|
||||
};
|
||||
let author = author.create(pool).await?;
|
||||
let author = author.create(&mut *tx).await?;
|
||||
|
||||
// Create a post
|
||||
let post = Post {
|
||||
@ -133,9 +137,12 @@ async fn example(pool: &PgPool) -> sqlx::Result<()> {
|
||||
author_id: author.id,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let post = post.create(pool).await?;
|
||||
let post = post.create(&mut *tx).await?;
|
||||
|
||||
// Find all posts
|
||||
// Commit the transaction
|
||||
tx.commit().await?;
|
||||
|
||||
// Find all posts (using the pool directly)
|
||||
let all_posts = Post::find_all(pool).await?;
|
||||
|
||||
// Get the post's author
|
||||
@ -181,9 +188,13 @@ let user_role = UserRole::find(pool, &id).await?;
|
||||
|
||||
**Note**: Relationships are not yet supported for entities with composite primary keys.
|
||||
|
||||
### Defaultable Fields
|
||||
### Defaultable and Generated Fields
|
||||
|
||||
For fields with database defaults or auto-generated values, use the `defaultable` attribute:
|
||||
Georm provides three attributes for handling fields with database-managed values:
|
||||
|
||||
#### `#[georm(defaultable)]` - Optional Override Fields
|
||||
|
||||
For fields with database defaults that can be manually overridden:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
@ -200,22 +211,74 @@ pub struct Post {
|
||||
}
|
||||
```
|
||||
|
||||
This generates a `PostDefault` struct for easier creation:
|
||||
#### `#[georm(generated)]` - Generated by Default
|
||||
|
||||
For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
#[georm(generated)]
|
||||
pub sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
|
||||
pub name: String,
|
||||
pub price: sqlx::types::BigDecimal,
|
||||
}
|
||||
```
|
||||
|
||||
#### `#[georm(generated_always)]` - Always Generated
|
||||
|
||||
For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
|
||||
|
||||
```rust
|
||||
#[derive(Georm)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
pub name: String,
|
||||
pub price: sqlx::types::BigDecimal,
|
||||
pub discount_percent: i32,
|
||||
#[georm(generated_always)]
|
||||
pub final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
|
||||
}
|
||||
```
|
||||
|
||||
#### Generated Structs and Behavior
|
||||
|
||||
Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct:
|
||||
|
||||
```rust
|
||||
use georm::Defaultable;
|
||||
|
||||
let post_default = PostDefault {
|
||||
id: None, // Let database auto-generate
|
||||
title: "My Post".to_string(),
|
||||
published: None, // Use database default
|
||||
created_at: None, // Use database default (NOW())
|
||||
author_id: 42,
|
||||
let product_default = ProductDefault {
|
||||
name: "Laptop".to_string(),
|
||||
price: BigDecimal::from(999),
|
||||
discount_percent: 10,
|
||||
sku_number: None, // Let database auto-generate
|
||||
// Note: generated_always fields are excluded from this struct
|
||||
};
|
||||
|
||||
let created_post = post_default.create(pool).await?;
|
||||
let created_product = product_default.create(pool).await?;
|
||||
```
|
||||
|
||||
#### Key Differences
|
||||
|
||||
| Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
|
||||
|--------------------|------------------------------------|-----------------|----------------------------------------------|
|
||||
| `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
|
||||
| `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
|
||||
| `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
|
||||
|
||||
#### Important Notes
|
||||
|
||||
- **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
|
||||
- **`generated` and `generated_always` cannot be used together** on the same field
|
||||
- **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
|
||||
- **Option types cannot be marked as `defaultable`** to prevent `Option<Option<T>>` situations
|
||||
|
||||
### Relationships
|
||||
|
||||
Georm supports comprehensive relationship modeling with two approaches: field-level relationships for foreign keys and struct-level relationships for reverse lookups.
|
||||
@ -465,22 +528,41 @@ pub struct Post {
|
||||
|
||||
### Core Operations
|
||||
|
||||
All entities implementing `Georm<Id>` get these methods:
|
||||
All entities implementing `Georm<Id>` get these methods. All database operations now accept any `sqlx`-compatible executor, which can be a connection pool (`&PgPool`) or a transaction (`&mut Transaction<'_, Postgres>`).
|
||||
|
||||
```rust
|
||||
async fn example(pool: &PgPool, post_id: i32) -> sqlx::Result<()> {
|
||||
// Operations on the pool
|
||||
let all_posts = Post::find_all(pool).await?;
|
||||
|
||||
// Operations within a transaction
|
||||
let mut tx = pool.begin().await?;
|
||||
let post = Post::find(&mut *tx, &post_id).await?;
|
||||
if let Some(post) = post {
|
||||
post.delete(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The available methods are:
|
||||
|
||||
```rust
|
||||
// Query operations
|
||||
Post::find_all(pool).await?; // Find all posts
|
||||
Post::find(pool, &post_id).await?; // Find by ID
|
||||
Post::find_all(executor).await?;
|
||||
Post::find(executor, &post_id).await?;
|
||||
|
||||
// Mutation operations
|
||||
post.create(pool).await?; // Insert new record
|
||||
post.update(pool).await?; // Update existing record
|
||||
post.create_or_update(pool).await?; // Upsert operation
|
||||
post.delete(pool).await?; // Delete this record
|
||||
Post::delete_by_id(pool, &post_id).await?; // Delete by ID
|
||||
post.create(executor).await?;
|
||||
post.update(executor).await?;
|
||||
post.upsert(executor).await?;
|
||||
post.delete(executor).await?;
|
||||
Post::delete_by_id(executor, &post_id).await?;
|
||||
|
||||
// Utility
|
||||
post.get_id(); // Get entity ID
|
||||
post.get_id();
|
||||
```
|
||||
|
||||
### Defaultable Operations
|
||||
@ -512,6 +594,8 @@ post_default.create(pool).await?;
|
||||
```rust
|
||||
#[georm(id)] // Mark as primary key
|
||||
#[georm(defaultable)] // Mark as defaultable field
|
||||
#[georm(generated)] // Mark as generated by default field
|
||||
#[georm(generated_always)] // Mark as always generated field
|
||||
#[georm(relation = { /* ... */ })] // Define relationship
|
||||
```
|
||||
|
||||
@ -556,15 +640,16 @@ cargo run help # For a list of all available actions
|
||||
|----------------------|-------|--------|--------|
|
||||
| Compile-time safety | ✅ | ✅ | ✅ |
|
||||
| Relationship support | ✅ | ✅ | ✅ |
|
||||
| Async support | ✅ | ✅ | ⚠️ |
|
||||
| Async support | ✅ | ✅ | ⚠️[^1] |
|
||||
| Learning curve | Low | Medium | High |
|
||||
| Macro simplicity | ✅ | ❌ | ❌ |
|
||||
| Advanced queries | ❌ | ✅ | ✅ |
|
||||
|
||||
[^1]: Requires `diesel-async`
|
||||
|
||||
## Roadmap
|
||||
|
||||
### High Priority
|
||||
- **Transaction Support**: Comprehensive transaction handling with atomic operations
|
||||
- **Simplified Relationship Syntax**: Remove redundant table/remote_id specifications by inferring them from target entity metadata
|
||||
- **Multi-Database Support**: MySQL and SQLite support with feature flags
|
||||
|
||||
|
@ -61,7 +61,8 @@ async fn create_comment(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result {
|
||||
let prompt = "Who is creating the comment?";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).await?;
|
||||
let content = match text {
|
||||
Some(text) => text,
|
||||
None => inquire::Text::new("Content of the comment:")
|
||||
@ -73,29 +74,33 @@ async fn create_comment(
|
||||
content,
|
||||
id: None,
|
||||
};
|
||||
let comment = comment.create(pool).await?;
|
||||
let comment = comment.create(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
println!("Successfuly created comment:\n{comment}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_comment(id: Option<i32>, pool: &sqlx::PgPool) -> Result {
|
||||
let prompt = "Select the comment to remove:";
|
||||
let mut tx = pool.begin().await?;
|
||||
let comment = match id {
|
||||
Some(id) => Comment::find(pool, &id)
|
||||
Some(id) => Comment::find(&mut *tx, &id)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)?
|
||||
.ok_or(UserInputError::CommentDoesNotExist)?,
|
||||
None => Comment::select_comment(prompt, pool).await?,
|
||||
None => Comment::select_comment(prompt, &mut *tx).await?,
|
||||
};
|
||||
comment.delete(pool).await?;
|
||||
comment.delete(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let mut tx = pool.begin().await?;
|
||||
let prompt = "Select user whose comment you want to delete:";
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, pool).await?;
|
||||
let user = User::get_user_by_username_or_select(username.as_deref(), prompt, &mut *tx).await?;
|
||||
let comments: HashMap<String, Comment> = user
|
||||
.get_comments(pool)
|
||||
.get_comments(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|comment| (comment.content.clone(), comment))
|
||||
@ -105,7 +110,8 @@ async fn remove_user_comment(username: Option<String>, pool: &sqlx::PgPool) -> R
|
||||
.prompt()
|
||||
.map_err(UserInputError::InquireError)?;
|
||||
let comment: &Comment = comments.get(&selected_comment_content).unwrap();
|
||||
comment.delete(pool).await?;
|
||||
comment.delete(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -53,16 +53,17 @@ async fn follow_user(
|
||||
followed: Option<String>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result {
|
||||
let mut tx = pool.begin().await?;
|
||||
let follower = User::get_user_by_username_or_select(
|
||||
follower.as_deref(),
|
||||
"Select who will be following someone:",
|
||||
pool,
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let followed = User::get_user_by_username_or_select(
|
||||
followed.as_deref(),
|
||||
"Select who will be followed:",
|
||||
pool,
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let follow = FollowerDefault {
|
||||
@ -70,17 +71,22 @@ async fn follow_user(
|
||||
follower: follower.id,
|
||||
followed: followed.id,
|
||||
};
|
||||
follow.create(pool).await?;
|
||||
follow.create(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
println!("User {follower} now follows {followed}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result {
|
||||
let follower =
|
||||
User::get_user_by_username_or_select(follower.as_deref(), "Select who is following", pool)
|
||||
let mut tx = pool.begin().await?;
|
||||
let follower = User::get_user_by_username_or_select(
|
||||
follower.as_deref(),
|
||||
"Select who is following",
|
||||
&mut *tx,
|
||||
)
|
||||
.await?;
|
||||
let followed_list: HashMap<String, User> = follower
|
||||
.get_followed(pool)
|
||||
.get_followed(&mut *tx)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|person| (person.username.clone(), person.clone()))
|
||||
@ -97,8 +103,9 @@ async fn unfollow_user(follower: Option<String>, pool: &sqlx::PgPool) -> Result
|
||||
follower.id,
|
||||
followed.id
|
||||
)
|
||||
.execute(pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
println!("User {follower} unfollowed {followed}");
|
||||
Ok(())
|
||||
}
|
||||
|
@ -18,8 +18,11 @@ pub struct Comment {
|
||||
}
|
||||
|
||||
impl Comment {
|
||||
pub async fn select_comment(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let comments: HashMap<String, Self> = Self::find_all(pool)
|
||||
pub async fn select_comment<'e, E>(prompt: &str, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let comments: HashMap<String, Self> = Self::find_all(executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|comment| (comment.content.clone(), comment))
|
||||
|
@ -38,7 +38,10 @@ impl Profile {
|
||||
self.bio.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn try_new(user_id: i32, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
pub async fn try_new<'e, E>(user_id: i32, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let profile = ProfileDefault {
|
||||
user_id,
|
||||
id: None,
|
||||
@ -46,20 +49,23 @@ impl Profile {
|
||||
display_name: None,
|
||||
};
|
||||
profile
|
||||
.create(pool)
|
||||
.create(executor)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
||||
pub async fn update_interactive(
|
||||
pub async fn update_interactive<'e, E>(
|
||||
&mut self,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
self.display_name = display_name;
|
||||
self.bio = bio;
|
||||
self.update(pool)
|
||||
self.update(executor)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
@ -50,8 +50,11 @@ impl From<&str> for UserDefault {
|
||||
}
|
||||
|
||||
impl User {
|
||||
async fn select_user(prompt: &str, pool: &sqlx::PgPool) -> Result<Self> {
|
||||
let users: HashMap<String, Self> = Self::find_all(pool)
|
||||
async fn select_user<'e, E>(prompt: &str, executor: E) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let users: HashMap<String, Self> = Self::find_all(executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|user| (user.username.clone(), user))
|
||||
@ -63,41 +66,50 @@ impl User {
|
||||
Ok(user.clone())
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id_or_select(
|
||||
pub async fn get_user_by_id_or_select<'e, E>(
|
||||
id: Option<i32>,
|
||||
prompt: &str,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let user = match id {
|
||||
Some(id) => Self::find(pool, &id)
|
||||
Some(id) => Self::find(executor, &id)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, pool).await?,
|
||||
None => Self::select_user(prompt, executor).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username_or_select(
|
||||
pub async fn get_user_by_username_or_select<'e, E>(
|
||||
username: Option<&str>,
|
||||
prompt: &str,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<Self> {
|
||||
executor: E,
|
||||
) -> Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
let user = match username {
|
||||
Some(username) => Self::find_by_username(username, pool)
|
||||
Some(username) => Self::find_by_username(username, executor)
|
||||
.await?
|
||||
.ok_or(UserInputError::UserDoesNotExist)?,
|
||||
None => Self::select_user(prompt, pool).await?,
|
||||
None => Self::select_user(prompt, executor).await?,
|
||||
};
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_username(username: &str, pool: &sqlx::PgPool) -> Result<Option<Self>> {
|
||||
pub async fn find_by_username<'e, E>(username: &str, executor: E) -> Result<Option<Self>>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT * FROM Users u WHERE u.username = $1",
|
||||
username
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(UserInputError::DatabaseError)
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ fn generate_struct(
|
||||
let fields: Vec<proc_macro2::TokenStream> = fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
if field.id {
|
||||
if field.is_id {
|
||||
Some(field_to_code(field))
|
||||
} else {
|
||||
None
|
||||
@ -56,7 +56,7 @@ pub fn create_primary_key(
|
||||
ast: &syn::DeriveInput,
|
||||
fields: &[GeormField],
|
||||
) -> (IdType, proc_macro2::TokenStream) {
|
||||
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.id).collect();
|
||||
let georm_id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect();
|
||||
let id_fields: Vec<IdField> = georm_id_fields
|
||||
.iter()
|
||||
.map(|field| IdField {
|
||||
|
@ -9,6 +9,8 @@
|
||||
//! or something similar. The type `<StructName>Default` implements the
|
||||
//! `Defaultable` trait.
|
||||
|
||||
use crate::georm::ir::GeneratedType;
|
||||
|
||||
use super::ir::{GeormField, GeormStructAttributes};
|
||||
use quote::quote;
|
||||
|
||||
@ -19,7 +21,7 @@ fn create_defaultable_field(field: &GeormField) -> proc_macro2::TokenStream {
|
||||
|
||||
// If the field is marked as defaultable, wrap it in Option<T>
|
||||
// Otherwise, keep the original type
|
||||
let field_type = if field.defaultable {
|
||||
let field_type = if field.is_defaultable_behavior() {
|
||||
quote! { Option<#ty> }
|
||||
} else {
|
||||
quote! { #ty }
|
||||
@ -41,13 +43,25 @@ fn generate_defaultable_trait_impl(
|
||||
// Find the ID field
|
||||
let id_field = fields
|
||||
.iter()
|
||||
.find(|field| field.id)
|
||||
.find(|field| field.is_id)
|
||||
.expect("Must have an ID field");
|
||||
let id_type = &id_field.ty;
|
||||
|
||||
// Remove always generated fields
|
||||
let fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
|
||||
.collect();
|
||||
|
||||
// Separate defaultable and non-defaultable fields
|
||||
let non_defaultable_fields: Vec<_> = fields.iter().filter(|f| !f.defaultable).collect();
|
||||
let defaultable_fields: Vec<_> = fields.iter().filter(|f| f.defaultable).collect();
|
||||
let non_defaultable_fields: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|f| !f.is_defaultable_behavior())
|
||||
.collect();
|
||||
let defaultable_fields: Vec<_> = fields
|
||||
.iter()
|
||||
.filter(|f| f.is_defaultable_behavior())
|
||||
.collect();
|
||||
|
||||
// Build static parts for non-defaultable fields
|
||||
let static_field_names: Vec<String> = non_defaultable_fields
|
||||
@ -80,7 +94,10 @@ fn generate_defaultable_trait_impl(
|
||||
|
||||
quote! {
|
||||
impl ::georm::Defaultable<#id_type, #struct_name> for #defaultable_struct_name {
|
||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#struct_name> {
|
||||
async fn create<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#struct_name>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
let mut dynamic_fields = Vec::new();
|
||||
|
||||
#(#field_checks)*
|
||||
@ -107,7 +124,7 @@ fn generate_defaultable_trait_impl(
|
||||
// Then bind defaultable fields that have values
|
||||
#(#bind_checks)*
|
||||
|
||||
query_builder.fetch_one(pool).await
|
||||
query_builder.fetch_one(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -119,7 +136,7 @@ pub fn derive_defaultable_struct(
|
||||
fields: &[GeormField],
|
||||
) -> proc_macro2::TokenStream {
|
||||
// Only generate if there are defaultable fields
|
||||
if fields.iter().all(|field| !field.defaultable) {
|
||||
if fields.iter().all(|field| !field.is_defaultable_behavior()) {
|
||||
return quote! {};
|
||||
}
|
||||
|
||||
@ -127,8 +144,13 @@ pub fn derive_defaultable_struct(
|
||||
let vis = &ast.vis;
|
||||
let defaultable_struct_name = quote::format_ident!("{}Default", struct_name);
|
||||
|
||||
let defaultable_fields: Vec<proc_macro2::TokenStream> =
|
||||
fields.iter().map(create_defaultable_field).collect();
|
||||
let defaultable_fields: Vec<proc_macro2::TokenStream> = fields
|
||||
.iter()
|
||||
.flat_map(|field| match field.generated_type {
|
||||
GeneratedType::Always => None,
|
||||
_ => Some(create_defaultable_field(field)),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let trait_impl = generate_defaultable_trait_impl(
|
||||
struct_name,
|
||||
|
@ -71,8 +71,11 @@ WHERE local.{} = $1",
|
||||
value.local.id
|
||||
);
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,10 @@ struct GeormFieldAttributes {
|
||||
pub relation: Option<O2ORelationship>,
|
||||
#[deluxe(default = false)]
|
||||
pub defaultable: bool,
|
||||
#[deluxe(default = false)]
|
||||
pub generated: bool,
|
||||
#[deluxe(default = false)]
|
||||
pub generated_always: bool,
|
||||
}
|
||||
|
||||
#[derive(deluxe::ParseMetaItem, Clone, Debug)]
|
||||
@ -40,14 +44,22 @@ pub struct O2ORelationship {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GeneratedType {
|
||||
None,
|
||||
ByDefault, // #[georm(generated)] - BY DEFAULT behaviour
|
||||
Always, // #[georm(generated_always)] - ALWAYS behaviour
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GeormField {
|
||||
pub ident: syn::Ident,
|
||||
pub field: syn::Field,
|
||||
pub ty: syn::Type,
|
||||
pub id: bool,
|
||||
pub is_id: bool,
|
||||
pub is_defaultable: bool,
|
||||
pub generated_type: GeneratedType,
|
||||
pub relation: Option<O2ORelationship>,
|
||||
pub defaultable: bool,
|
||||
}
|
||||
|
||||
impl GeormField {
|
||||
@ -60,24 +72,42 @@ impl GeormField {
|
||||
id,
|
||||
relation,
|
||||
defaultable,
|
||||
generated,
|
||||
generated_always,
|
||||
} = attrs;
|
||||
|
||||
// Validate that defaultable is not used on Option<T> fields
|
||||
if defaultable && Self::is_option_type(&ty) {
|
||||
panic!(
|
||||
"Field '{}' is already an Option<T> and cannot be marked as defaultable. \
|
||||
"Field '{}' is already an Option<T> and cannot be marked as defaultable.\
|
||||
Remove the #[georm(defaultable)] attribute.",
|
||||
ident
|
||||
);
|
||||
}
|
||||
|
||||
if generated && generated_always {
|
||||
panic!(
|
||||
"Field '{}' cannot have both the #[georm(generated)] and \
|
||||
#[georm(generated_always)] attributes at the same time. Remove one\
|
||||
of them before continuing.",
|
||||
ident
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
ident,
|
||||
field: field.to_owned(),
|
||||
id,
|
||||
is_id: id,
|
||||
ty,
|
||||
relation,
|
||||
defaultable,
|
||||
is_defaultable: defaultable,
|
||||
generated_type: if generated_always {
|
||||
GeneratedType::Always
|
||||
} else if generated {
|
||||
GeneratedType::ByDefault
|
||||
} else {
|
||||
GeneratedType::None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +124,26 @@ impl GeormField {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if field should be excluded from INSERT statements
|
||||
pub fn exclude_from_insert(&self) -> bool {
|
||||
matches!(self.generated_type, GeneratedType::Always)
|
||||
}
|
||||
|
||||
/// Check if field should be excluded from UPDATE statements
|
||||
pub fn exclude_from_update(&self) -> bool {
|
||||
matches!(self.generated_type, GeneratedType::Always)
|
||||
}
|
||||
|
||||
/// Check if field should behave like a defaultable field
|
||||
pub fn is_defaultable_behavior(&self) -> bool {
|
||||
self.is_defaultable || matches!(self.generated_type, GeneratedType::ByDefault)
|
||||
}
|
||||
|
||||
/// Check if field is any type of generated field
|
||||
pub fn is_any_generated(&self) -> bool {
|
||||
!matches!(self.generated_type, GeneratedType::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
@ -122,8 +172,11 @@ impl From<&GeormField> for proc_macro2::TokenStream {
|
||||
quote! { fetch_one }
|
||||
};
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<#return_type> {
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(pool).await
|
||||
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<#return_type>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.#local_ident).#fetch(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,8 +45,11 @@ impl From<&SimpleRelationship<OneToOne>> for proc_macro2::TokenStream {
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Option<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(pool).await
|
||||
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Option<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_optional(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -58,8 +61,11 @@ impl From<&SimpleRelationship<OneToMany>> for proc_macro2::TokenStream {
|
||||
let entity = &value.entity;
|
||||
let function = value.make_function_name();
|
||||
quote! {
|
||||
pub async fn #function(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<#entity>> {
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(pool).await
|
||||
pub async fn #function<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Vec<#entity>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(#entity, #query, self.get_id()).fetch_all(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ fn extract_georm_field_attrs(ast: &mut syn::DeriveInput) -> deluxe::Result<Vec<G
|
||||
let identifiers: Vec<GeormField> = fields
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|field| field.id)
|
||||
.filter(|field| field.is_id)
|
||||
.collect();
|
||||
if identifiers.is_empty() {
|
||||
Err(syn::Error::new_spanned(
|
||||
|
@ -1,26 +1,36 @@
|
||||
use crate::georm::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_create_query(table: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||
let create_string = format!(
|
||||
"INSERT INTO {table} ({}) VALUES ({}) RETURNING *",
|
||||
fields
|
||||
pub fn generate_create_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||
let insert_fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.map(|f| f.ident.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", "),
|
||||
inputs.join(", ")
|
||||
.filter(|field| !field.exclude_from_insert())
|
||||
.collect();
|
||||
let field_names: Vec<String> = insert_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.to_string())
|
||||
.collect();
|
||||
let field_idents: Vec<syn::Ident> = insert_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.clone())
|
||||
.collect();
|
||||
let placeholders: Vec<String> = (1..=insert_fields.len()).map(|i| format!("${i}")).collect();
|
||||
let query = format!(
|
||||
"INSERT INTO {table_name} ({}) VALUES ({}) RETURNING *",
|
||||
field_names.join(", "),
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||
quote! {
|
||||
async fn create(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||
async fn create<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#create_string,
|
||||
#query,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@ -24,16 +24,22 @@ pub fn generate_delete_query(table: &str, id: &IdType) -> proc_macro2::TokenStre
|
||||
};
|
||||
let delete_string = format!("DELETE FROM {table} WHERE {where_clause}");
|
||||
quote! {
|
||||
async fn delete_by_id(pool: &::sqlx::PgPool, id: &#id_type) -> ::sqlx::Result<u64> {
|
||||
async fn delete<'e, E>(&self, mut executor: E) -> ::sqlx::Result<u64>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
Self::delete_by_id(executor, &self.get_id()).await
|
||||
}
|
||||
|
||||
async fn delete_by_id<'e, E>(mut executor: E, id: &#id_type) -> ::sqlx::Result<u64>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
let rows_affected = ::sqlx::query!(#delete_string, #query_args)
|
||||
.execute(pool)
|
||||
.execute(executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows_affected)
|
||||
}
|
||||
|
||||
async fn delete(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<u64> {
|
||||
Self::delete_by_id(pool, &self.get_id()).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,11 @@ use quote::quote;
|
||||
pub fn generate_find_all_query(table: &str) -> proc_macro2::TokenStream {
|
||||
let find_string = format!("SELECT * FROM {table}");
|
||||
quote! {
|
||||
async fn find_all(pool: &::sqlx::PgPool) -> ::sqlx::Result<Vec<Self>> {
|
||||
::sqlx::query_as!(Self, #find_string).fetch_all(pool).await
|
||||
async fn find_all<'e, E>(mut executor: E) -> ::sqlx::Result<Vec<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string).fetch_all(executor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,9 +21,12 @@ pub fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream
|
||||
} => {
|
||||
let find_string = format!("SELECT * FROM {table} WHERE {} = $1", field_name);
|
||||
quote! {
|
||||
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||
async fn find<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string, id)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@ -36,9 +42,12 @@ pub fn generate_find_query(table: &str, id: &IdType) -> proc_macro2::TokenStream
|
||||
fields.iter().map(|field| field.name.clone()).collect();
|
||||
let find_string = format!("SELECT * FROM {table} WHERE {id_match_string}");
|
||||
quote! {
|
||||
async fn find(pool: &::sqlx::PgPool, id: &#field_type) -> ::sqlx::Result<Option<Self>> {
|
||||
async fn find<'e, E>(mut executor: E, id: &#field_type) -> ::sqlx::Result<Option<Self>>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(Self, #find_string, #(id.#id_members),*)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ pub fn derive_trait(
|
||||
let get_all = find::generate_find_all_query(table);
|
||||
let find_query = find::generate_find_query(table, id);
|
||||
let create_query = create::generate_create_query(table, fields);
|
||||
let update_query = update::generate_update_query(table, fields, id);
|
||||
let update_query = update::generate_update_query(table, fields);
|
||||
let upsert_query = upsert::generate_upsert_query(table, fields, id);
|
||||
let delete_query = delete::generate_delete_query(table, id);
|
||||
quote! {
|
||||
|
@ -1,47 +1,44 @@
|
||||
use crate::georm::{GeormField, IdType};
|
||||
use crate::georm::GeormField;
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_update_query(
|
||||
table: &str,
|
||||
fields: &[GeormField],
|
||||
id: &IdType,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let non_id_fields: Vec<syn::Ident> = fields
|
||||
pub fn generate_update_query(table_name: &str, fields: &[GeormField]) -> proc_macro2::TokenStream {
|
||||
let update_fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter_map(|f| if f.id { None } else { Some(f.ident.clone()) })
|
||||
.filter(|field| !field.is_id && !field.exclude_from_update())
|
||||
.collect();
|
||||
let update_columns = non_id_fields
|
||||
let update_idents: Vec<syn::Ident> = update_fields
|
||||
.iter()
|
||||
.map(|field| field.ident.clone())
|
||||
.collect();
|
||||
let id_fields: Vec<&GeormField> = fields.iter().filter(|field| field.is_id).collect();
|
||||
let id_idents: Vec<syn::Ident> = id_fields.iter().map(|f| f.ident.clone()).collect();
|
||||
let set_clauses: Vec<String> = update_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| format!("{} = ${}", field, i + 1))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
let mut all_fields = non_id_fields.clone();
|
||||
let where_clause = match id {
|
||||
IdType::Simple { field_name, .. } => {
|
||||
let where_clause = format!("{} = ${}", field_name, non_id_fields.len() + 1);
|
||||
all_fields.push(field_name.clone());
|
||||
where_clause
|
||||
}
|
||||
IdType::Composite { fields, .. } => fields
|
||||
.map(|(i, field)| format!("{} = ${}", field.ident, i + 1))
|
||||
.collect();
|
||||
let where_clauses: Vec<String> = id_fields
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, field)| {
|
||||
let where_clause = format!("{} = ${}", field.name, non_id_fields.len() + i + 1);
|
||||
all_fields.push(field.name.clone());
|
||||
where_clause
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" AND "),
|
||||
};
|
||||
let update_string =
|
||||
format!("UPDATE {table} SET {update_columns} WHERE {where_clause} RETURNING *");
|
||||
.map(|(i, field)| format!("{} = ${}", field.ident, update_fields.len() + i + 1))
|
||||
.collect();
|
||||
let query = format!(
|
||||
"UPDATE {table_name} SET {} WHERE {} RETURNING *",
|
||||
set_clauses.join(", "),
|
||||
where_clauses.join(" AND ")
|
||||
);
|
||||
quote! {
|
||||
async fn update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||
async fn update<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self, #update_string, #(self.#all_fields),*
|
||||
Self,
|
||||
#query,
|
||||
#(self.#update_idents),*,
|
||||
#(self.#id_idents),*
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::georm::{GeormField, IdType};
|
||||
use crate::georm::{GeormField, IdType, ir::GeneratedType};
|
||||
use quote::quote;
|
||||
|
||||
pub fn generate_upsert_query(
|
||||
@ -6,6 +6,10 @@ pub fn generate_upsert_query(
|
||||
fields: &[GeormField],
|
||||
id: &IdType,
|
||||
) -> proc_macro2::TokenStream {
|
||||
let fields: Vec<&GeormField> = fields
|
||||
.iter()
|
||||
.filter(|field| !matches!(field.generated_type, GeneratedType::Always))
|
||||
.collect();
|
||||
let inputs: Vec<String> = (1..=fields.len()).map(|num| format!("${num}")).collect();
|
||||
let columns = fields
|
||||
.iter()
|
||||
@ -26,7 +30,7 @@ pub fn generate_upsert_query(
|
||||
// For ON CONFLICT DO UPDATE, exclude the ID field from updates
|
||||
let update_assignments = fields
|
||||
.iter()
|
||||
.filter(|f| !f.id)
|
||||
.filter(|f| !f.is_id)
|
||||
.map(|f| format!("{} = EXCLUDED.{}", f.ident, f.ident))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
@ -40,13 +44,16 @@ pub fn generate_upsert_query(
|
||||
let field_idents: Vec<syn::Ident> = fields.iter().map(|f| f.ident.clone()).collect();
|
||||
|
||||
quote! {
|
||||
async fn create_or_update(&self, pool: &::sqlx::PgPool) -> ::sqlx::Result<Self> {
|
||||
async fn upsert<'e, E>(&self, mut executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: ::sqlx::Executor<'e, Database = ::sqlx::Postgres>
|
||||
{
|
||||
::sqlx::query_as!(
|
||||
Self,
|
||||
#upsert_string,
|
||||
#(self.#field_idents),*
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
1
migrations/20250807202945_generated-columns.down.sql
Normal file
1
migrations/20250807202945_generated-columns.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS products;
|
12
migrations/20250807202945_generated-columns.up.sql
Normal file
12
migrations/20250807202945_generated-columns.up.sql
Normal file
@ -0,0 +1,12 @@
|
||||
CREATE TABLE products (
|
||||
-- strictly autogenerated
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
-- auto-generated but allows manual override
|
||||
sku_number INTEGER GENERATED BY DEFAULT AS IDENTITY,
|
||||
|
||||
name VARCHAR(100) NOT NULL,
|
||||
price DECIMAL(10, 2) NOT NULL,
|
||||
discount_percent INTEGER DEFAULT 0 NOT NULL,
|
||||
|
||||
final_price DECIMAL(10, 2) GENERATED ALWAYS AS (price * (1 - discount_percent / 100.0)) STORED
|
||||
);
|
@ -1,3 +1,5 @@
|
||||
use sqlx::{Executor, Postgres};
|
||||
|
||||
/// Trait for creating entities with database defaults and auto-generated values.
|
||||
///
|
||||
/// This trait is automatically implemented on generated companion structs for entities
|
||||
@ -269,10 +271,11 @@ pub trait Defaultable<Id, Entity> {
|
||||
/// };
|
||||
/// let created = post_default.create(&pool).await?;
|
||||
/// ```
|
||||
fn create(
|
||||
fn create<'e, E>(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Entity>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
}
|
||||
|
@ -1,83 +0,0 @@
|
||||
pub trait Georm<Id> {
|
||||
/// Find all the entities in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Find the entiy in the database based on its identifier.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Create the entity in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Update an entity with a matching identifier in the database if
|
||||
/// it exists, create it otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn create_or_update(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Delete the entity from the database if it exists.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Delete any entity with the identifier `id`.
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns the amount of rows affected by the deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns any error Postgres may have encountered
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
|
||||
/// Returns the identifier of the entity.
|
||||
fn get_id(&self) -> &Id;
|
||||
}
|
68
src/georm.rs
68
src/georm.rs
@ -1,3 +1,5 @@
|
||||
use sqlx::{Executor, Postgres};
|
||||
|
||||
/// Core database operations trait for Georm entities.
|
||||
///
|
||||
/// This trait is automatically implemented by the `#[derive(Georm)]` macro and provides
|
||||
@ -24,7 +26,7 @@
|
||||
/// ### Instance Methods (Mutation Operations)
|
||||
/// - [`create`] - Insert a new entity into the database
|
||||
/// - [`update`] - Update an existing entity in the database
|
||||
/// - [`create_or_update`] - Upsert (insert or update) an entity
|
||||
/// - [`upsert`] - Upsert (insert or update) an entity
|
||||
/// - [`delete`] - Delete this entity from the database
|
||||
/// - [`get_id`] - Get the primary key of this entity
|
||||
///
|
||||
@ -90,7 +92,7 @@
|
||||
/// [`find`]: Georm::find
|
||||
/// [`create`]: Georm::create
|
||||
/// [`update`]: Georm::update
|
||||
/// [`create_or_update`]: Georm::create_or_update
|
||||
/// [`upsert`]: Georm::upsert
|
||||
/// [`delete`]: Georm::delete
|
||||
/// [`delete_by_id`]: Georm::delete_by_id
|
||||
/// [`get_id`]: Georm::get_id
|
||||
@ -118,11 +120,12 @@ pub trait Georm<Id> {
|
||||
/// # Errors
|
||||
/// Returns `sqlx::Error` for database connection issues, permission problems,
|
||||
/// or if the table doesn't exist.
|
||||
fn find_all(
|
||||
pool: &sqlx::PgPool,
|
||||
fn find_all<'e, E>(
|
||||
executor: E,
|
||||
) -> impl ::std::future::Future<Output = ::sqlx::Result<Vec<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Find a single entity by its primary key.
|
||||
///
|
||||
@ -152,12 +155,13 @@ pub trait Georm<Id> {
|
||||
/// Returns `sqlx::Error` for database connection issues, type conversion errors,
|
||||
/// or query execution problems. Note that not finding a record is not an error
|
||||
/// - it returns `Ok(None)`.
|
||||
fn find(
|
||||
pool: &sqlx::PgPool,
|
||||
fn find<'e, E>(
|
||||
executor: E,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Option<Self>>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Insert this entity as a new record in the database.
|
||||
///
|
||||
@ -191,12 +195,13 @@ pub trait Georm<Id> {
|
||||
/// - NOT NULL constraint violations
|
||||
/// - Database connection issues
|
||||
/// - Permission problems
|
||||
fn create(
|
||||
fn create<'e, E>(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Update an existing entity in the database.
|
||||
///
|
||||
@ -229,12 +234,13 @@ pub trait Georm<Id> {
|
||||
/// - Constraint violations (unique, foreign key, etc.)
|
||||
/// - Database connection issues
|
||||
/// - Permission problems
|
||||
fn update(
|
||||
fn update<'e, E>(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<Self>> + Send
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Insert or update this entity using PostgreSQL's upsert functionality.
|
||||
///
|
||||
@ -258,7 +264,7 @@ pub trait Georm<Id> {
|
||||
/// # Examples
|
||||
/// ```ignore
|
||||
/// let user = User { id: 1, username: "alice".into(), email: "alice@example.com".into() };
|
||||
/// let final_user = user.create_or_update(&pool).await?;
|
||||
/// let final_user = user.upsert(&pool).await?;
|
||||
/// // Will insert if ID 1 doesn't exist, update if it does
|
||||
/// ```
|
||||
///
|
||||
@ -267,12 +273,22 @@ pub trait Georm<Id> {
|
||||
/// - Non-primary-key constraint violations
|
||||
/// - Database connection issues
|
||||
/// - Permission problems
|
||||
fn create_or_update(
|
||||
fn upsert<'e, E>(&self, executor: E) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
#[deprecated(since = "0.3.0", note = "Please use `upsert` instead")]
|
||||
fn create_or_update<'e, E>(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
executor: E,
|
||||
) -> impl ::std::future::Future<Output = sqlx::Result<Self>>
|
||||
where
|
||||
Self: Sized;
|
||||
Self: Sized,
|
||||
E: Executor<'e, Database = Postgres>,
|
||||
{
|
||||
self.upsert(executor)
|
||||
}
|
||||
|
||||
/// Delete this entity from the database.
|
||||
///
|
||||
@ -303,10 +319,12 @@ pub trait Georm<Id> {
|
||||
/// - Foreign key constraint violations (referenced by other tables)
|
||||
/// - Database connection issues
|
||||
/// - Permission problems
|
||||
fn delete(
|
||||
fn delete<'e, E>(
|
||||
&self,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
executor: E,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
|
||||
where
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Delete an entity by its primary key without needing an entity instance.
|
||||
///
|
||||
@ -341,10 +359,12 @@ pub trait Georm<Id> {
|
||||
/// - Foreign key constraint violations (referenced by other tables)
|
||||
/// - Database connection issues
|
||||
/// - Permission problems
|
||||
fn delete_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
fn delete_by_id<'e, E>(
|
||||
executor: E,
|
||||
id: &Id,
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send;
|
||||
) -> impl std::future::Future<Output = sqlx::Result<u64>> + Send
|
||||
where
|
||||
E: Executor<'e, Database = Postgres>;
|
||||
|
||||
/// Get the primary key of this entity.
|
||||
///
|
||||
|
127
src/lib.rs
127
src/lib.rs
@ -33,7 +33,7 @@
|
||||
//! ### Instance Methods (called on entity objects)
|
||||
//! - `entity.create(pool)` - Insert new record, returns created entity with database-generated values
|
||||
//! - `entity.update(pool)` - Update existing record, returns updated entity with fresh database state
|
||||
//! - `entity.create_or_update(pool)` - True PostgreSQL upsert using `ON CONFLICT`, returns final entity
|
||||
//! - `entity.upsert(pool)` - True PostgreSQL upsert using `ON CONFLICT`, returns final entity
|
||||
//! - `entity.delete(pool)` - Delete this record, returns affected row count
|
||||
//! - `entity.get_id()` - Get reference to the entity's ID (`&Id` for simple keys, owned for composite)
|
||||
//!
|
||||
@ -55,7 +55,7 @@
|
||||
//! Georm leverages PostgreSQL-specific features for performance and reliability:
|
||||
//!
|
||||
//! - **RETURNING clause**: All `INSERT` and `UPDATE` operations use `RETURNING *` to capture database-generated values (sequences, defaults, triggers)
|
||||
//! - **True upserts**: `create_or_update()` uses `INSERT ... ON CONFLICT ... DO UPDATE` for atomic upsert operations
|
||||
//! - **True upserts**: `upsert()` uses `INSERT ... ON CONFLICT ... DO UPDATE` for atomic upsert operations
|
||||
//! - **Prepared statements**: All queries use parameter binding for security and performance
|
||||
//! - **Compile-time verification**: SQLx macros verify all generated SQL against your database schema at compile time
|
||||
//!
|
||||
@ -124,9 +124,13 @@
|
||||
//! yet define relationships (one-to-one, one-to-many, many-to-many)
|
||||
//! - **ID struct naming**: Generated ID struct follows pattern `{EntityName}Id` (not customizable)
|
||||
//!
|
||||
//! ## Defaultable Fields
|
||||
//! ## Defaultable and Generated Fields
|
||||
//!
|
||||
//! Use `#[georm(defaultable)]` for fields with database defaults or auto-generated values:
|
||||
//! Georm provides three attributes for handling fields with database-managed values:
|
||||
//!
|
||||
//! ### `#[georm(defaultable)]` - Optional Override Fields
|
||||
//!
|
||||
//! For fields with database defaults that can be manually overridden:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(Georm)]
|
||||
@ -145,21 +149,60 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! This generates a companion `PostDefault` struct where defaultable fields become `Option<T>`:
|
||||
//! ### `#[georm(generated)]` - Generated by Default
|
||||
//!
|
||||
//! For PostgreSQL `GENERATED BY DEFAULT` columns that can be overridden but are typically auto-generated:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Generated automatically by the macro
|
||||
//! pub struct PostDefault {
|
||||
//! pub id: Option<i32>, // Can be None for auto-generation
|
||||
//! pub title: String, // Required field stays the same
|
||||
//! pub published: Option<bool>, // Can be None to use database default
|
||||
//! pub created_at: Option<chrono::DateTime<chrono::Utc>>, // Can be None
|
||||
//! pub(crate) internal_note: Option<String>, // Visibility preserved
|
||||
//! pub author_id: i32, // Required field stays the same
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "products")]
|
||||
//! pub struct Product {
|
||||
//! #[georm(id, generated_always)]
|
||||
//! id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
//! #[georm(generated)]
|
||||
//! sku_number: i32, // GENERATED BY DEFAULT AS IDENTITY
|
||||
//! name: String,
|
||||
//! price: sqlx::types::BigDecimal,
|
||||
//! discount_percent: i32,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### `#[georm(generated_always)]` - Always Generated
|
||||
//!
|
||||
//! For PostgreSQL `GENERATED ALWAYS` columns that are strictly managed by the database:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "products")]
|
||||
//! pub struct Product {
|
||||
//! #[georm(id, generated_always)]
|
||||
//! id: i32, // GENERATED ALWAYS AS IDENTITY
|
||||
//! name: String,
|
||||
//! price: sqlx::types::BigDecimal,
|
||||
//! discount_percent: i32,
|
||||
//! #[georm(generated_always)]
|
||||
//! final_price: Option<sqlx::types::BigDecimal>, // GENERATED ALWAYS AS (expression) STORED
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Generated Structs and Behavior
|
||||
//!
|
||||
//! Both `defaultable` and `generated` fields create a companion `<Entity>Default` struct where these fields become `Option<T>`:
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Generated automatically by the macro for the Product example above
|
||||
//! pub struct ProductDefault {
|
||||
//! pub name: String, // Required field stays the same
|
||||
//! pub price: sqlx::types::BigDecimal, // Required field stays the same
|
||||
//! pub discount_percent: i32, // Required field stays the same
|
||||
//! pub sku_number: Option<i32>, // Can be None for auto-generation
|
||||
//! // Note: generated_always fields are completely excluded from this struct
|
||||
//! }
|
||||
//!
|
||||
//! impl Defaultable<i32, Post> for PostDefault {
|
||||
//! async fn create(&self, pool: &sqlx::PgPool) -> sqlx::Result<Post>;
|
||||
//! impl Defaultable<i32, Product> for ProductDefault {
|
||||
//! async fn create<'e, E>(&self, pool: E) -> sqlx::Result<Post>
|
||||
//! where
|
||||
//! E: sqlx::Executor<'e, Database = sqlx::Postgres>;
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
@ -168,32 +211,43 @@
|
||||
//! ```ignore
|
||||
//! use georm::{Georm, Defaultable};
|
||||
//!
|
||||
//! // Create a post with some fields using database defaults
|
||||
//! let post_default = PostDefault {
|
||||
//! id: None, // Let database auto-generate
|
||||
//! title: "My Blog Post".to_string(),
|
||||
//! published: None, // Use database default (e.g., false)
|
||||
//! created_at: None, // Use database default (e.g., NOW())
|
||||
//! internal_note: Some("Draft".to_string()),
|
||||
//! author_id: 42,
|
||||
//! // Create a product with auto-generated values
|
||||
//! let product_default = ProductDefault {
|
||||
//! name: "Laptop".to_string(),
|
||||
//! price: sqlx::types::BigDecimal::from(999),
|
||||
//! discount_percent: 10,
|
||||
//! sku_number: None, // Let database auto-generate
|
||||
//! // Note: id and final_price are excluded (generated_always)
|
||||
//! };
|
||||
//!
|
||||
//! // Create the entity in the database (instance method on PostDefault)
|
||||
//! let created_post = post_default.create(&pool).await?;
|
||||
//! println!("Created post with ID: {}", created_post.id);
|
||||
//! // Create the entity in the database (instance method on ProductDefault)
|
||||
//! let created_product = product_default.create(&pool).await?;
|
||||
//! println!("Created product with ID: {}", created_product.id);
|
||||
//! println!("Final price: ${}", created_product.final_price.unwrap_or_default());
|
||||
//! ```
|
||||
//!
|
||||
//! ### Defaultable Rules and Limitations
|
||||
//! ### Key Differences
|
||||
//!
|
||||
//! - **Option fields cannot be marked as defaultable**: If a field is already
|
||||
//! | Attribute | INSERT Behavior | UPDATE Behavior | Use Case |
|
||||
//! |--------------------|------------------------------------|-----------------|----------------------------------------------|
|
||||
//! | `defaultable` | Optional (can override defaults) | Included | Fields with database defaults |
|
||||
//! | `generated` | Optional (can override generation) | Included | `GENERATED BY DEFAULT` columns |
|
||||
//! | `generated_always` | **Excluded** (always generated) | **Excluded** | `GENERATED ALWAYS` columns, computed columns |
|
||||
//!
|
||||
//! ### Rules and Limitations
|
||||
//!
|
||||
//! - **`generated_always` fields are completely excluded** from INSERT and UPDATE statements to prevent database errors
|
||||
//! - **`generated` and `generated_always` cannot be used together** on the same field - this causes a compile-time error
|
||||
//! - **`generated` fields behave like `defaultable` fields** but are semantically distinct for future enhancements
|
||||
//! - **Option fields cannot be marked as `defaultable`**: If a field is already
|
||||
//! `Option<T>`, you cannot mark it with `#[georm(defaultable)]`. This prevents
|
||||
//! `Option<Option<T>>` types and causes a compile-time error.
|
||||
//! - **Field visibility is preserved**: The generated defaultable struct maintains
|
||||
//! the same field visibility (`pub`, `pub(crate)`, private) as the original struct.
|
||||
//! - **ID fields can be defaultable**: It's common to mark ID fields as defaultable
|
||||
//! when they are auto-generated serials in PostgreSQL.
|
||||
//! - **ID fields can be defaultable or generated**: It's common to mark ID fields as defaultable
|
||||
//! or generated when they are auto-generated serials in PostgreSQL.
|
||||
//! - **Only generates when needed**: The defaultable struct is only generated if
|
||||
//! at least one field is marked as defaultable.
|
||||
//! at least one field is marked as defaultable or generated.
|
||||
//!
|
||||
//! ## Relationships
|
||||
//!
|
||||
@ -518,6 +572,15 @@
|
||||
//! #[georm(defaultable)] // Error: would create Option<Option<String>>
|
||||
//! optional_field: Option<String>,
|
||||
//! }
|
||||
//!
|
||||
//! // ❌ Compile error: Cannot use both generated attributes on same field
|
||||
//! #[derive(Georm)]
|
||||
//! #[georm(table = "invalid")]
|
||||
//! pub struct Invalid {
|
||||
//! #[georm(id, generated, generated_always)] // Error: conflicting attributes
|
||||
//! id: i32,
|
||||
//! name: String,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Attribute Reference
|
||||
@ -538,6 +601,8 @@
|
||||
//! ```ignore
|
||||
//! #[georm(id)] // Mark as primary key (required on at least one field)
|
||||
//! #[georm(defaultable)] // Mark as defaultable field (database default/auto-generated)
|
||||
//! #[georm(generated)] // Mark as generated by default field (GENERATED BY DEFAULT)
|
||||
//! #[georm(generated_always)] // Mark as always generated field (GENERATED ALWAYS)
|
||||
//! #[georm(relation = { /* ... */ })] // Define foreign key relationship
|
||||
//! ```
|
||||
//!
|
||||
|
@ -36,7 +36,7 @@ fn composite_key_get_id() {
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("composite_key"))]
|
||||
async fn composite_key_create_or_update(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
async fn composite_key_upsert(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let new_user_role = UserRole {
|
||||
user_id: 5,
|
||||
role_id: 2,
|
||||
@ -44,7 +44,7 @@ async fn composite_key_create_or_update(pool: sqlx::PgPool) -> sqlx::Result<()>
|
||||
};
|
||||
|
||||
// This will test the upsert query generation bug
|
||||
let result = new_user_role.create_or_update(&pool).await?;
|
||||
let result = new_user_role.upsert(&pool).await?;
|
||||
assert_eq!(5, result.user_id);
|
||||
assert_eq!(2, result.role_id);
|
||||
|
||||
|
8
tests/fixtures/generated.sql
vendored
Normal file
8
tests/fixtures/generated.sql
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
INSERT INTO products (name, price, discount_percent)
|
||||
VALUES ('Laptop', 999.99, 10);
|
||||
|
||||
INSERT INTO products (sku_number, name, price, discount_percent)
|
||||
VALUES (5000, 'Mouse', 29.99, 5);
|
||||
|
||||
INSERT INTO products (name, price)
|
||||
VALUES ('Keyboard', 79.99);
|
64
tests/generated.rs
Normal file
64
tests/generated.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use georm::{Defaultable, Georm};
|
||||
use sqlx::types::BigDecimal;
|
||||
|
||||
mod models;
|
||||
use models::{Product, ProductDefault};
|
||||
|
||||
#[sqlx::test()]
|
||||
async fn create_without_generated_values(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let base = ProductDefault {
|
||||
name: "Desktop".to_owned(),
|
||||
price: BigDecimal::from(2000),
|
||||
discount_percent: 5,
|
||||
sku_number: None,
|
||||
};
|
||||
let result = base.create(&pool).await?;
|
||||
assert_eq!(BigDecimal::from(1900), result.final_price.unwrap());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test()]
|
||||
async fn create_with_manual_generated_value(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let base = ProductDefault {
|
||||
name: "Monitor".to_owned(),
|
||||
price: BigDecimal::from(750),
|
||||
discount_percent: 10,
|
||||
sku_number: Some(12345),
|
||||
};
|
||||
let result = base.create(&pool).await?;
|
||||
assert_eq!(12345, result.sku_number);
|
||||
assert_eq!("Monitor", result.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("generated"))]
|
||||
async fn update_does_not_change_generated_always_field(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let products = Product::find_all(&pool).await?;
|
||||
dbg!(&products);
|
||||
let mut product = products.first().unwrap().clone();
|
||||
let original_final_price = product.clone().final_price;
|
||||
product.name = "Gaming Laptop".to_owned();
|
||||
product.final_price = Some(BigDecimal::from(1000000));
|
||||
dbg!(&product);
|
||||
let updated = product.update(&pool).await?;
|
||||
assert_eq!(original_final_price, updated.final_price);
|
||||
assert_eq!("Gaming Laptop", updated.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("generated"))]
|
||||
async fn upsert_handles_generated_fields(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let product = Product::find_by_name("Laptop".to_owned(), &pool).await?;
|
||||
let mut modified_product = product.clone();
|
||||
modified_product.price = BigDecimal::from(1200);
|
||||
|
||||
let upserted = modified_product.upsert(&pool).await?;
|
||||
|
||||
// price is updated
|
||||
assert_eq!(upserted.price, BigDecimal::from(1200));
|
||||
// final_price is re-calculated by the DB
|
||||
// 1200 * (1 - 10 / 100.0) = 1080
|
||||
assert_eq!(BigDecimal::from(1080), upserted.final_price.unwrap());
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
use georm::Georm;
|
||||
use sqlx::{Postgres, types::BigDecimal};
|
||||
|
||||
#[derive(Debug, Georm, PartialEq, Eq, Default)]
|
||||
#[georm(
|
||||
@ -105,3 +106,29 @@ pub struct UserRole {
|
||||
#[georm(defaultable)]
|
||||
pub assigned_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Georm, PartialEq, Default, Clone)]
|
||||
#[georm(table = "products")]
|
||||
pub struct Product {
|
||||
#[georm(id, generated_always)]
|
||||
pub id: i32,
|
||||
#[georm(generated)]
|
||||
pub sku_number: i32,
|
||||
pub name: String,
|
||||
pub price: BigDecimal,
|
||||
pub discount_percent: i32,
|
||||
#[georm(generated_always)]
|
||||
pub final_price: Option<BigDecimal>, // Apparently this can be null ?
|
||||
}
|
||||
|
||||
impl Product {
|
||||
#[allow(dead_code)]
|
||||
pub async fn find_by_name<'e, E>(name: String, executor: E) -> ::sqlx::Result<Self>
|
||||
where
|
||||
E: sqlx::Executor<'e, Database = Postgres>,
|
||||
{
|
||||
::sqlx::query_as!(Self, "SELECT * FROM products WHERE name = $1", name)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ async fn should_create_if_does_not_exist(pool: sqlx::PgPool) -> sqlx::Result<()>
|
||||
name: "Miura Kentaro".into(),
|
||||
..Default::default()
|
||||
};
|
||||
author.create_or_update(&pool).await?;
|
||||
author.upsert(&pool).await?;
|
||||
let all_authors = Author::find_all(&pool).await?;
|
||||
assert_eq!(1, all_authors.len());
|
||||
Ok(())
|
||||
@ -130,7 +130,7 @@ async fn should_update_if_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
name: "Miura Kentaro".into(),
|
||||
..Default::default()
|
||||
};
|
||||
author.create_or_update(&pool).await?;
|
||||
author.upsert(&pool).await?;
|
||||
let mut all_authors = Author::find_all(&pool).await?;
|
||||
all_authors.sort();
|
||||
assert_eq!(3, all_authors.len());
|
||||
|
Loading…
x
Reference in New Issue
Block a user