From 44ac78d67e421066804924181ac535eb10a00baa Mon Sep 17 00:00:00 2001 From: Michael Netshipise Date: Wed, 28 Jan 2026 16:44:57 +0200 Subject: [PATCH] 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 --- .claude/skills/sqlx-batch-ops.md | 196 ++++++++++++++++++++ .claude/skills/sqlx-entity.md | 69 +++++++ .claude/skills/sqlx-pagination.md | 164 +++++++++++++++++ .claude/skills/sqlx-record.md | 59 +++++- .claude/skills/sqlx-soft-delete.md | 241 +++++++++++++++++++++++++ .claude/skills/sqlx-transactions.md | 209 ++++++++++++++++++++++ .gitignore | 5 +- mcp/src/main.rs | 267 +++++++++++++++++++++++++++- 8 files changed, 1206 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/sqlx-batch-ops.md create mode 100644 .claude/skills/sqlx-pagination.md create mode 100644 .claude/skills/sqlx-soft-delete.md create mode 100644 .claude/skills/sqlx-transactions.md diff --git a/.claude/skills/sqlx-batch-ops.md b/.claude/skills/sqlx-batch-ops.md new file mode 100644 index 0000000..ac1d09f --- /dev/null +++ b/.claude/skills/sqlx-batch-ops.md @@ -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, 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) -> Result, 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 +pub async fn insert_or_update(&self, executor) -> Result // 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) -> 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 diff --git a/.claude/skills/sqlx-entity.md b/.claude/skills/sqlx-entity.md index 45b9c32..2f4d695 100644 --- a/.claude/skills/sqlx-entity.md +++ b/.claude/skills/sqlx-entity.md @@ -56,11 +56,45 @@ large_count: i64, - SQLx type hint for compile-time validation - 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 ### Insert ```rust pub async fn insert(&self, executor: E) -> Result + +// Batch insert +pub async fn insert_many(executor, entities: &[Self]) -> Result, Error> + +// Insert or update on conflict +pub async fn upsert(&self, executor) -> Result +pub async fn insert_or_update(&self, executor) -> Result // alias ``` ### Get Methods @@ -102,6 +136,23 @@ pub async fn find_ordered_with_limit( // Count matching pub async fn count(executor, filters: Vec, index: Option<&str>) -> Result + +// Paginated results +pub async fn paginate( + executor, + filters: Vec, + index: Option<&str>, + order_by: Vec<(&str, bool)>, + page_request: PageRequest +) -> Result, Error> + +// Select specific columns only +pub async fn find_partial( + executor, + select_fields: &[&str], + filters: Vec, + index: Option<&str> +) -> Result, Error> ``` ### Update Methods @@ -143,6 +194,24 @@ pub async fn get_version(executor, pk: &PkType) -> Result, E pub async fn get_versions(executor, pks: &[PkType]) -> Result, 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 ```rust pub const fn table_name() -> &'static str diff --git a/.claude/skills/sqlx-pagination.md b/.claude/skills/sqlx-pagination.md new file mode 100644 index 0000000..7328101 --- /dev/null +++ b/.claude/skills/sqlx-pagination.md @@ -0,0 +1,164 @@ +# sqlx-record Pagination Skill + +Guide to pagination with Page and PageRequest. + +## Triggers +- "pagination", "paginate" +- "page request", "page size" +- "total pages", "has next" + +## Overview + +`sqlx-record` provides built-in pagination support with the `Page` 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 + +Paginated results container: + +```rust +use sqlx_record::prelude::Page; + +// Properties +page.items // Vec - 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 +page.into_items() // Vec +page.iter() // impl Iterator +``` + +## Entity Paginate Method + +Generated on all entities: + +```rust +pub async fn paginate( + executor, + filters: Vec, + index: Option<&str>, // MySQL index hint + order_by: Vec<(&str, bool)>, // (field, is_ascending) + page_request: PageRequest +) -> Result, 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 = page.map(|user| UserDto::from(user)); + +// Or consume items +let items: Vec = 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 diff --git a/.claude/skills/sqlx-record.md b/.claude/skills/sqlx-record.md index c551070..3ae0cda 100644 --- a/.claude/skills/sqlx-record.md +++ b/.claude/skills/sqlx-record.md @@ -117,11 +117,68 @@ let id = new_uuid(); // Timestamp prefix for better indexing ```toml [dependencies] -sqlx-record = { version = "0.2", features = ["mysql", "derive"] } +sqlx-record = { version = "0.3", features = ["mysql", "derive"] } # Database: "mysql", "postgres", or "sqlite" (pick one) # 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 +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) ```rust diff --git a/.claude/skills/sqlx-soft-delete.md b/.claude/skills/sqlx-soft-delete.md new file mode 100644 index 0000000..d6642e3 --- /dev/null +++ b/.claude/skills/sqlx-soft-delete.md @@ -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>, + index: Option<&str> + ) -> Result, 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` for deletion timestamps +- For complex filtering, consider database views diff --git a/.claude/skills/sqlx-transactions.md b/.claude/skills/sqlx-transactions.md new file mode 100644 index 0000000..59fcf01 --- /dev/null +++ b/.claude/skills/sqlx-transactions.md @@ -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` +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 { + 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 { + 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::(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 diff --git a/.gitignore b/.gitignore index 409fc30..c4e7cff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target -/entity-update_derive/target -/entity-changes-ctl/target +/sqlx-record-derive/target +/sqlx-record-ctl/target +/mcp/target .idea /Cargo.lock diff --git a/mcp/src/main.rs b/mcp/src/main.rs index 514c91d..124845b 100644 --- a/mcp/src/main.rs +++ b/mcp/src/main.rs @@ -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 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 + +```rust +let page = User::paginate(&pool, filters, None, order_by, request).await?; + +page.items // Vec +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 +page.into_items() // Vec +``` + +## 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::(42) // Also correct +``` +"#; + const CLI_TOOL: &str = r#"# sqlx-record-ctl CLI Command-line tool for managing audit tables. @@ -1488,7 +1721,7 @@ fn handle_list_tools() -> Value { "properties": { "feature": { "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" } }, @@ -1547,6 +1780,10 @@ fn handle_call_tool(params: &Value) -> Value { "uuid" => NEW_UUID, "cli" => CLI_TOOL, "examples" => EXAMPLES, + "pagination" => PAGINATION, + "soft_delete" => SOFT_DELETE, + "batch_ops" => BATCH_OPS, + "transactions" => TRANSACTIONS, _ => OVERVIEW, }; json!({ @@ -1646,6 +1883,30 @@ fn handle_list_resources() -> Value { "name": "Examples", "description": "Complete usage examples", "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/pagination", + "name": "Pagination", + "description": "Page 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/cli" => CLI_TOOL, "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", };