Update docs, MCP server, and skills for v0.3.0 features

- Add skill docs for batch ops, pagination, soft delete, transactions
- Update sqlx-entity.md with new attributes and methods
- Update sqlx-record.md with quick reference for all features
- Update MCP server with new feature documentation resources
- Fix .gitignore paths for renamed directories

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Netshipise 2026-01-28 16:44:57 +02:00
parent f785bb1bf6
commit 44ac78d67e
8 changed files with 1206 additions and 4 deletions

View File

@ -0,0 +1,196 @@
# sqlx-record Batch Operations Skill
Guide to insert_many() and upsert() for efficient bulk operations.
## Triggers
- "batch insert", "bulk insert"
- "insert many", "insert_many"
- "upsert", "insert or update"
- "on conflict", "on duplicate key"
## Overview
`sqlx-record` provides efficient batch operations:
- `insert_many()` - Insert multiple records in a single query
- `upsert()` - Insert or update on primary key conflict
## insert_many()
Insert multiple entities in a single SQL statement:
```rust
pub async fn insert_many(executor, entities: &[Self]) -> Result<Vec<PkType>, Error>
```
### Usage
```rust
use sqlx_record::prelude::*;
let users = vec![
User { id: new_uuid(), name: "Alice".into(), email: "alice@example.com".into() },
User { id: new_uuid(), name: "Bob".into(), email: "bob@example.com".into() },
User { id: new_uuid(), name: "Carol".into(), email: "carol@example.com".into() },
];
// Insert all in single query
let ids = User::insert_many(&pool, &users).await?;
println!("Inserted {} users", ids.len());
```
### SQL Generated
```sql
-- MySQL
INSERT INTO users (id, name, email) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)
-- PostgreSQL
INSERT INTO users (id, name, email) VALUES ($1, $2, $3), ($4, $5, $6), ($7, $8, $9)
-- SQLite
INSERT INTO users (id, name, email) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?)
```
### Benefits
- Single round-trip to database
- Much faster than N individual inserts
- Atomic - all succeed or all fail
### Limitations
- Entity must implement `Clone` (for collecting PKs)
- Empty slice returns empty vec without database call
- Very large batches may hit database limits (split into chunks if needed)
### Chunked Insert
For very large datasets:
```rust
const BATCH_SIZE: usize = 1000;
async fn insert_large_dataset(pool: &Pool, users: Vec<User>) -> Result<Vec<Uuid>, sqlx::Error> {
let mut all_ids = Vec::with_capacity(users.len());
for chunk in users.chunks(BATCH_SIZE) {
let ids = User::insert_many(pool, chunk).await?;
all_ids.extend(ids);
}
Ok(all_ids)
}
```
## upsert() / insert_or_update()
Insert a new record, or update if primary key already exists:
```rust
pub async fn upsert(&self, executor) -> Result<PkType, Error>
pub async fn insert_or_update(&self, executor) -> Result<PkType, Error> // alias
```
### Usage
```rust
let user = User {
id: existing_or_new_id,
name: "Alice".into(),
email: "alice@example.com".into(),
};
// Insert if new, update if exists
user.upsert(&pool).await?;
// Or using alias
user.insert_or_update(&pool).await?;
```
### SQL Generated
```sql
-- MySQL
INSERT INTO users (id, name, email) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email)
-- PostgreSQL
INSERT INTO users (id, name, email) VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email
-- SQLite
INSERT INTO users (id, name, email) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, email = excluded.email
```
### Use Cases
1. **Sync external data**: Import data that may already exist
2. **Idempotent operations**: Safe to retry without duplicates
3. **Cache refresh**: Update cached records atomically
### Examples
#### Sync Products
```rust
async fn sync_products(pool: &Pool, external_products: Vec<ExternalProduct>) -> Result<(), sqlx::Error> {
for ext in external_products {
let product = Product {
id: ext.id, // Use external ID as PK
name: ext.name,
price: ext.price,
updated_at: chrono::Utc::now().timestamp_millis(),
};
product.upsert(pool).await?;
}
Ok(())
}
```
#### Idempotent Event Processing
```rust
async fn process_event(pool: &Pool, event: Event) -> Result<(), sqlx::Error> {
let record = ProcessedEvent {
id: event.id, // Event ID as PK - prevents duplicates
event_type: event.event_type,
payload: event.payload,
processed_at: chrono::Utc::now().timestamp_millis(),
};
// Safe to call multiple times - won't create duplicates
record.upsert(pool).await?;
Ok(())
}
```
#### With Transaction
```rust
use sqlx_record::transaction;
transaction!(&pool, |tx| {
// Upsert multiple records atomically
for item in items {
item.upsert(&mut *tx).await?;
}
Ok::<_, sqlx::Error>(())
}).await?;
```
## Comparison
| Operation | Behavior on Existing PK | SQL Efficiency |
|-----------|------------------------|----------------|
| `insert()` | Error (duplicate key) | Single row |
| `insert_many()` | Error (duplicate key) | Multiple rows, single query |
| `upsert()` | Updates all non-PK fields | Single row |
## Notes
- `upsert()` updates ALL non-PK fields, not just changed ones
- Primary key must be properly indexed (usually automatic)
- For partial updates, use `insert()` + `update_by_id()` with conflict check
- `insert_many()` requires all entities have unique PKs among themselves

View File

@ -56,11 +56,45 @@ large_count: i64,
- SQLx type hint for compile-time validation - SQLx type hint for compile-time validation
- Adds type annotation in SELECT: `field as "field: TYPE"` - Adds type annotation in SELECT: `field as "field: TYPE"`
### #[soft_delete]
```rust
#[soft_delete]
is_deleted: bool,
```
- Enables soft delete functionality
- Generates `delete()`, `restore()`, `hard_delete()` methods
- Field must be `bool` type
### #[created_at]
```rust
#[created_at]
created_at: i64,
```
- Auto-set to current timestamp (milliseconds) on insert
- Field must be `i64` type
- Excluded from UpdateForm
### #[updated_at]
```rust
#[updated_at]
updated_at: i64,
```
- Auto-set to current timestamp (milliseconds) on every update
- Field must be `i64` type
- Excluded from UpdateForm
## Generated Methods ## Generated Methods
### Insert ### Insert
```rust ```rust
pub async fn insert<E>(&self, executor: E) -> Result<PkType, sqlx::Error> pub async fn insert<E>(&self, executor: E) -> Result<PkType, sqlx::Error>
// Batch insert
pub async fn insert_many(executor, entities: &[Self]) -> Result<Vec<PkType>, Error>
// Insert or update on conflict
pub async fn upsert(&self, executor) -> Result<PkType, Error>
pub async fn insert_or_update(&self, executor) -> Result<PkType, Error> // alias
``` ```
### Get Methods ### Get Methods
@ -102,6 +136,23 @@ pub async fn find_ordered_with_limit(
// Count matching // Count matching
pub async fn count(executor, filters: Vec<Filter>, index: Option<&str>) -> Result<u64, Error> pub async fn count(executor, filters: Vec<Filter>, index: Option<&str>) -> Result<u64, Error>
// Paginated results
pub async fn paginate(
executor,
filters: Vec<Filter>,
index: Option<&str>,
order_by: Vec<(&str, bool)>,
page_request: PageRequest
) -> Result<Page<Self>, Error>
// Select specific columns only
pub async fn find_partial(
executor,
select_fields: &[&str],
filters: Vec<Filter>,
index: Option<&str>
) -> Result<Vec<Row>, Error>
``` ```
### Update Methods ### Update Methods
@ -143,6 +194,24 @@ pub async fn get_version(executor, pk: &PkType) -> Result<Option<VersionType>, E
pub async fn get_versions(executor, pks: &[PkType]) -> Result<HashMap<PkType, VersionType>, Error> pub async fn get_versions(executor, pks: &[PkType]) -> Result<HashMap<PkType, VersionType>, Error>
``` ```
### Soft Delete Methods (if #[soft_delete] exists)
```rust
// Soft delete - sets field to true
pub async fn delete(&self, executor) -> Result<(), Error>
pub async fn delete_by_id(executor, id: &Uuid) -> Result<(), Error>
// Hard delete - permanently removes row
pub async fn hard_delete(&self, executor) -> Result<(), Error>
pub async fn hard_delete_by_id(executor, id: &Uuid) -> Result<(), Error>
// Restore - sets field to false
pub async fn restore(&self, executor) -> Result<(), Error>
pub async fn restore_by_id(executor, id: &Uuid) -> Result<(), Error>
// Get field name
pub const fn soft_delete_field() -> &'static str
```
### Metadata Methods ### Metadata Methods
```rust ```rust
pub const fn table_name() -> &'static str pub const fn table_name() -> &'static str

View File

@ -0,0 +1,164 @@
# sqlx-record Pagination Skill
Guide to pagination with Page<T> and PageRequest.
## Triggers
- "pagination", "paginate"
- "page request", "page size"
- "total pages", "has next"
## Overview
`sqlx-record` provides built-in pagination support with the `Page<T>` container and `PageRequest` options.
## PageRequest
Create pagination options with 1-indexed page numbers:
```rust
use sqlx_record::prelude::PageRequest;
// Create request for page 1 with 20 items per page
let request = PageRequest::new(1, 20);
// First page shorthand
let request = PageRequest::first(20);
// Access offset/limit for manual queries
request.offset() // 0 for page 1, 20 for page 2, etc.
request.limit() // page_size
```
## Page<T>
Paginated results container:
```rust
use sqlx_record::prelude::Page;
// Properties
page.items // Vec<T> - items for this page
page.total_count // u64 - total records matching filters
page.page // u32 - current page (1-indexed)
page.page_size // u32 - items per page
// Computed methods
page.total_pages() // u32 - ceil(total_count / page_size)
page.has_next() // bool - page < total_pages
page.has_prev() // bool - page > 1
page.is_empty() // bool - items.is_empty()
page.len() // usize - items.len()
// Transformation
page.map(|item| transform(item)) // Page<U>
page.into_items() // Vec<T>
page.iter() // impl Iterator<Item = &T>
```
## Entity Paginate Method
Generated on all entities:
```rust
pub async fn paginate(
executor,
filters: Vec<Filter>,
index: Option<&str>, // MySQL index hint
order_by: Vec<(&str, bool)>, // (field, is_ascending)
page_request: PageRequest
) -> Result<Page<Self>, Error>
```
## Usage Examples
### Basic Pagination
```rust
use sqlx_record::prelude::*;
// Get first page of 20 users
let page = User::paginate(
&pool,
filters![],
None,
vec![("created_at", false)], // ORDER BY created_at DESC
PageRequest::new(1, 20)
).await?;
println!("Page {} of {}", page.page, page.total_pages());
println!("Showing {} of {} users", page.len(), page.total_count);
for user in page.iter() {
println!("{}: {}", user.id, user.name);
}
```
### With Filters
```rust
// Active users only, page 3
let page = User::paginate(
&pool,
filters![("is_active", true)],
None,
vec![("name", true)], // ORDER BY name ASC
PageRequest::new(3, 10)
).await?;
```
### With Index Hint (MySQL)
```rust
// Use specific index for performance
let page = User::paginate(
&pool,
filters![("status", "active")],
Some("idx_users_status"), // MySQL: USE INDEX(idx_users_status)
vec![("created_at", false)],
PageRequest::new(1, 50)
).await?;
```
### Navigation Logic
```rust
let page = User::paginate(&pool, filters![], None, vec![], PageRequest::new(current, 20)).await?;
if page.has_prev() {
println!("Previous: page {}", page.page - 1);
}
if page.has_next() {
println!("Next: page {}", page.page + 1);
}
```
### Transform Results
```rust
// Convert to DTOs
let dto_page: Page<UserDto> = page.map(|user| UserDto::from(user));
// Or consume items
let items: Vec<User> = page.into_items();
```
## Comparison with Manual Pagination
```rust
// Manual approach (still available)
let offset = (page_num - 1) * page_size;
let items = User::find_ordered_with_limit(
&pool, filters, None, order_by, Some((offset, page_size))
).await?;
let total = User::count(&pool, filters.clone(), None).await?;
// With paginate() - simpler
let page = User::paginate(&pool, filters, None, order_by, PageRequest::new(page_num, page_size)).await?;
```
## Notes
- Page numbers are 1-indexed (page 1 is first page)
- `paginate()` executes two queries: count + select
- For very large tables, consider cursor-based pagination instead
- Index hints only work on MySQL, ignored on Postgres/SQLite

View File

@ -117,11 +117,68 @@ let id = new_uuid(); // Timestamp prefix for better indexing
```toml ```toml
[dependencies] [dependencies]
sqlx-record = { version = "0.2", features = ["mysql", "derive"] } sqlx-record = { version = "0.3", features = ["mysql", "derive"] }
# Database: "mysql", "postgres", or "sqlite" (pick one) # Database: "mysql", "postgres", or "sqlite" (pick one)
# Optional: "derive", "static-validation" # Optional: "derive", "static-validation"
``` ```
## Soft Delete, Timestamps, Batch Operations
```rust
#[derive(Entity, FromRow)]
struct User {
#[primary_key] id: Uuid,
name: String,
#[soft_delete] // Enables delete/restore/hard_delete
is_deleted: bool,
#[created_at] // Auto-set on insert
created_at: i64,
#[updated_at] // Auto-set on update
updated_at: i64,
}
// Soft delete
user.delete(&pool).await?; // is_deleted = true
user.restore(&pool).await?; // is_deleted = false
user.hard_delete(&pool).await?; // DELETE FROM
// Batch insert
User::insert_many(&pool, &users).await?;
// Upsert (insert or update on conflict)
user.upsert(&pool).await?;
```
## Pagination
```rust
use sqlx_record::prelude::{Page, PageRequest};
let page = User::paginate(&pool, filters![], None,
vec![("name", true)], PageRequest::new(1, 20)).await?;
page.items // Vec<User>
page.total_count // Total matching records
page.total_pages() // Calculated pages
page.has_next() // bool
page.has_prev() // bool
```
## Transaction Helper
```rust
use sqlx_record::transaction;
transaction!(&pool, |tx| {
user.insert(&mut *tx).await?;
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(())
}).await?;
```
## Advanced Updates (UpdateExpr) ## Advanced Updates (UpdateExpr)
```rust ```rust

View File

@ -0,0 +1,241 @@
# sqlx-record Soft Delete Skill
Guide to soft delete functionality with #[soft_delete] attribute.
## Triggers
- "soft delete", "soft-delete"
- "is_deleted", "deleted"
- "restore", "undelete"
- "hard delete", "permanent delete"
## Overview
Soft delete allows marking records as deleted without removing them from the database. This enables:
- Recovery of accidentally deleted data
- Audit trails of deletions
- Referential integrity preservation
## Enabling Soft Delete
Add `#[soft_delete]` to a boolean field:
```rust
use sqlx_record::prelude::*;
#[derive(Entity, FromRow)]
#[table_name = "users"]
struct User {
#[primary_key]
id: Uuid,
name: String,
#[soft_delete]
is_deleted: bool, // Must be bool type
}
```
Auto-detection: Fields named `is_deleted` or `deleted` with `bool` type are automatically treated as soft delete fields even without the attribute.
## Generated Methods
### delete() / delete_by_{pk}()
Sets the soft delete field to `true`:
```rust
// Instance method
user.delete(&pool).await?;
// Static method by primary key
User::delete_by_id(&pool, &user_id).await?;
```
**SQL generated:**
```sql
UPDATE users SET is_deleted = TRUE WHERE id = ?
```
### hard_delete() / hard_delete_by_{pk}()
Permanently removes the row:
```rust
// Instance method
user.hard_delete(&pool).await?;
// Static method by primary key
User::hard_delete_by_id(&pool, &user_id).await?;
```
**SQL generated:**
```sql
DELETE FROM users WHERE id = ?
```
### restore() / restore_by_{pk}()
Sets the soft delete field to `false`:
```rust
// Instance method
user.restore(&pool).await?;
// Static method by primary key
User::restore_by_id(&pool, &user_id).await?;
```
**SQL generated:**
```sql
UPDATE users SET is_deleted = FALSE WHERE id = ?
```
### soft_delete_field()
Returns the field name:
```rust
let field = User::soft_delete_field(); // "is_deleted"
```
## Filtering Deleted Records
Soft delete does **NOT** automatically filter `find()` queries. You must add the filter manually:
```rust
// Include only non-deleted
let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Include only deleted (trash view)
let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?;
// Include all records
let all = User::find(&pool, filters![], None).await?;
```
### Helper Pattern
Create a helper function for consistent filtering:
```rust
impl User {
pub async fn find_active(
pool: &Pool,
mut filters: Vec<Filter<'_>>,
index: Option<&str>
) -> Result<Vec<Self>, sqlx::Error> {
filters.push(Filter::Equal("is_deleted", false.into()));
Self::find(pool, filters, index).await
}
}
// Usage
let users = User::find_active(&pool, filters![("role", "admin")], None).await?;
```
## Usage Examples
### Basic Soft Delete Flow
```rust
// Create user
let user = User {
id: new_uuid(),
name: "Alice".into(),
is_deleted: false,
};
user.insert(&pool).await?;
// Soft delete
user.delete(&pool).await?;
// user still exists in DB with is_deleted = true
// Find won't return deleted users (with proper filter)
let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Alice not in results
// Restore
User::restore_by_id(&pool, &user.id).await?;
// user.is_deleted = false again
// Hard delete (permanent)
User::hard_delete_by_id(&pool, &user.id).await?;
// Row completely removed from database
```
### With Audit Trail
```rust
use sqlx_record::{transaction, prelude::*};
async fn soft_delete_with_audit(
pool: &Pool,
user_id: &Uuid,
actor_id: &Uuid
) -> Result<(), sqlx::Error> {
transaction!(&pool, |tx| {
// Soft delete the user
User::delete_by_id(&mut *tx, user_id).await?;
// Record the deletion
let change = EntityChange {
id: new_uuid(),
entity_id: *user_id,
action: "delete".into(),
changed_at: chrono::Utc::now().timestamp_millis(),
actor_id: *actor_id,
session_id: Uuid::nil(),
change_set_id: Uuid::nil(),
new_value: None,
};
create_entity_change(&mut *tx, "entity_changes_users", &change).await?;
Ok::<_, sqlx::Error>(())
}).await
}
```
### Cascade Soft Delete
```rust
async fn delete_user_cascade(pool: &Pool, user_id: &Uuid) -> Result<(), sqlx::Error> {
transaction!(&pool, |tx| {
// Soft delete user's orders
let orders = Order::find(&mut *tx, filters![("user_id", user_id)], None).await?;
for order in orders {
order.delete(&mut *tx).await?;
}
// Soft delete user
User::delete_by_id(&mut *tx, user_id).await?;
Ok::<_, sqlx::Error>(())
}).await
}
```
## Database Schema
Recommended column definition:
```sql
-- MySQL
is_deleted BOOLEAN NOT NULL DEFAULT FALSE
-- PostgreSQL
is_deleted BOOLEAN NOT NULL DEFAULT FALSE
-- SQLite
is_deleted INTEGER NOT NULL DEFAULT 0 -- 0=false, 1=true
```
Add an index for efficient filtering:
```sql
CREATE INDEX idx_users_is_deleted ON users (is_deleted);
-- Or composite index for common queries
CREATE INDEX idx_users_active_name ON users (is_deleted, name);
```
## Notes
- Soft delete field must be `bool` type
- The field is included in UpdateForm (can be manually toggled)
- Consider adding `deleted_at: Option<i64>` for deletion timestamps
- For complex filtering, consider database views

View File

@ -0,0 +1,209 @@
# sqlx-record Transaction Skill
Guide to the transaction! macro for ergonomic transactions.
## Triggers
- "transaction", "transactions"
- "commit", "rollback"
- "atomic", "transactional"
## Overview
The `transaction!` macro provides ergonomic transaction handling with automatic commit on success and rollback on error.
## Basic Syntax
```rust
use sqlx_record::transaction;
let result = transaction!(&pool, |tx| {
// Operations using &mut *tx as executor
user.insert(&mut *tx).await?;
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(order.id) // Return value type annotation
}).await?;
```
## Key Points
1. **Automatic commit**: Transaction commits if closure returns `Ok`
2. **Automatic rollback**: Transaction rolls back if closure returns `Err` or panics
3. **Return values**: The closure can return any value wrapped in `Result<T, sqlx::Error>`
4. **Executor access**: Use `&mut *tx` to pass the transaction as an executor
## Usage Examples
### Basic Transaction
```rust
use sqlx_record::{transaction, prelude::*};
async fn create_user_with_profile(pool: &Pool, user: User, profile: Profile) -> Result<Uuid, sqlx::Error> {
transaction!(&pool, |tx| {
let user_id = user.insert(&mut *tx).await?;
let mut profile = profile;
profile.user_id = user_id;
profile.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(user_id)
}).await
}
```
### Multiple Operations
```rust
async fn transfer_funds(
pool: &Pool,
from_id: &Uuid,
to_id: &Uuid,
amount: i64
) -> Result<(), sqlx::Error> {
transaction!(&pool, |tx| {
// Debit from source
Account::update_by_id(&mut *tx, from_id,
Account::update_form().eval_balance(UpdateExpr::Sub(amount.into()))
).await?;
// Credit to destination
Account::update_by_id(&mut *tx, to_id,
Account::update_form().eval_balance(UpdateExpr::Add(amount.into()))
).await?;
// Create transfer record
let transfer = Transfer {
id: new_uuid(),
from_account: *from_id,
to_account: *to_id,
amount,
created_at: chrono::Utc::now().timestamp_millis(),
};
transfer.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(())
}).await
}
```
### With Error Handling
```rust
async fn create_order(pool: &Pool, cart: Cart) -> Result<Order, AppError> {
transaction!(&pool, |tx| {
// Verify stock
for item in &cart.items {
let product = Product::get_by_id(&mut *tx, &item.product_id).await?
.ok_or(sqlx::Error::RowNotFound)?;
if product.stock < item.quantity {
return Err(sqlx::Error::Protocol("Insufficient stock".into()));
}
}
// Create order
let order = Order {
id: new_uuid(),
user_id: cart.user_id,
status: OrderStatus::PENDING.into(),
total: cart.total(),
created_at: chrono::Utc::now().timestamp_millis(),
};
order.insert(&mut *tx).await?;
// Create order items and decrement stock
for item in cart.items {
let order_item = OrderItem {
id: new_uuid(),
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
price: item.price,
};
order_item.insert(&mut *tx).await?;
Product::update_by_id(&mut *tx, &item.product_id,
Product::update_form().eval_stock(UpdateExpr::Sub(item.quantity.into()))
).await?;
}
Ok::<_, sqlx::Error>(order)
}).await.map_err(AppError::from)
}
```
### Nested Operations (Not Nested Transactions)
```rust
// Helper function that accepts any executor
async fn create_audit_log<'a, E>(executor: E, action: &str, entity_id: Uuid) -> Result<(), sqlx::Error>
where
E: sqlx::Executor<'a, Database = sqlx::MySql>,
{
let log = AuditLog {
id: new_uuid(),
action: action.into(),
entity_id,
created_at: chrono::Utc::now().timestamp_millis(),
};
log.insert(executor).await?;
Ok(())
}
// Use in transaction
transaction!(&pool, |tx| {
user.insert(&mut *tx).await?;
create_audit_log(&mut *tx, "user_created", user.id).await?;
Ok::<_, sqlx::Error>(())
}).await?;
```
## Type Annotation
The closure must have an explicit return type annotation:
```rust
// Correct - with type annotation
Ok::<_, sqlx::Error>(value)
// Also correct
Ok::<i32, sqlx::Error>(42)
// Incorrect - missing annotation (won't compile)
// Ok(value)
```
## Comparison with Manual Transactions
```rust
// Manual approach
let mut tx = pool.begin().await?;
match async {
user.insert(&mut *tx).await?;
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(order.id)
}.await {
Ok(result) => {
tx.commit().await?;
Ok(result)
}
Err(e) => {
tx.rollback().await?;
Err(e)
}
}
// With transaction! macro - cleaner
transaction!(&pool, |tx| {
user.insert(&mut *tx).await?;
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(order.id)
}).await
```
## Notes
- The macro works with all supported databases (MySQL, PostgreSQL, SQLite)
- Transactions use the pool's default isolation level
- For custom isolation levels, use sqlx's native transaction API
- The closure is async - use `.await` for all database operations

5
.gitignore vendored
View File

@ -1,5 +1,6 @@
/target /target
/entity-update_derive/target /sqlx-record-derive/target
/entity-changes-ctl/target /sqlx-record-ctl/target
/mcp/target
.idea .idea
/Cargo.lock /Cargo.lock

View File

@ -1033,6 +1033,239 @@ let users = User::find(&*provider, filters![("active", true)], None).await?;
``` ```
"#; "#;
const PAGINATION: &str = r#"# Pagination
Built-in pagination support with Page<T> and PageRequest.
## PageRequest
```rust
use sqlx_record::prelude::PageRequest;
// Create request (1-indexed pages)
let request = PageRequest::new(1, 20); // page 1, 20 items
// First page shorthand
let request = PageRequest::first(20);
// For manual queries
request.offset() // (page - 1) * page_size
request.limit() // page_size
```
## Page<T>
```rust
let page = User::paginate(&pool, filters, None, order_by, request).await?;
page.items // Vec<T>
page.total_count // u64
page.page // u32 (current page)
page.page_size // u32
page.total_pages() // ceil(total / page_size)
page.has_next() // page < total_pages
page.has_prev() // page > 1
page.is_empty() // items.is_empty()
page.len() // items.len()
page.map(|t| f(t)) // Page<U>
page.into_items() // Vec<T>
```
## Usage
```rust
let page = User::paginate(
&pool,
filters![("is_active", true)],
Some("idx_users"), // MySQL index hint
vec![("created_at", false)], // ORDER BY created_at DESC
PageRequest::new(1, 20)
).await?;
for user in page.iter() {
println!("{}", user.name);
}
if page.has_next() {
let next = User::paginate(&pool, filters, None, order, PageRequest::new(page.page + 1, 20)).await?;
}
```
"#;
const SOFT_DELETE: &str = r#"# Soft Delete
Mark records as deleted without removing from database.
## Enable
```rust
#[derive(Entity, FromRow)]
struct User {
#[primary_key]
id: Uuid,
#[soft_delete] // Must be bool
is_deleted: bool,
}
```
Auto-detection: Fields named `is_deleted` or `deleted` with `bool` type work without attribute.
## Generated Methods
```rust
// Soft delete (set to true)
user.delete(&pool).await?;
User::delete_by_id(&pool, &id).await?;
// Hard delete (permanent)
user.hard_delete(&pool).await?;
User::hard_delete_by_id(&pool, &id).await?;
// Restore (set to false)
user.restore(&pool).await?;
User::restore_by_id(&pool, &id).await?;
// Field name
User::soft_delete_field() // "is_deleted"
```
## Filtering
Soft delete does NOT auto-filter. Add filter manually:
```rust
// Only non-deleted
let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Only deleted (trash)
let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?;
// All records
let all = User::find(&pool, filters![], None).await?;
```
"#;
const BATCH_OPS: &str = r#"# Batch Operations
Efficient bulk insert and upsert operations.
## insert_many()
Insert multiple entities in a single query:
```rust
let users = vec![
User { id: new_uuid(), name: "Alice".into() },
User { id: new_uuid(), name: "Bob".into() },
];
let ids = User::insert_many(&pool, &users).await?;
```
SQL: `INSERT INTO users (id, name) VALUES (?, ?), (?, ?)`
## upsert() / insert_or_update()
Insert or update on primary key conflict:
```rust
user.upsert(&pool).await?;
// or
user.insert_or_update(&pool).await?;
```
SQL (MySQL):
```sql
INSERT INTO users (id, name) VALUES (?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
```
SQL (PostgreSQL/SQLite):
```sql
INSERT INTO users (id, name) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
```
## Use Cases
```rust
// Sync external data
for product in external_products {
Product { id: product.id, name: product.name }.upsert(&pool).await?;
}
// Chunked batch insert
for chunk in users.chunks(1000) {
User::insert_many(&pool, chunk).await?;
}
```
"#;
const TRANSACTIONS: &str = r#"# Transactions
Ergonomic transaction handling with automatic commit/rollback.
## transaction! Macro
```rust
use sqlx_record::transaction;
let result = transaction!(&pool, |tx| {
user.insert(&mut *tx).await?;
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(order.id)
}).await?;
```
- Commits on `Ok`
- Rolls back on `Err` or panic
- Use `&mut *tx` as executor
## Examples
### Transfer Funds
```rust
transaction!(&pool, |tx| {
Account::update_by_id(&mut *tx, &from_id,
Account::update_form().eval_balance(UpdateExpr::Sub(amount.into()))
).await?;
Account::update_by_id(&mut *tx, &to_id,
Account::update_form().eval_balance(UpdateExpr::Add(amount.into()))
).await?;
Ok::<_, sqlx::Error>(())
}).await?;
```
### With Return Value
```rust
let order_id = transaction!(&pool, |tx| {
let user = User { id: new_uuid(), name: "Alice".into() };
user.insert(&mut *tx).await?;
let order = Order { id: new_uuid(), user_id: user.id, total: 100 };
order.insert(&mut *tx).await?;
Ok::<_, sqlx::Error>(order.id)
}).await?;
```
## Type Annotation
Must include return type annotation:
```rust
Ok::<_, sqlx::Error>(value) // Correct
Ok::<i32, sqlx::Error>(42) // Also correct
```
"#;
const CLI_TOOL: &str = r#"# sqlx-record-ctl CLI const CLI_TOOL: &str = r#"# sqlx-record-ctl CLI
Command-line tool for managing audit tables. Command-line tool for managing audit tables.
@ -1488,7 +1721,7 @@ fn handle_list_tools() -> Value {
"properties": { "properties": {
"feature": { "feature": {
"type": "string", "type": "string",
"enum": ["overview", "derive", "filters", "values", "lookup", "audit", "update_form", "update_expr", "conn_provider", "databases", "uuid", "cli", "examples"], "enum": ["overview", "derive", "filters", "values", "lookup", "audit", "update_form", "update_expr", "conn_provider", "databases", "uuid", "cli", "examples", "pagination", "soft_delete", "batch_ops", "transactions"],
"description": "Feature to explain" "description": "Feature to explain"
} }
}, },
@ -1547,6 +1780,10 @@ fn handle_call_tool(params: &Value) -> Value {
"uuid" => NEW_UUID, "uuid" => NEW_UUID,
"cli" => CLI_TOOL, "cli" => CLI_TOOL,
"examples" => EXAMPLES, "examples" => EXAMPLES,
"pagination" => PAGINATION,
"soft_delete" => SOFT_DELETE,
"batch_ops" => BATCH_OPS,
"transactions" => TRANSACTIONS,
_ => OVERVIEW, _ => OVERVIEW,
}; };
json!({ json!({
@ -1646,6 +1883,30 @@ fn handle_list_resources() -> Value {
"name": "Examples", "name": "Examples",
"description": "Complete usage examples", "description": "Complete usage examples",
"mimeType": "text/markdown" "mimeType": "text/markdown"
},
{
"uri": "sqlx-record://docs/pagination",
"name": "Pagination",
"description": "Page<T> and PageRequest for paginated queries",
"mimeType": "text/markdown"
},
{
"uri": "sqlx-record://docs/soft_delete",
"name": "Soft Delete",
"description": "#[soft_delete] attribute and delete/restore methods",
"mimeType": "text/markdown"
},
{
"uri": "sqlx-record://docs/batch_ops",
"name": "Batch Operations",
"description": "insert_many() and upsert() for bulk operations",
"mimeType": "text/markdown"
},
{
"uri": "sqlx-record://docs/transactions",
"name": "Transactions",
"description": "transaction! macro for ergonomic transactions",
"mimeType": "text/markdown"
} }
] ]
}) })
@ -1668,6 +1929,10 @@ fn handle_read_resource(params: &Value) -> Value {
"sqlx-record://docs/uuid" => NEW_UUID, "sqlx-record://docs/uuid" => NEW_UUID,
"sqlx-record://docs/cli" => CLI_TOOL, "sqlx-record://docs/cli" => CLI_TOOL,
"sqlx-record://docs/examples" => EXAMPLES, "sqlx-record://docs/examples" => EXAMPLES,
"sqlx-record://docs/pagination" => PAGINATION,
"sqlx-record://docs/soft_delete" => SOFT_DELETE,
"sqlx-record://docs/batch_ops" => BATCH_OPS,
"sqlx-record://docs/transactions" => TRANSACTIONS,
_ => "Resource not found", _ => "Resource not found",
}; };