From b1052ac27105ff70d5189c39013d2f38ec661947 Mon Sep 17 00:00:00 2001 From: Michael Netshipise Date: Wed, 28 Jan 2026 16:21:14 +0200 Subject: [PATCH] Release v0.2.0 with UpdateExpr, ConnProvider, MCP server, and skills New features: - UpdateExpr: Advanced updates with column arithmetic (Add, Sub, Mul, Div, Mod), CASE/WHEN conditionals, AddIf/SubIf, Coalesce, Greatest, Least, and raw SQL - ConnProvider: Flexible borrowed/owned connection management - lookup_table! and lookup_options! macros for type-safe lookup tables - new_uuid() for time-ordered UUIDs with better database indexing - MCP server (sqlx-record-expert) with documentation tools and resources - Claude Code skills for all features Improvements: - Fixed Postgres/SQLite unsigned integer binding (cast to signed) - Added From implementations for all integer types to Value - Added param_count() to Filter for expression parameter counting - Added bind_all_values() for proper expression binding order All three database backends (MySQL, PostgreSQL, SQLite) build and work correctly. Co-Authored-By: Claude Opus 4.5 --- .claude/skills/sqlx-audit.md | 276 +++++ .claude/skills/sqlx-conn-provider.md | 202 +++ .claude/skills/sqlx-entity.md | 238 ++++ .claude/skills/sqlx-filters.md | 261 ++++ .claude/skills/sqlx-lookup.md | 279 +++++ .claude/skills/sqlx-record.md | 165 +++ .claude/skills/sqlx-update-expr.md | 242 ++++ .claude/skills/sqlx-values.md | 257 ++++ CLAUDE.md | 137 +- Cargo.toml | 10 +- mcp/Cargo.toml | 14 + mcp/src/main.rs | 1724 ++++++++++++++++++++++++++ sqlx-record-ctl/Cargo.toml | 2 +- sqlx-record-derive/Cargo.toml | 2 +- sqlx-record-derive/src/lib.rs | 128 +- src/conn_provider.rs | 145 +++ src/filter.rs | 21 + src/lib.rs | 164 +++ src/value.rs | 439 ++++++- 19 files changed, 4652 insertions(+), 54 deletions(-) create mode 100644 .claude/skills/sqlx-audit.md create mode 100644 .claude/skills/sqlx-conn-provider.md create mode 100644 .claude/skills/sqlx-entity.md create mode 100644 .claude/skills/sqlx-filters.md create mode 100644 .claude/skills/sqlx-lookup.md create mode 100644 .claude/skills/sqlx-record.md create mode 100644 .claude/skills/sqlx-update-expr.md create mode 100644 .claude/skills/sqlx-values.md create mode 100644 mcp/Cargo.toml create mode 100644 mcp/src/main.rs create mode 100644 src/conn_provider.rs diff --git a/.claude/skills/sqlx-audit.md b/.claude/skills/sqlx-audit.md new file mode 100644 index 0000000..1d1504f --- /dev/null +++ b/.claude/skills/sqlx-audit.md @@ -0,0 +1,276 @@ +# sqlx-record Audit Trail Skill + +Guide to change tracking and audit functionality. + +## Triggers +- "audit trail", "change tracking" +- "entity change", "change history" +- "who changed", "audit log" + +## EntityChange Model + +```rust +pub struct EntityChange { + pub id: Uuid, // Change record ID + pub entity_id: Uuid, // Target entity ID + pub action: String, // Change type + pub changed_at: i64, // Timestamp (milliseconds since epoch) + pub actor_id: Uuid, // Who made the change + pub session_id: Uuid, // Session context + pub change_set_id: Uuid, // Transaction grouping + pub new_value: Option, // JSON diff of changes +} +``` + +## Action Enum + +```rust +pub enum Action { + Insert, // New entity created + Update, // Entity modified + Delete, // Soft delete + Restore, // Restored from soft delete + HardDelete, // Permanent deletion + Unknown(String), +} + +// String conversion +let action = Action::from("insert".to_string()); // Action::Insert +println!("{}", Action::Update); // "update" +``` + +## Repository Functions + +```rust +use sqlx_record::repositories::*; + +// Create a change record +create_entity_change(&pool, "entity_changes_users", &change).await?; + +// Query by change ID +let changes = get_entity_changes_by_id(&pool, table, &change_id).await?; + +// Query by entity (all changes to an entity) +let history = get_entity_changes_by_entity(&pool, table, &entity_id).await?; + +// Query by session (all changes in a session) +let changes = get_entity_changes_session(&pool, table, &session_id).await?; + +// Query by actor (all changes by a user) +let changes = get_entity_changes_actor(&pool, table, &actor_id).await?; + +// Query by change set (atomic transaction) +let changes = get_entity_changes_by_change_set(&pool, table, &change_set_id).await?; + +// Combined query +let changes = get_entity_changes_by_entity_and_actor(&pool, table, &entity_id, &actor_id).await?; +``` + +## Diff Methods + +### model_diff +Compare UpdateForm with existing model: +```rust +let form = User::update_form().with_name("New Name").with_email("new@example.com"); +let existing = User::get_by_id(&pool, &id).await?.unwrap(); + +let diff = User::model_diff(&form, &existing); +// {"name": {"old": "Old Name", "new": "New Name"}, "email": {"old": "old@example.com", "new": "new@example.com"}} +``` + +### db_diff +Compare UpdateForm with database values: +```rust +let form = User::update_form().with_name("New Name"); +let diff = User::db_diff(&form, &user_id, &pool).await?; +``` + +### diff_modify +Modify form to only include actual changes: +```rust +let mut form = User::update_form().with_name("Same Name").with_email("new@example.com"); +let existing = User::get_by_id(&pool, &id).await?.unwrap(); + +let diff = User::diff_modify(&mut form, &existing); +// form now only contains email if name was already "Same Name" +// diff contains only the actual changes +``` + +### initial_diff +Capture initial state for inserts: +```rust +let new_user = User { id: new_uuid(), name: "Alice".into(), ... }; +let diff = new_user.initial_diff(); +// {"id": "...", "name": "Alice", ...} +``` + +### to_update_form +Convert entity to UpdateForm: +```rust +let user = User::get_by_id(&pool, &id).await?.unwrap(); +let form = user.to_update_form(); +// form has all fields set to current values +``` + +## Audit Table Setup + +Use sqlx-record-ctl CLI: +```bash +sqlx-record-ctl --schema-name mydb --db-url "mysql://user:pass@localhost/mydb" +``` + +Or create manually: +```sql +-- Metadata table (required) +CREATE TABLE entity_changes_metadata ( + table_name VARCHAR(255) PRIMARY KEY, + is_auditable BOOLEAN NOT NULL DEFAULT TRUE +); + +INSERT INTO entity_changes_metadata VALUES ('users', TRUE); + +-- Audit table +CREATE TABLE entity_changes_users ( + id BINARY(16) PRIMARY KEY, + entity_id BINARY(16) NOT NULL, + action ENUM('insert','update','delete','restore','hard-delete'), + changed_at BIGINT, + actor_id BINARY(16), + session_id BINARY(16), + change_set_id BINARY(16), + new_value JSON +); + +-- Indexes +CREATE INDEX idx_users_entity_id ON entity_changes_users (entity_id); +CREATE INDEX idx_users_actor_id ON entity_changes_users (actor_id); +CREATE INDEX idx_users_session_id ON entity_changes_users (session_id); +CREATE INDEX idx_users_change_set_id ON entity_changes_users (change_set_id); +CREATE INDEX idx_users_entity_id_actor_id ON entity_changes_users (entity_id, actor_id); +``` + +## Complete Audit Integration + +```rust +use sqlx_record::prelude::*; +use sqlx_record::models::{EntityChange, Action}; +use sqlx_record::repositories::create_entity_change; + +async fn create_user_with_audit( + pool: &Pool, + user: User, + actor_id: &Uuid, + session_id: &Uuid, + change_set_id: &Uuid, +) -> Result { + // Capture initial state + let diff = user.initial_diff(); + + // Insert entity + let user_id = user.insert(pool).await?; + + // Record change + let change = EntityChange { + id: new_uuid(), + entity_id: user_id, + action: Action::Insert.to_string(), + changed_at: chrono::Utc::now().timestamp_millis(), + actor_id: *actor_id, + session_id: *session_id, + change_set_id: *change_set_id, + new_value: Some(serde_json::to_value(diff).unwrap()), + }; + + create_entity_change(pool, &User::entity_changes_table_name(), &change).await?; + + Ok(user_id) +} + +async fn update_user_with_audit( + pool: &Pool, + user_id: &Uuid, + form: UserUpdateForm, + actor_id: &Uuid, + session_id: &Uuid, + change_set_id: &Uuid, +) -> Result<(), Error> { + // Get current state + let existing = User::get_by_id(pool, user_id).await?.unwrap(); + + // Calculate diff + let diff = User::model_diff(&form, &existing); + + // Skip if no changes + if diff.as_object().map(|o| o.is_empty()).unwrap_or(true) { + return Ok(()); + } + + // Update entity + User::update_by_id(pool, user_id, form).await?; + + // Record change + let change = EntityChange { + id: new_uuid(), + entity_id: *user_id, + action: Action::Update.to_string(), + changed_at: chrono::Utc::now().timestamp_millis(), + actor_id: *actor_id, + session_id: *session_id, + change_set_id: *change_set_id, + new_value: Some(diff), + }; + + create_entity_change(pool, &User::entity_changes_table_name(), &change).await?; + + Ok(()) +} + +// Query history +async fn get_user_history(pool: &Pool, user_id: &Uuid) -> Result, Error> { + get_entity_changes_by_entity(pool, "entity_changes_users", user_id).await +} + +// Query by actor +async fn get_changes_by_user(pool: &Pool, actor_id: &Uuid) -> Result, Error> { + get_entity_changes_actor(pool, "entity_changes_users", actor_id).await +} +``` + +## Change Set Pattern + +Group related changes into atomic transactions: + +```rust +async fn transfer_ownership( + pool: &Pool, + item_id: &Uuid, + from_user: &Uuid, + to_user: &Uuid, + actor_id: &Uuid, + session_id: &Uuid, +) -> Result<(), Error> { + // Single change set for the transaction + let change_set_id = new_uuid(); + + // Update item ownership + update_with_audit(pool, item_id, + Item::update_form().with_owner_id(*to_user), + actor_id, session_id, &change_set_id + ).await?; + + // Update from_user stats + update_with_audit(pool, from_user, + UserStats::update_form().with_item_count_delta(-1), + actor_id, session_id, &change_set_id + ).await?; + + // Update to_user stats + update_with_audit(pool, to_user, + UserStats::update_form().with_item_count_delta(1), + actor_id, session_id, &change_set_id + ).await?; + + // All three changes can be queried together via change_set_id + Ok(()) +} +``` diff --git a/.claude/skills/sqlx-conn-provider.md b/.claude/skills/sqlx-conn-provider.md new file mode 100644 index 0000000..52bef3c --- /dev/null +++ b/.claude/skills/sqlx-conn-provider.md @@ -0,0 +1,202 @@ +# sqlx-record ConnProvider Skill + +Guide to flexible connection management. + +## Triggers +- "connection provider", "conn provider" +- "borrow connection", "pool connection" +- "lazy connection", "connection management" + +## Overview + +`ConnProvider` enables flexible connection handling: +- **Borrowed**: Use an existing connection reference +- **Owned**: Lazily acquire from pool on first use + +## Enum Variants + +```rust +pub enum ConnProvider<'a> { + /// Reference to existing connection + Borrowed { + conn: &'a mut PoolConnection, + }, + /// Lazy acquisition from pool + Owned { + pool: Pool, + conn: Option>, + }, +} +``` + +## Constructors + +### from_ref +Use an existing connection: +```rust +let mut conn = pool.acquire().await?; +let mut provider = ConnProvider::from_ref(&mut conn); +``` + +### from_pool +Lazy acquisition from pool: +```rust +let mut provider = ConnProvider::from_pool(pool.clone()); +// Connection acquired on first get_conn() call +``` + +## Getting the Connection + +```rust +let conn = provider.get_conn().await?; +// Returns &mut PoolConnection +``` + +- **Borrowed**: Returns reference immediately +- **Owned**: Acquires on first call, returns same connection on subsequent calls + +## Use Cases + +### Reuse Existing Connection +```rust +async fn process_batch(conn: &mut PoolConnection) -> Result<()> { + let mut provider = ConnProvider::from_ref(conn); + + do_work_a(&mut provider).await?; + do_work_b(&mut provider).await?; // Same connection + do_work_c(&mut provider).await?; // Same connection + + Ok(()) +} +``` + +### Lazy Pool Connection +```rust +async fn maybe_needs_db(pool: MySqlPool, condition: bool) -> Result<()> { + let mut provider = ConnProvider::from_pool(pool); + + if condition { + // Connection acquired here (first use) + let conn = provider.get_conn().await?; + sqlx::query("SELECT 1").execute(&mut **conn).await?; + } + // If condition is false, no connection was ever acquired + + Ok(()) +} +``` + +### Uniform Interface +```rust +async fn do_database_work(provider: &mut ConnProvider<'_>) -> Result<()> { + let conn = provider.get_conn().await?; + + // Works regardless of Borrowed or Owned + sqlx::query("INSERT INTO logs (msg) VALUES (?)") + .bind("operation completed") + .execute(&mut **conn) + .await?; + + Ok(()) +} + +// Call with borrowed +let mut conn = pool.acquire().await?; +do_database_work(&mut ConnProvider::from_ref(&mut conn)).await?; + +// Call with pool +do_database_work(&mut ConnProvider::from_pool(pool)).await?; +``` + +### Transaction-like Patterns +```rust +async fn multi_step_operation(pool: MySqlPool) -> Result<()> { + let mut provider = ConnProvider::from_pool(pool); + + // All operations use same connection + step_1(&mut provider).await?; + step_2(&mut provider).await?; + step_3(&mut provider).await?; + + // Connection returned to pool when provider drops + Ok(()) +} +``` + +## Database-Specific Types + +The concrete types depend on the enabled feature: + +| Feature | Pool Type | Connection Type | +|---------|-----------|-----------------| +| `mysql` | `MySqlPool` | `PoolConnection` | +| `postgres` | `PgPool` | `PoolConnection` | +| `sqlite` | `SqlitePool` | `PoolConnection` | + +## Example: Service Layer + +```rust +use sqlx_record::prelude::*; + +struct UserService; + +impl UserService { + async fn create_with_profile( + provider: &mut ConnProvider<'_>, + name: &str, + bio: &str, + ) -> Result { + let conn = provider.get_conn().await?; + + // Create user + let user_id = new_uuid(); + sqlx::query("INSERT INTO users (id, name) VALUES (?, ?)") + .bind(user_id) + .bind(name) + .execute(&mut **conn) + .await?; + + // Create profile (same connection) + sqlx::query("INSERT INTO profiles (user_id, bio) VALUES (?, ?)") + .bind(user_id) + .bind(bio) + .execute(&mut **conn) + .await?; + + Ok(user_id) + } +} + +// Usage +let mut provider = ConnProvider::from_pool(pool); +let user_id = UserService::create_with_profile(&mut provider, "Alice", "Hello!").await?; +``` + +## Connection Lifecycle + +``` +from_pool(pool) from_ref(&mut conn) + │ │ + ▼ ▼ + Owned { Borrowed { + pool, conn: &mut PoolConnection + conn: None } + } │ + │ │ + │ get_conn() │ get_conn() + ▼ ▼ + pool.acquire() return conn + │ │ + ▼ │ + Owned { │ + pool, │ + conn: Some(acquired) │ + } │ + │ │ + │ get_conn() (subsequent) │ + ▼ │ + return &mut acquired │ + │ │ + ▼ ▼ + Drop: conn returned Drop: nothing (borrowed) +``` diff --git a/.claude/skills/sqlx-entity.md b/.claude/skills/sqlx-entity.md new file mode 100644 index 0000000..45b9c32 --- /dev/null +++ b/.claude/skills/sqlx-entity.md @@ -0,0 +1,238 @@ +# sqlx-record Entity Skill + +Detailed guidance for #[derive(Entity)] macro. + +## Triggers +- "derive entity", "entity macro" +- "generate crud", "crud methods" +- "primary key", "version field" +- "table name", "rename field" + +## Struct Attributes + +### #[table_name] +```rust +#[derive(Entity, FromRow)] +#[table_name = "users"] // or #[table_name("users")] +struct User { ... } +``` +- Optional: defaults to snake_case of struct name +- `User` -> `users`, `OrderItem` -> `order_items` + +## Field Attributes + +### #[primary_key] +```rust +#[primary_key] +id: Uuid, +``` +- Required on one field +- Generates `get_by_{pk}`, `update_by_{pk}` methods +- Supports: Uuid, String, i32, i64, etc. + +### #[rename] +```rust +#[rename("user_name")] // or #[rename = "user_name"] +name: String, +``` +- Maps struct field to different database column +- Use when DB column doesn't match Rust naming + +### #[version] +```rust +#[version] +version: u32, +``` +- Auto-increments on every update +- Wraps on overflow (u32::MAX -> 0) +- Generates `get_version()`, `get_versions()` methods +- Supports: u32, u64, i32, i64 + +### #[field_type] +```rust +#[field_type("BIGINT")] // or #[field_type = "BIGINT"] +large_count: i64, +``` +- SQLx type hint for compile-time validation +- Adds type annotation in SELECT: `field as "field: TYPE"` + +## Generated Methods + +### Insert +```rust +pub async fn insert(&self, executor: E) -> Result +``` + +### Get Methods +```rust +// By single primary key +pub async fn get_by_id(executor, id: &Uuid) -> Result, Error> + +// By multiple primary keys +pub async fn get_by_ids(executor, ids: &[Uuid]) -> Result, Error> + +// Generic primary key access +pub async fn get_by_primary_key(executor, pk: &PkType) -> Result, Error> +``` + +### Find Methods +```rust +// Basic find +pub async fn find(executor, filters: Vec, index: Option<&str>) -> Result, Error> + +// Find first match +pub async fn find_one(executor, filters: Vec, index: Option<&str>) -> Result, Error> + +// With ordering +pub async fn find_ordered( + executor, + filters: Vec, + index: Option<&str>, + order_by: Vec<(&str, bool)> // (field, is_ascending) +) -> Result, Error> + +// With ordering and pagination +pub async fn find_ordered_with_limit( + executor, + filters: Vec, + index: Option<&str>, + order_by: Vec<(&str, bool)>, + offset_limit: Option<(u32, u32)> // (offset, limit) +) -> Result, Error> + +// Count matching +pub async fn count(executor, filters: Vec, index: Option<&str>) -> Result +``` + +### Update Methods +```rust +// Update instance +pub async fn update(&self, executor, form: UpdateForm) -> Result<(), Error> + +// Update by primary key +pub async fn update_by_id(executor, id: &Uuid, form: UpdateForm) -> Result<(), Error> + +// Update multiple +pub async fn update_by_ids(executor, ids: &[Uuid], form: UpdateForm) -> Result<(), Error> + +// Create update form +pub fn update_form() -> UpdateForm +``` + +### Diff Methods +```rust +// Compare form with model +pub fn model_diff(form: &UpdateForm, model: &Self) -> serde_json::Value + +// Compare form with database +pub async fn db_diff(form: &UpdateForm, pk: &PkType, executor) -> Result + +// Modify form to only include changes +pub fn diff_modify(form: &mut UpdateForm, model: &Self) -> serde_json::Value + +// Convert entity to update form +pub fn to_update_form(&self) -> UpdateForm + +// Get initial state as JSON +pub fn initial_diff(&self) -> serde_json::Value +``` + +### Version Methods (if #[version] exists) +```rust +pub async fn get_version(executor, pk: &PkType) -> Result, Error> +pub async fn get_versions(executor, pks: &[PkType]) -> Result, Error> +``` + +### Metadata Methods +```rust +pub const fn table_name() -> &'static str +pub fn entity_key(pk: &PkType) -> String // "/entities/{table}/{pk}" +pub fn entity_changes_table_name() -> String // "entity_changes_{table}" +pub const fn primary_key_field() -> &'static str +pub const fn primary_key_db_field() -> &'static str +pub fn primary_key(&self) -> &PkType +pub fn select_fields() -> Vec<&'static str> +``` + +## UpdateForm + +Generated struct `{Entity}UpdateForm` with all non-PK fields as Option. + +```rust +// Builder pattern +let form = User::update_form() + .with_name("Alice") + .with_email("alice@example.com"); + +// Setter pattern +let mut form = User::update_form(); +form.set_name("Alice"); + +// Execute +User::update_by_id(&pool, &id, form).await?; +``` + +Only set fields are updated - others remain unchanged. + +## Complete Example + +```rust +use sqlx_record::prelude::*; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Entity, FromRow, Debug, Clone)] +#[table_name = "products"] +pub struct Product { + #[primary_key] + pub id: Uuid, + + #[rename("product_name")] + pub name: String, + + pub price_cents: i64, + + pub category_id: Uuid, + + pub is_active: bool, + + #[version] + pub version: u32, + + #[field_type("TEXT")] + pub description: Option, +} + +// Usage +async fn example(pool: &Pool) -> Result<(), Error> { + // Create + let product = Product { + id: new_uuid(), + name: "Widget".into(), + price_cents: 999, + category_id: category_id, + is_active: true, + version: 0, + description: Some("A great widget".into()), + }; + product.insert(pool).await?; + + // Read + let product = Product::get_by_id(pool, &product.id).await?.unwrap(); + + // Update + Product::update_by_id(pool, &product.id, + Product::update_form() + .with_price_cents(1299) + .with_is_active(false) + ).await?; + + // Find active products in category + let products = Product::find(pool, + filters![("is_active", true), ("category_id", category_id)], + None + ).await?; + + Ok(()) +} +``` diff --git a/.claude/skills/sqlx-filters.md b/.claude/skills/sqlx-filters.md new file mode 100644 index 0000000..7da3846 --- /dev/null +++ b/.claude/skills/sqlx-filters.md @@ -0,0 +1,261 @@ +# sqlx-record Filters Skill + +Comprehensive guide to the Filter system. + +## Triggers +- "filter query", "where clause" +- "filter macro", "filters!" +- "query builder", "search criteria" + +## Filter Enum + +```rust +pub enum Filter<'a> { + // Comparison + Equal(&'a str, Value), + NotEqual(&'a str, Value), + GreaterThan(&'a str, Value), + GreaterThanOrEqual(&'a str, Value), + LessThan(&'a str, Value), + LessThanOrEqual(&'a str, Value), + + // Pattern matching + Like(&'a str, Value), + ILike(&'a str, Value), // Case-insensitive + NotLike(&'a str, Value), + + // Set membership + In(&'a str, Vec), + NotIn(&'a str, Vec), + + // Null checks + IsNull(&'a str), + IsNotNull(&'a str), + + // Composition + And(Vec>), + Or(Vec>), +} +``` + +## Macros + +### filters! +```rust +// Empty +filters![] + +// Single condition +filters![("is_active", true)] + +// Multiple conditions (implicit AND) +filters![("is_active", true), ("role", "admin")] +``` + +### filter_and! +```rust +// Explicit AND +filter_and![("age", 18), ("verified", true)] +``` + +### filter_or! +```rust +// OR conditions +filter_or![("status", "active"), ("status", "pending")] +``` + +## Operator Trait (FilterOps) + +String references implement FilterOps for fluent syntax: + +```rust +"age".gt(18) // Filter::GreaterThan("age", 18.into()) +"age".ge(18) // Filter::GreaterThanOrEqual +"age".lt(65) // Filter::LessThan +"age".le(65) // Filter::LessThanOrEqual +"name".eq("Bob") // Filter::Equal +"name".ne("Bob") // Filter::NotEqual +``` + +## Direct Filter Construction + +```rust +// Pattern matching +Filter::Like("name", "%alice%".into()) +Filter::ILike("email", "%@GMAIL.COM".into()) // Case-insensitive +Filter::NotLike("name", "test%".into()) + +// Set membership +Filter::In("status", vec!["active".into(), "pending".into()]) +Filter::NotIn("role", vec!["banned".into(), "suspended".into()]) + +// Null checks +Filter::IsNull("deleted_at") +Filter::IsNotNull("email_verified_at") +``` + +## Composition + +### Nested AND +```rust +Filter::And(vec![ + "age".ge(18), + "age".le(65), + Filter::IsNotNull("email"), +]) +``` + +### Nested OR +```rust +Filter::Or(vec![ + Filter::Equal("status", "active".into()), + Filter::Equal("status", "pending".into()), +]) +``` + +### Complex Queries +```rust +// (age >= 18 AND verified = true) OR role = 'admin' +let filters = vec![ + Filter::Or(vec![ + Filter::And(vec![ + "age".ge(18), + Filter::Equal("verified", true.into()), + ]), + Filter::Equal("role", "admin".into()), + ]) +]; +``` + +## Usage with Entity Methods + +### find() +```rust +let users = User::find(&pool, filters![("is_active", true)], None).await?; +``` + +### find_one() +```rust +let user = User::find_one(&pool, filters![("email", email)], None).await?; +``` + +### find_ordered() +```rust +let users = User::find_ordered( + &pool, + filters![("is_active", true)], + None, + vec![("created_at", false), ("name", true)] // DESC, ASC +).await?; +``` + +### find_ordered_with_limit() +```rust +let page = User::find_ordered_with_limit( + &pool, + filters![("role", "admin")], + None, + vec![("created_at", false)], + Some((20, 10)) // OFFSET 20, LIMIT 10 (page 3) +).await?; +``` + +### count() +```rust +let active_count = User::count(&pool, filters![("is_active", true)], None).await?; +``` + +## Index Hints (MySQL only) + +```rust +// MySQL: SELECT ... FROM users USE INDEX(idx_users_email) WHERE ... +let users = User::find(&pool, filters, Some("idx_users_email")).await?; + +// PostgreSQL/SQLite: Index hint ignored +``` + +## Building WHERE Clauses Manually + +```rust +let filters = filters![("status", "active"), ("role", "admin")]; + +// Build clause starting at placeholder index 1 +let (clause, values) = Filter::build_where_clause(&filters); +// MySQL/SQLite: "status = ? AND role = ?" +// PostgreSQL: "status = $1 AND role = $2" + +// Build with custom offset (e.g., after binding other values) +let (clause, values) = Filter::build_where_clause_with_offset(&filters, 3); +// PostgreSQL: "status = $3 AND role = $4" +``` + +## ILIKE Handling + +PostgreSQL has native ILIKE. For MySQL/SQLite, it's emulated: + +```rust +// PostgreSQL: field ILIKE value +// MySQL/SQLite: LOWER(field) LIKE LOWER(value) +Filter::ILike("email", "%@gmail.com".into()) +``` + +## Common Patterns + +### Pagination +```rust +async fn get_page(pool: &Pool, page: u32, per_page: u32, filters: Vec>) -> Result> { + let offset = page * per_page; + User::find_ordered_with_limit( + pool, + filters, + None, + vec![("id", true)], // Stable ordering + Some((offset, per_page)) + ).await +} +``` + +### Search with Multiple Fields +```rust +fn search_users(query: &str) -> Vec> { + let pattern = format!("%{}%", query); + vec![filter_or![ + Filter::ILike("name", pattern.clone().into()), + Filter::ILike("email", pattern.clone().into()), + Filter::ILike("username", pattern.into()), + ]] +} +``` + +### Date Range +```rust +fn date_range(start: NaiveDate, end: NaiveDate) -> Vec> { + vec![ + "created_at".ge(start), + "created_at".le(end), + ] +} +``` + +### Optional Filters +```rust +fn build_filters( + status: Option<&str>, + min_age: Option, + role: Option<&str>, +) -> Vec> { + let mut filters = vec![]; + + if let Some(s) = status { + filters.push(Filter::Equal("status", s.into())); + } + if let Some(age) = min_age { + filters.push("age".ge(age)); + } + if let Some(r) = role { + filters.push(Filter::Equal("role", r.into())); + } + + filters +} +``` diff --git a/.claude/skills/sqlx-lookup.md b/.claude/skills/sqlx-lookup.md new file mode 100644 index 0000000..f2bcc87 --- /dev/null +++ b/.claude/skills/sqlx-lookup.md @@ -0,0 +1,279 @@ +# sqlx-record Lookup Tables Skill + +Guide to lookup_table! and lookup_options! macros. + +## Triggers +- "lookup table", "lookup options" +- "code enum", "status enum" +- "type-safe codes", "constants" + +## lookup_table! Macro + +Creates a database-backed lookup entity with type-safe code enum. + +### Syntax +```rust +lookup_table!(Name, "code1", "code2", "code3"); +``` + +### Generated Code +```rust +// Entity struct with CRUD via #[derive(Entity)] +#[derive(Entity, FromRow)] +pub struct Name { + #[primary_key] + pub code: String, + pub name: String, + pub description: String, + pub is_active: bool, +} + +// Type-safe enum +pub enum NameCode { + Code1, + Code2, + Code3, +} + +// String constants +impl Name { + pub const CODE1: &'static str = "code1"; + pub const CODE2: &'static str = "code2"; + pub const CODE3: &'static str = "code3"; +} + +// Display impl (enum -> string) +impl Display for NameCode { ... } + +// TryFrom impl (string -> enum) +impl TryFrom<&str> for NameCode { ... } +``` + +### Example +```rust +lookup_table!(OrderStatus, + "pending", + "processing", + "shipped", + "delivered", + "cancelled" +); + +// Usage +let status = OrderStatus::PENDING; // "pending" +let code = OrderStatusCode::Pending; +println!("{}", code); // "pending" + +// Parse from string +let code = OrderStatusCode::try_from("shipped")?; + +// Query the lookup table +let statuses = OrderStatus::find(&pool, filters![("is_active", true)], None).await?; + +// Get specific status +let status = OrderStatus::get_by_code(&pool, OrderStatus::PENDING).await?; +``` + +### Database Schema +```sql +CREATE TABLE order_status ( + code VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +INSERT INTO order_status VALUES + ('pending', 'Pending', 'Order awaiting processing', TRUE), + ('processing', 'Processing', 'Order being prepared', TRUE), + ('shipped', 'Shipped', 'Order in transit', TRUE), + ('delivered', 'Delivered', 'Order completed', TRUE), + ('cancelled', 'Cancelled', 'Order cancelled', TRUE); +``` + +## lookup_options! Macro + +Creates code enum without database entity (for embedded options). + +### Syntax +```rust +lookup_options!(Name, "code1", "code2", "code3"); +``` + +### Generated Code +```rust +// Type-safe enum +pub enum NameCode { + Code1, + Code2, + Code3, +} + +// Unit struct for constants +pub struct Name; + +impl Name { + pub const CODE1: &'static str = "code1"; + pub const CODE2: &'static str = "code2"; + pub const CODE3: &'static str = "code3"; +} + +// Display and TryFrom implementations +``` + +### Example +```rust +lookup_options!(PaymentMethod, + "credit-card", + "debit-card", + "paypal", + "bank-transfer", + "crypto" +); + +// Usage +let method = PaymentMethod::CREDIT_CARD; // "credit-card" + +// In entity +#[derive(Entity)] +struct Order { + #[primary_key] + id: Uuid, + payment_method: String, // Stores the code string +} + +// Query with constant +let orders = Order::find(&pool, + filters![("payment_method", PaymentMethod::PAYPAL)], + None +).await?; + +// Type-safe matching +match PaymentMethodCode::try_from(order.payment_method.as_str())? { + PaymentMethodCode::CreditCard => process_credit_card(), + PaymentMethodCode::DebitCard => process_debit_card(), + PaymentMethodCode::Paypal => process_paypal(), + PaymentMethodCode::BankTransfer => process_bank_transfer(), + PaymentMethodCode::Crypto => process_crypto(), +} +``` + +## Code Naming Conventions + +Codes are converted to enum variants using camelCase: + +| Code String | Enum Variant | Constant | +|-------------|--------------|----------| +| `"active"` | `Active` | `ACTIVE` | +| `"pro-rata"` | `ProRata` | `PRO_RATA` | +| `"full-24-hours"` | `Full24Hours` | `FULL_24_HOURS` | +| `"activity_added"` | `ActivityAdded` | `ACTIVITY_ADDED` | +| `"no-refunds"` | `NoRefunds` | `NO_REFUNDS` | + +## When to Use Each + +### Use lookup_table! when: +- Lookup values are stored in database +- Values may change at runtime +- Need to query/manage lookup values +- Want audit trail on lookup changes + +### Use lookup_options! when: +- Codes are compile-time constants +- No database table needed +- Codes are embedded in other entities +- Values won't change without code deployment + +## Real-World Examples + +### Status Workflows +```rust +lookup_table!(TaskStatus, + "todo", + "in-progress", + "review", + "done", + "archived" +); + +impl Task { + pub fn can_transition(&self, to: TaskStatusCode) -> bool { + match (TaskStatusCode::try_from(self.status.as_str()), to) { + (Ok(TaskStatusCode::Todo), TaskStatusCode::InProgress) => true, + (Ok(TaskStatusCode::InProgress), TaskStatusCode::Review) => true, + (Ok(TaskStatusCode::Review), TaskStatusCode::Done) => true, + (Ok(TaskStatusCode::Review), TaskStatusCode::InProgress) => true, + (Ok(_), TaskStatusCode::Archived) => true, + _ => false, + } + } +} +``` + +### Configuration Options +```rust +lookup_options!(LogLevel, "debug", "info", "warn", "error"); +lookup_options!(Theme, "light", "dark", "system"); +lookup_options!(Language, "en", "es", "fr", "de", "ja"); + +#[derive(Entity)] +struct UserPreferences { + #[primary_key] + user_id: Uuid, + log_level: String, + theme: String, + language: String, +} + +// Usage +let prefs = UserPreferences { + user_id: user_id, + log_level: LogLevel::INFO.into(), + theme: Theme::DARK.into(), + language: Language::EN.into(), +}; +``` + +### Domain-Specific Types +```rust +lookup_options!(FastingPattern, + "time-restricted", + "omad", + "alternate-day", + "five-two", + "extended", + "custom" +); + +lookup_options!(ProgramType, + "self-paced", + "live", + "challenge", + "maintenance" +); + +lookup_options!(SubscriptionTier, + "free", + "basic", + "premium", + "unlimited" +); +``` + +## Error Handling + +```rust +// TryFrom returns Result +match UserStatusCode::try_from(unknown_string) { + Ok(code) => handle_status(code), + Err(msg) => { + // msg: "Unknown userstatus code: invalid_value" + log::warn!("{}", msg); + handle_unknown_status() + } +} + +// Or use unwrap_or for default +let code = UserStatusCode::try_from(status_str) + .unwrap_or(UserStatusCode::Pending); +``` diff --git a/.claude/skills/sqlx-record.md b/.claude/skills/sqlx-record.md new file mode 100644 index 0000000..c551070 --- /dev/null +++ b/.claude/skills/sqlx-record.md @@ -0,0 +1,165 @@ +# sqlx-record Skill + +Expert guidance for using the sqlx-record Rust library. + +## Triggers +- "create entity", "define entity", "entity struct" +- "sqlx record", "sqlx-record" +- "crud operations", "database entity" +- "audit trail", "change tracking" +- "lookup table", "lookup options" + +## Overview + +sqlx-record provides derive macros for automatic CRUD operations and audit trails for SQL entities. It supports MySQL, PostgreSQL, and SQLite. + +## Quick Reference + +### Entity Definition +```rust +use sqlx_record::prelude::*; +use sqlx::FromRow; + +#[derive(Entity, FromRow)] +#[table_name = "users"] +struct User { + #[primary_key] + id: Uuid, + + #[rename("user_name")] // Maps to different DB column + name: String, + + #[version] // Auto-increment on updates + version: u32, + + #[field_type("TEXT")] // SQLx type hint + bio: Option, +} +``` + +### CRUD Operations +```rust +// Insert +let user = User { id: new_uuid(), name: "Alice".into(), version: 0 }; +user.insert(&pool).await?; + +// Get +let user = User::get_by_id(&pool, &id).await?; +let users = User::get_by_ids(&pool, &ids).await?; + +// Find with filters +let users = User::find(&pool, filters![("is_active", true)], None).await?; +let user = User::find_one(&pool, filters![("email", email)], None).await?; + +// Find with ordering and pagination +let page = User::find_ordered_with_limit( + &pool, + filters![("role", "admin")], + None, + vec![("created_at", false)], // DESC + Some((0, 10)) // offset, limit +).await?; + +// Count +let count = User::count(&pool, filters![("is_active", true)], None).await?; + +// Update +User::update_by_id(&pool, &id, User::update_form().with_name("Bob")).await?; +``` + +### Filter System +```rust +// Simple equality +filters![("field", value)] + +// Multiple conditions (AND) +filters![("active", true), ("role", "admin")] + +// OR conditions +filter_or![("status", "active"), ("status", "pending")] + +// Operators +"age".gt(18) // > +"age".ge(18) // >= +"age".lt(65) // < +"age".le(65) // <= +"name".eq("Bob") // = +"name".ne("Bob") // != + +// Other filters +Filter::Like("name", "%alice%".into()) +Filter::In("status", vec!["active".into(), "pending".into()]) +Filter::IsNull("deleted_at") +Filter::IsNotNull("email") +``` + +### Lookup Tables +```rust +// With database entity +lookup_table!(OrderStatus, "pending", "shipped", "delivered"); +// Generates: struct OrderStatus, enum OrderStatusCode, constants + +// Without database entity +lookup_options!(PaymentMethod, "credit-card", "paypal", "bank-transfer"); +// Generates: enum PaymentMethodCode, struct PaymentMethod (constants only) + +// Usage +let status = OrderStatus::PENDING; // "pending" +let code = OrderStatusCode::try_from("pending")?; // OrderStatusCode::Pending +``` + +### Time-Ordered UUIDs +```rust +let id = new_uuid(); // Timestamp prefix for better indexing +``` + +## Feature Flags + +```toml +[dependencies] +sqlx-record = { version = "0.2", features = ["mysql", "derive"] } +# Database: "mysql", "postgres", or "sqlite" (pick one) +# Optional: "derive", "static-validation" +``` + +## Advanced Updates (UpdateExpr) + +```rust +use sqlx_record::prelude::UpdateExpr; + +// Arithmetic: score = score + 10 +User::update_by_id(&pool, &id, + User::update_form().eval_score(UpdateExpr::Add(10.into())) +).await?; + +// CASE/WHEN +User::update_by_id(&pool, &id, + User::update_form().eval_tier(UpdateExpr::Case { + branches: vec![("score".gt(100), "gold".into())], + default: "bronze".into(), + }) +).await?; + +// Raw SQL escape hatch +User::update_by_id(&pool, &id, + User::update_form().raw("computed", "COALESCE(a, 0) + b") +).await?; +``` + +## ConnProvider (Flexible Connections) + +```rust +use sqlx_record::ConnProvider; + +// Borrowed or owned pool connections +let conn = ConnProvider::Borrowed(&pool); +let users = User::find(&*conn, filters![], None).await?; +``` + +## Database Differences + +| Feature | MySQL | PostgreSQL | SQLite | +|---------|-------|------------|--------| +| Placeholder | `?` | `$1, $2` | `?` | +| Table quote | `` ` `` | `"` | `"` | +| Index hints | Supported | N/A | N/A | diff --git a/.claude/skills/sqlx-update-expr.md b/.claude/skills/sqlx-update-expr.md new file mode 100644 index 0000000..ea72171 --- /dev/null +++ b/.claude/skills/sqlx-update-expr.md @@ -0,0 +1,242 @@ +# sqlx-record UpdateExpr Skill + +Guide to advanced update operations with eval_* methods. + +## Triggers +- "update expression", "update expr" +- "increment field", "decrement field" +- "case when update", "conditional update" +- "arithmetic update", "column arithmetic" + +## Overview + +`UpdateExpr` enables complex update operations beyond simple value assignment: +- Column arithmetic (`count = count + 1`) +- CASE/WHEN conditional updates +- Conditional increments/decrements +- Raw SQL escape hatch + +## UpdateExpr Enum + +```rust +pub enum UpdateExpr { + Set(Value), // column = value + Add(Value), // column = column + value + Sub(Value), // column = column - value + Mul(Value), // column = column * value + Div(Value), // column = column / value + Mod(Value), // column = column % value + Case { + branches: Vec<(Filter<'static>, Value)>, + default: Value, + }, + AddIf { condition: Filter<'static>, value: Value }, + SubIf { condition: Filter<'static>, value: Value }, + Coalesce(Value), // COALESCE(column, value) + Greatest(Value), // GREATEST(column, value) + Least(Value), // LEAST(column, value) + Raw { sql: String, values: Vec }, +} +``` + +## Generated Methods + +For each non-binary field, an `eval_{field}` method is generated: + +```rust +// Generated for: count: i32 +pub fn eval_count(mut self, expr: UpdateExpr) -> Self + +// Generated for: score: i64 +pub fn eval_score(mut self, expr: UpdateExpr) -> Self + +// Generated for: status: String +pub fn eval_status(mut self, expr: UpdateExpr) -> Self +``` + +Binary fields (`Vec`) do not get `eval_*` methods. + +## Precedence + +`eval_*` methods take precedence over `with_*` if both are set for the same field: + +```rust +let form = User::update_form() + .with_count(100) // This is ignored + .eval_count(UpdateExpr::Add(1.into())); // This is used +``` + +## Usage Examples + +### Simple Arithmetic +```rust +// Increment +let form = User::update_form() + .eval_count(UpdateExpr::Add(1.into())); // count = count + 1 + +// Decrement +let form = User::update_form() + .eval_balance(UpdateExpr::Sub(50.into())); // balance = balance - 50 + +// Multiply +let form = User::update_form() + .eval_score(UpdateExpr::Mul(2.into())); // score = score * 2 +``` + +### CASE/WHEN Conditional +```rust +// Update status based on score +let form = User::update_form() + .eval_tier(UpdateExpr::Case { + branches: vec![ + ("score".gt(1000), "platinum".into()), + ("score".gt(500), "gold".into()), + ("score".gt(100), "silver".into()), + ], + default: "bronze".into(), + }); +// SQL: tier = CASE +// WHEN score > ? THEN ? +// WHEN score > ? THEN ? +// WHEN score > ? THEN ? +// ELSE ? END +``` + +### Conditional Increment +```rust +// Add bonus only for premium users +let form = User::update_form() + .eval_balance(UpdateExpr::AddIf { + condition: "is_premium".eq(true), + value: 100.into(), + }); +// SQL: balance = CASE WHEN is_premium = ? THEN balance + ? ELSE balance END +``` + +### Using Filters with Case +```rust +use sqlx_record::prelude::*; + +// Complex condition with AND +let form = User::update_form() + .eval_discount(UpdateExpr::Case { + branches: vec![ + (Filter::And(vec![ + "orders".gt(10), + "is_vip".eq(true), + ]), 20.into()), + ("orders".gt(5), 10.into()), + ], + default: 0.into(), + }); +``` + +### Coalesce (NULL handling) +```rust +// Set to value if NULL +let form = User::update_form() + .eval_nickname(UpdateExpr::Coalesce("Anonymous".into())); +// SQL: nickname = COALESCE(nickname, ?) +``` + +### Greatest/Least +```rust +// Ensure minimum value (clamp) +let form = User::update_form() + .eval_balance(UpdateExpr::Greatest(0.into())); // balance = GREATEST(balance, 0) + +// Ensure maximum value (cap) +let form = User::update_form() + .eval_score(UpdateExpr::Least(100.into())); // score = LEAST(score, 100) +``` + +### Raw SQL Escape Hatch +```rust +// Simple expression without parameters +let form = User::update_form() + .raw("computed", "COALESCE(a, 0) + COALESCE(b, 0)"); + +// Expression with bind parameters +let form = User::update_form() + .raw_with_values("adjusted", "ROUND(price * ? * (1 - discount / 100))", values![1.1]); + +// Multiple placeholders +let form = User::update_form() + .raw_with_values("stats", "JSON_SET(stats, '$.views', JSON_EXTRACT(stats, '$.views') + ?)", values![1]); +``` + +## Combining with Simple Updates + +```rust +let form = User::update_form() + .with_name("Alice") // Simple value update + .with_email("alice@example.com") // Simple value update + .eval_login_count(UpdateExpr::Add(1.into())) // Arithmetic + .eval_last_login(UpdateExpr::Set( // Expression set + Value::NaiveDateTime(Utc::now().naive_utc()) + )); + +User::update_by_id(&pool, &user_id, form).await?; +``` + +## Full Update Flow + +```rust +use sqlx_record::prelude::*; + +#[derive(Entity, FromRow)] +#[table_name = "game_scores"] +struct GameScore { + #[primary_key] + id: Uuid, + player_id: Uuid, + score: i64, + high_score: i64, + games_played: i32, + tier: String, +} + +async fn record_game(pool: &Pool, id: &Uuid, new_score: i64) -> Result<(), Error> { + let form = GameScore::update_form() + // Increment games played + .eval_games_played(UpdateExpr::Add(1.into())) + // Update high score if this score is higher + .eval_high_score(UpdateExpr::Greatest(new_score.into())) + // Set current score + .with_score(new_score) + // Update tier based on high score + .eval_tier(UpdateExpr::Case { + branches: vec![ + ("high_score".gt(10000), "master".into()), + ("high_score".gt(5000), "expert".into()), + ("high_score".gt(1000), "advanced".into()), + ], + default: "beginner".into(), + }); + + GameScore::update_by_id(pool, id, form).await +} +``` + +## SQL Generation + +The `update_stmt_with_values()` method generates SQL and collects bind values: + +```rust +let form = User::update_form() + .with_name("Alice") + .eval_count(UpdateExpr::Add(5.into())); + +let (sql, values) = form.update_stmt_with_values(); +// sql: "name = ?, count = count + ?" +// values: [Value::String("Alice"), Value::Int32(5)] +``` + +## Database Compatibility + +All UpdateExpr variants generate standard SQL that works across: +- MySQL +- PostgreSQL +- SQLite + +Note: `Greatest` and `Least` use SQL functions that are available in all three databases. diff --git a/.claude/skills/sqlx-values.md b/.claude/skills/sqlx-values.md new file mode 100644 index 0000000..ae921e2 --- /dev/null +++ b/.claude/skills/sqlx-values.md @@ -0,0 +1,257 @@ +# sqlx-record Values Skill + +Guide to Value types and database binding. + +## Triggers +- "value type", "sql value" +- "bind value", "query parameter" +- "type conversion" + +## Value Enum + +```rust +pub enum Value { + // Integers + Int8(i8), + Uint8(u8), + Int16(i16), + Uint16(u16), + Int32(i32), + Uint32(u32), + Int64(i64), + Uint64(u64), + + // Other primitives + String(String), + Bool(bool), + VecU8(Vec), + + // Special types + Uuid(uuid::Uuid), + NaiveDate(NaiveDate), + NaiveDateTime(NaiveDateTime), +} +``` + +## Auto-Conversions (From trait) + +```rust +// Strings +Value::from("hello") // String +Value::from("hello".to_string()) // String + +// Integers +Value::from(42i32) // Int32 +Value::from(42i64) // Int64 +Value::from(&42i32) // Int32 (from reference) +Value::from(&42i64) // Int64 (from reference) + +// Boolean +Value::from(true) // Bool +Value::from(&true) // Bool (from reference) + +// UUID +Value::from(uuid::Uuid::new_v4()) // Uuid +Value::from(&some_uuid) // Uuid (from reference) + +// Dates +Value::from(NaiveDate::from_ymd(2024, 1, 15)) // NaiveDate +Value::from(NaiveDateTime::new(...)) // NaiveDateTime +``` + +## values! Macro + +```rust +// Empty +values![] + +// Single value (auto-converts) +values!["hello"] +values![42] +values![true] + +// Multiple values +values!["name", 30, true, uuid] + +// With explicit types +values![ + Value::String("test".into()), + Value::Int32(42), + Value::Bool(true) +] +``` + +## Database-Specific Handling + +### Unsigned Integers + +| Type | MySQL | PostgreSQL | SQLite | +|------|-------|------------|--------| +| Uint8 | Native u8 | Cast to i16 | Cast to i16 | +| Uint16 | Native u16 | Cast to i32 | Cast to i32 | +| Uint32 | Native u32 | Cast to i64 | Cast to i64 | +| Uint64 | Native u64 | Cast to i64* | Cast to i64* | + +*Note: Uint64 values > i64::MAX will overflow when cast. + +### UUID Storage + +| Database | Type | Notes | +|----------|------|-------| +| MySQL | BINARY(16) | Stored as bytes | +| PostgreSQL | UUID | Native type | +| SQLite | BLOB | Stored as bytes | + +### JSON Storage + +| Database | Type | +|----------|------| +| MySQL | JSON | +| PostgreSQL | JSONB | +| SQLite | TEXT | + +## Bind Functions + +### bind_values +Bind values to a raw Query: +```rust +use sqlx_record::prelude::*; + +let query = sqlx::query("SELECT * FROM users WHERE name = ? AND age > ?"); +let query = bind_values(query, &values!["Alice", 18]); +let rows = query.fetch_all(&pool).await?; +``` + +### bind_as_values +Bind values to a typed QueryAs: +```rust +let query = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?"); +let query = bind_as_values(query, &values![user_id]); +let user = query.fetch_optional(&pool).await?; +``` + +### bind_scalar_values +Bind values to a scalar QueryScalar: +```rust +let query = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE active = ?"); +let query = bind_scalar_values(query, &values![true]); +let count: i64 = query.fetch_one(&pool).await?; +``` + +## BindValues Trait + +Extension trait for fluent binding: +```rust +use sqlx_record::prelude::BindValues; + +let users = sqlx::query_as::<_, User>("SELECT * FROM users WHERE role = ?") + .bind_values(&values!["admin"]) + .fetch_all(&pool) + .await?; +``` + +## Placeholder Function + +Database-specific placeholder generation: +```rust +use sqlx_record::prelude::placeholder; + +let ph1 = placeholder(1); +let ph2 = placeholder(2); + +// MySQL/SQLite: "?", "?" +// PostgreSQL: "$1", "$2" + +let sql = format!("SELECT * FROM users WHERE id = {} AND role = {}", ph1, ph2); +``` + +## With Filters + +Filters automatically use Value internally: +```rust +// These are equivalent: +filters![("name", "Alice")] +filters![("name", Value::String("Alice".into()))] + +// Values are extracted for binding: +let (where_clause, values) = Filter::build_where_clause(&filters); +// values: Vec +``` + +## Custom Queries with Values + +```rust +use sqlx_record::prelude::*; + +async fn complex_query(pool: &Pool, status: &str, min_age: i32) -> Result> { + let values = values![status, min_age]; + + let sql = format!( + "SELECT * FROM users WHERE status = {} AND age >= {} ORDER BY name", + placeholder(1), + placeholder(2) + ); + + let query = sqlx::query_as::<_, User>(&sql); + let query = bind_as_values(query, &values); + + query.fetch_all(pool).await +} +``` + +## Updater Enum + +For more complex update patterns: +```rust +pub enum Updater<'a> { + Set(&'a str, Value), // SET field = value + Increment(&'a str, Value), // SET field = field + value + Decrement(&'a str, Value), // SET field = field - value +} +``` + +## Type Helpers + +### query_fields +Extract field names from aliased field list: +```rust +let fields = vec!["id", "name as user_name", "email"]; +let result = query_fields(fields); +// "id, name, email" +``` + +## Common Patterns + +### Dynamic WHERE Clause +```rust +fn build_query(filters: &[(&str, Value)]) -> (String, Vec) { + let mut conditions = Vec::new(); + let mut values = Vec::new(); + + for (i, (field, value)) in filters.iter().enumerate() { + conditions.push(format!("{} = {}", field, placeholder(i + 1))); + values.push(value.clone()); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + (where_clause, values) +} +``` + +### Batch Insert Values +```rust +fn batch_values(users: &[User]) -> Vec { + users.iter().flat_map(|u| { + vec![ + Value::Uuid(u.id), + Value::String(u.name.clone()), + Value::String(u.email.clone()), + ] + }).collect() +} +``` diff --git a/CLAUDE.md b/CLAUDE.md index 8385a89..45e8a52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code when working with this repository. ``` sqlx-record/ ├── src/ # Core library -│ ├── lib.rs # Public API exports and prelude +│ ├── lib.rs # Public API exports, prelude, lookup macros, new_uuid │ ├── models.rs # EntityChange struct, Action enum │ ├── repositories.rs # Database query functions for entity changes │ ├── value.rs # Type-safe Value enum, bind functions @@ -26,6 +26,15 @@ sqlx-record/ │ └── string_utils.rs # Pluralization helpers ├── sqlx-record-ctl/ # CLI tool for audit table management │ └── src/main.rs +├── mcp/ # MCP server for documentation/code generation +│ └── src/main.rs # sqlx-record-expert executable +├── .claude/skills/ # Claude Code skills documentation +│ ├── sqlx-record.md # Overview and quick reference +│ ├── sqlx-entity.md # #[derive(Entity)] detailed guide +│ ├── sqlx-filters.md # Filter system guide +│ ├── sqlx-audit.md # Audit trail guide +│ ├── sqlx-lookup.md # Lookup tables guide +│ └── sqlx-values.md # Value types guide └── Cargo.toml # Workspace root ``` @@ -50,6 +59,9 @@ cargo build --features "mysql,derive" # Build CLI tool cargo build -p sqlx-record-ctl --features mysql +# Build MCP server +cargo build -p sqlx-record-mcp + # Test cargo test --features mysql @@ -57,6 +69,125 @@ cargo test --features mysql make tag ``` +## MCP Server (sqlx-record-expert) + +The `mcp/` directory contains an MCP server providing: + +**Tools:** +- `generate_entity` - Generate Entity struct code +- `generate_filter` - Generate filter expressions +- `generate_lookup` - Generate lookup_table!/lookup_options! code +- `explain_feature` - Get detailed documentation + +**Resources:** +- `sqlx-record://docs/overview` - Library overview +- `sqlx-record://docs/derive` - Derive macro docs +- `sqlx-record://docs/filters` - Filter system docs +- `sqlx-record://docs/values` - Value types docs +- `sqlx-record://docs/lookup` - Lookup tables docs +- `sqlx-record://docs/audit` - Audit trail docs +- `sqlx-record://docs/examples` - Complete examples + +**Usage:** +```bash +cargo build -p sqlx-record-mcp +./target/debug/sqlx-record-expert +``` + +## Lookup Macros + +### lookup_table! +Creates database-backed lookup entity with code enum: +```rust +lookup_table!(OrderStatus, "pending", "shipped", "delivered"); +// Generates: struct OrderStatus, enum OrderStatusCode, constants +``` + +### lookup_options! +Creates code enum without database entity: +```rust +lookup_options!(PaymentMethod, "credit-card", "paypal", "bank-transfer"); +// Generates: enum PaymentMethodCode, struct PaymentMethod (constants only) +``` + +## Time-Ordered UUIDs + +```rust +use sqlx_record::new_uuid; +let id = new_uuid(); // Timestamp prefix (8 bytes) + random (8 bytes) +``` + +## Connection Provider + +Flexible connection management - borrow existing or lazily acquire from pool: + +```rust +use sqlx_record::prelude::ConnProvider; + +// From borrowed connection +let mut provider = ConnProvider::from_ref(&mut conn); + +// From pool (lazy acquisition) +let mut provider = ConnProvider::from_pool(pool.clone()); + +// Get connection (acquires on first call for Owned variant) +let conn = provider.get_conn().await?; +``` + +## UpdateExpr - Advanced Update Operations + +Beyond simple value updates, use `eval_*` methods for arithmetic and conditional updates: + +```rust +use sqlx_record::prelude::*; + +// Arithmetic operations +let form = User::update_form() + .with_name("Alice") // Simple value + .eval_count(UpdateExpr::Add(1.into())) // count = count + 1 + .eval_score(UpdateExpr::Sub(10.into())); // score = score - 10 + +// CASE/WHEN conditional +let form = User::update_form() + .eval_status(UpdateExpr::Case { + branches: vec![ + ("score".gt(100), "vip".into()), + ("score".gt(50), "premium".into()), + ], + default: "standard".into(), + }); + +// Conditional increment +let form = User::update_form() + .eval_balance(UpdateExpr::AddIf { + condition: "is_premium".eq(true), + value: 100.into(), + }); + +// Raw SQL escape hatch +let form = User::update_form() + .raw("computed", "COALESCE(a, 0) + COALESCE(b, 0)") + .raw_with_values("adjusted", "value * ? + ?", values![1.5, 10]); +``` + +### UpdateExpr Variants + +| Variant | SQL Generated | +|---------|--------------| +| `Set(value)` | `column = ?` | +| `Add(value)` | `column = column + ?` | +| `Sub(value)` | `column = column - ?` | +| `Mul(value)` | `column = column * ?` | +| `Div(value)` | `column = column / ?` | +| `Mod(value)` | `column = column % ?` | +| `Case { branches, default }` | `column = CASE WHEN ... THEN ... ELSE ... END` | +| `AddIf { condition, value }` | `column = CASE WHEN cond THEN column + ? ELSE column END` | +| `SubIf { condition, value }` | `column = CASE WHEN cond THEN column - ? ELSE column END` | +| `Coalesce(value)` | `column = COALESCE(column, ?)` | +| `Greatest(value)` | `column = GREATEST(column, ?)` | +| `Least(value)` | `column = LEAST(column, ?)` | +| `Raw { sql, values }` | `column = {sql}` (escape hatch) | + ## Derive Macro API ### Entity Attributes @@ -158,6 +289,10 @@ Filter::Or(vec![filters]) | JSON type | `JSON` | `JSONB` | `TEXT` | | ILIKE | `LOWER() LIKE LOWER()` | Native | `LOWER() LIKE LOWER()` | | Index hints | `USE INDEX()` | N/A | N/A | +| Unsigned ints | Native | Cast to signed | Cast to signed | + +**Unsigned integer conversion (PostgreSQL/SQLite):** +- `u8` → `i16`, `u16` → `i32`, `u32` → `i64`, `u64` → `i64` ## Value Types diff --git a/Cargo.toml b/Cargo.toml index d36118d..d6e0ee2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-record" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Entity CRUD and change tracking for SQL databases with SQLx" @@ -8,12 +8,16 @@ description = "Entity CRUD and change tracking for SQL databases with SQLx" sqlx-record-derive = { path = "sqlx-record-derive", optional = true } sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "json"] } serde_json = "1.0" -uuid = { version = "1", features = ["v4"]} +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +rand = "0.8" +paste = "1.0" [workspace] members = [ "sqlx-record-derive", - "sqlx-record-ctl" + "sqlx-record-ctl", + "mcp" ] [features] diff --git a/mcp/Cargo.toml b/mcp/Cargo.toml new file mode 100644 index 0000000..800f405 --- /dev/null +++ b/mcp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "sqlx-record-mcp" +version = "0.2.0" +edition = "2021" +description = "MCP server providing sqlx-record documentation and code generation" + +[[bin]] +name = "sqlx-record-expert" +path = "src/main.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["full"] } diff --git a/mcp/src/main.rs b/mcp/src/main.rs new file mode 100644 index 0000000..9cb829d --- /dev/null +++ b/mcp/src/main.rs @@ -0,0 +1,1724 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::io::{self, BufRead, Write}; + +// ============================================================================ +// MCP Protocol Types +// ============================================================================ + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: String, + id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i32, + message: String, +} + +// ============================================================================ +// Documentation Content +// ============================================================================ + +const OVERVIEW: &str = r#"# sqlx-record v0.2.0 + +A Rust library providing derive macros for automatic CRUD operations and comprehensive audit trails for SQL entities. Supports MySQL, PostgreSQL, and SQLite via SQLx. + +## Features + +- **Derive Macros**: `#[derive(Entity)]` generates 40+ methods for CRUD operations +- **Multi-Database**: MySQL, PostgreSQL, SQLite with unified API +- **Audit Trails**: Track who changed what, when, and why +- **Type-Safe Filters**: Composable query building with `Filter` enum +- **UpdateExpr**: Advanced updates with arithmetic, CASE/WHEN, conditionals +- **Lookup Tables**: Macros for code/enum generation +- **ConnProvider**: Flexible connection management (borrowed or pooled) +- **Time-Ordered UUIDs**: Better database indexing + +## Quick Start + +```rust +use sqlx_record::prelude::*; + +#[derive(Entity, FromRow)] +#[table_name = "users"] +struct User { + #[primary_key] + id: Uuid, + name: String, + email: String, + score: i32, + #[version] + version: u32, +} + +// Insert +let user = User { id: new_uuid(), name: "Alice".into(), email: "alice@example.com".into(), score: 0, version: 0 }; +user.insert(&pool).await?; + +// Find +let users = User::find(&pool, filters![("name", "Alice")], None).await?; + +// Simple update +User::update_by_id(&pool, &id, User::update_form().with_name("Bob")).await?; + +// Advanced update with expressions +User::update_by_id(&pool, &id, User::update_form() + .eval_score(UpdateExpr::Add(10.into())) // score = score + 10 +).await?; +``` +"#; + +const DERIVE_ENTITY: &str = r#"# #[derive(Entity)] Macro + +Generates comprehensive CRUD methods for your struct. + +## Struct Attributes + +| Attribute | Example | Description | +|-----------|---------|-------------| +| `#[table_name]` | `#[table_name = "users"]` | Database table name (defaults to snake_case struct name) | + +## Field Attributes + +| Attribute | Example | Description | +|-----------|---------|-------------| +| `#[primary_key]` | `#[primary_key]` | Marks primary key field (required) | +| `#[rename]` | `#[rename("user_name")]` | Maps to different DB column | +| `#[version]` | `#[version]` | Auto-increment version field | +| `#[field_type]` | `#[field_type("BIGINT")]` | SQLx type hint | + +## Example + +```rust +#[derive(Entity, FromRow)] +#[table_name = "products"] +struct Product { + #[primary_key] + id: Uuid, + + #[rename("product_name")] + name: String, + + price: i64, + + #[version] + version: u32, + + #[field_type("TEXT")] + description: Option, +} +``` + +## Generated Methods + +### Insert +```rust +pub async fn insert(&self, executor: E) -> Result +``` + +### Get +```rust +pub async fn get_by_id(executor, id: &Uuid) -> Result, Error> +pub async fn get_by_ids(executor, ids: &[Uuid]) -> Result, Error> +pub async fn get_by_primary_key(executor, pk: &PkType) -> Result, Error> +``` + +### Find +```rust +pub async fn find(executor, filters: Vec, index: Option<&str>) -> Result, Error> +pub async fn find_one(executor, filters: Vec, index: Option<&str>) -> Result, Error> +pub async fn find_ordered(executor, filters, index, order_by: Vec<(&str, bool)>) -> Result, Error> +pub async fn find_ordered_with_limit(executor, filters, index, order_by, offset_limit: Option<(u32, u32)>) -> Result, Error> +pub async fn count(executor, filters: Vec, index: Option<&str>) -> Result +``` + +### Update +```rust +pub async fn update(&self, executor, form: UpdateForm) -> Result<(), Error> +pub async fn update_by_id(executor, id: &Uuid, form: UpdateForm) -> Result<(), Error> +pub async fn update_by_ids(executor, ids: &[Uuid], form: UpdateForm) -> Result<(), Error> +pub fn update_form() -> UpdateForm +``` + +### Diff (Change Detection) +```rust +pub fn model_diff(form: &UpdateForm, model: &Self) -> serde_json::Value +pub async fn db_diff(form: &UpdateForm, pk: &PkType, executor) -> Result +pub fn diff_modify(form: &mut UpdateForm, model: &Self) -> serde_json::Value +pub fn to_update_form(&self) -> UpdateForm +pub fn initial_diff(&self) -> serde_json::Value +``` + +### Version (if #[version] field exists) +```rust +pub async fn get_version(executor, pk: &PkType) -> Result, Error> +pub async fn get_versions(executor, pks: &[PkType]) -> Result, Error> +``` + +### Metadata +```rust +pub const fn table_name() -> &'static str +pub fn entity_key(pk: &PkType) -> String // "/entities/{table}/{pk}" +pub fn entity_changes_table_name() -> String // "entity_changes_{table}" +pub const fn primary_key_field() -> &'static str +pub const fn primary_key_db_field() -> &'static str +pub fn primary_key(&self) -> &PkType +pub fn select_fields() -> Vec<&'static str> +``` +"#; + +const FILTERS: &str = r#"# Filter System + +Type-safe query building with composable filters. + +## Filter Enum Variants + +```rust +Filter::Equal(&str, Value) // field = value +Filter::NotEqual(&str, Value) // field != value +Filter::GreaterThan(&str, Value) // field > value +Filter::GreaterThanOrEqual(&str, Value) // field >= value +Filter::LessThan(&str, Value) // field < value +Filter::LessThanOrEqual(&str, Value) // field <= value +Filter::Like(&str, Value) // field LIKE value +Filter::ILike(&str, Value) // case-insensitive LIKE +Filter::NotLike(&str, Value) // field NOT LIKE value +Filter::In(&str, Vec) // field IN (...) +Filter::NotIn(&str, Vec) // field NOT IN (...) +Filter::IsNull(&str) // field IS NULL +Filter::IsNotNull(&str) // field IS NOT NULL +Filter::And(Vec) // nested AND +Filter::Or(Vec) // nested OR +``` + +## Macros + +```rust +// Simple equality filters +filters![("active", true), ("role", "admin")] + +// OR conditions +filter_or![("status", "active"), ("status", "pending")] + +// AND conditions +filter_and![("age", 18), ("verified", true)] + +// Empty filter vec +filters![] +``` + +## Operator Trait (FilterOps) + +```rust +"field".eq(value) // Filter::Equal +"field".ne(value) // Filter::NotEqual +"field".gt(value) // Filter::GreaterThan +"field".ge(value) // Filter::GreaterThanOrEqual +"field".lt(value) // Filter::LessThan +"field".le(value) // Filter::LessThanOrEqual +``` + +## Examples + +```rust +use sqlx_record::prelude::*; + +// Find active users +let users = User::find(&pool, filters![("is_active", true)], None).await?; + +// Find users with age > 18 OR verified +let users = User::find(&pool, vec![ + filter_or![("age".gt(18)), ("verified", true)] +], None).await?; + +// Pagination with ordering +let users = User::find_ordered_with_limit( + &pool, + filters![("role", "admin")], + None, + vec![("created_at", false)], // DESC + Some((0, 10)) // OFFSET 0, LIMIT 10 +).await?; + +// Count matching records +let count = User::count(&pool, filters![("is_active", true)], None).await?; +``` + +## Building WHERE Clauses Manually + +```rust +let filters = filters![("status", "active"), ("role", "admin")]; +let (where_clause, values) = Filter::build_where_clause(&filters); +// where_clause: "status = ? AND role = ?" (MySQL/SQLite) +// where_clause: "status = $1 AND role = $2" (PostgreSQL) +``` +"#; + +const VALUE_TYPES: &str = r#"# Value Types + +Type-safe SQL value binding across all database backends. + +## Value Enum + +```rust +pub enum Value { + Int8(i8), + Uint8(u8), + Int16(i16), + Uint16(u16), + Int32(i32), + Uint32(u32), + Int64(i64), + Uint64(u64), + VecU8(Vec), + String(String), + Bool(bool), + Uuid(uuid::Uuid), + NaiveDate(NaiveDate), + NaiveDateTime(NaiveDateTime), +} +``` + +## Auto-Conversions (From trait) + +```rust +Value::from("string") // String +Value::from(42i32) // Int32 +Value::from(42i64) // Int64 +Value::from(true) // Bool +Value::from(uuid::Uuid::new_v4()) // Uuid +Value::from(NaiveDate::from_ymd(2024, 1, 1)) // NaiveDate +``` + +## values! Macro + +```rust +values![] // Empty vec +values!("single") // Single value +values!("a", 1, true) // Multiple values +``` + +## Database-Specific Handling + +| Type | MySQL | PostgreSQL | SQLite | +|------|-------|------------|--------| +| Uint8 | Native | Cast to i16 | Cast to i16 | +| Uint16 | Native | Cast to i32 | Cast to i32 | +| Uint32 | Native | Cast to i64 | Cast to i64 | +| Uint64 | Native | Cast to i64 | Cast to i64 | + +## Bind Functions + +```rust +// Bind values to Query +bind_values(query, &values) -> Query + +// Bind values to QueryAs (typed) +bind_as_values(query, &values) -> QueryAs + +// Bind values to QueryScalar +bind_scalar_values(query, &values) -> QueryScalar + +// Trait-based binding +query.bind_values(&values) +``` +"#; + +const LOOKUP_TABLES: &str = r#"# Lookup Tables & Options + +Macros for creating type-safe lookup tables and code enums. + +## lookup_table! Macro + +Creates a full entity struct with code enum for database-backed lookup tables. + +```rust +lookup_table!(UserStatus, "active", "inactive", "suspended"); +``` + +### Generated Code + +```rust +// Entity struct with CRUD via #[derive(Entity)] +#[derive(Entity, FromRow)] +pub struct UserStatus { + #[primary_key] + pub code: String, + pub name: String, + pub description: String, + pub is_active: bool, +} + +// Type-safe enum +pub enum UserStatusCode { + Active, + Inactive, + Suspended, +} + +// String constants +impl UserStatus { + pub const ACTIVE: &'static str = "active"; + pub const INACTIVE: &'static str = "inactive"; + pub const SUSPENDED: &'static str = "suspended"; +} + +// Display trait (enum -> string) +impl Display for UserStatusCode { ... } + +// TryFrom trait (string -> enum) +impl TryFrom<&str> for UserStatusCode { ... } +``` + +### Usage + +```rust +// Use constant +let status = UserStatus::ACTIVE; + +// Use enum +let code = UserStatusCode::Active; +println!("{}", code); // "active" + +// Parse string to enum +let code = UserStatusCode::try_from("active")?; + +// Query the lookup table +let statuses = UserStatus::find(&pool, filters![("is_active", true)], None).await?; +``` + +## lookup_options! Macro + +Creates code enum without entity struct (for non-database-backed options). + +```rust +lookup_options!(RefundPolicy, "pro-rata", "full-24-hours", "no-refunds"); +``` + +### Generated Code + +```rust +// Type-safe enum +pub enum RefundPolicyCode { + ProRata, + Full24Hours, + NoRefunds, +} + +// Unit struct for constants +pub struct RefundPolicy; + +impl RefundPolicy { + pub const PRO_RATA: &'static str = "pro-rata"; + pub const FULL_24_HOURS: &'static str = "full-24-hours"; + pub const NO_REFUNDS: &'static str = "no-refunds"; +} + +// Display and TryFrom implementations +``` + +### Usage + +```rust +// Use constant in queries +let orders = Order::find(&pool, + filters![("refund_policy", RefundPolicy::PRO_RATA)], + None +).await?; + +// Type-safe matching +match RefundPolicyCode::try_from(order.refund_policy.as_str())? { + RefundPolicyCode::ProRata => calculate_pro_rata_refund(), + RefundPolicyCode::Full24Hours => full_refund_if_within_24h(), + RefundPolicyCode::NoRefunds => deny_refund(), +} +``` + +## Code Naming Convention + +Codes are converted using paste's `:camel` modifier: +- `"active"` -> `Active` +- `"pro-rata"` -> `ProRata` +- `"full-24-hours"` -> `Full24Hours` +- `"activity_added"` -> `ActivityAdded` +"#; + +const AUDIT_TRAIL: &str = r#"# Audit Trail System + +Track entity changes with actor, session, and change set context. + +## EntityChange Struct + +```rust +pub struct EntityChange { + pub id: Uuid, // Change record ID + pub entity_id: Uuid, // Target entity ID + pub action: String, // "insert", "update", "delete", "restore", "hard-delete" + pub changed_at: i64, // Timestamp (milliseconds) + pub actor_id: Uuid, // Who made the change + pub session_id: Uuid, // Session context + pub change_set_id: Uuid, // Transaction grouping + pub new_value: Option, // JSON payload of changes +} +``` + +## Action Enum + +```rust +pub enum Action { + Insert, + Update, + Delete, + Restore, + HardDelete, + Unknown(String), +} + +// Convert from string (case-insensitive) +let action = Action::from("insert".to_string()); +``` + +## Repository Functions + +```rust +// Create change record +create_entity_change(executor, table_name, &change).await?; + +// Query by change ID +get_entity_changes_by_id(executor, table_name, &change_id).await?; + +// Query by entity +get_entity_changes_by_entity(executor, table_name, &entity_id).await?; + +// Query by session +get_entity_changes_session(executor, table_name, &session_id).await?; + +// Query by actor +get_entity_changes_actor(executor, table_name, &actor_id).await?; + +// Query by change set (transaction) +get_entity_changes_by_change_set(executor, table_name, &change_set_id).await?; + +// Combined query +get_entity_changes_by_entity_and_actor(executor, table_name, &entity_id, &actor_id).await?; +``` + +## Diff Methods for Change Tracking + +```rust +// Compare form with model instance +let diff = User::model_diff(&form, &existing_user); + +// Compare form with database +let diff = User::db_diff(&form, &user_id, &pool).await?; + +// Get initial state (for inserts) +let diff = new_user.initial_diff(); + +// Modify form to only include changes +let diff = form.diff_modify(&existing_user); +``` + +## Audit Table Schema (via sqlx-record-ctl) + +```sql +-- MySQL +CREATE TABLE entity_changes_users ( + id BINARY(16) PRIMARY KEY, + entity_id BINARY(16) NOT NULL, + action ENUM('insert','update','delete','restore','hard-delete'), + changed_at BIGINT, + actor_id BINARY(16), + session_id BINARY(16), + change_set_id BINARY(16), + new_value JSON +); + +-- Indexes for common queries +CREATE INDEX idx_users_entity_id ON entity_changes_users (entity_id); +CREATE INDEX idx_users_actor_id ON entity_changes_users (actor_id); +CREATE INDEX idx_users_session_id ON entity_changes_users (session_id); +CREATE INDEX idx_users_change_set_id ON entity_changes_users (change_set_id); +CREATE INDEX idx_users_entity_id_actor_id ON entity_changes_users (entity_id, actor_id); +``` +"#; + +const UPDATE_FORM: &str = r#"# UpdateForm Builder + +Generated builder pattern for partial updates. + +## Generated Struct + +For entity `User`, generates `UserUpdateForm`: + +```rust +#[derive(Default)] +pub struct UserUpdateForm { + pub name: Option, + pub email: Option, + pub age: Option, + // ... all non-PK fields as Option +} +``` + +## Builder Methods + +```rust +// Create new form +let form = User::update_form(); + +// Builder pattern (chainable) +let form = User::update_form() + .with_name("Alice") + .with_email("alice@example.com"); + +// Setter methods (mutable) +let mut form = User::update_form(); +form.set_name("Alice"); +form.set_email("alice@example.com"); +``` + +## Executing Updates + +```rust +// Update by primary key +User::update_by_id(&pool, &user_id, form).await?; + +// Update multiple +User::update_by_ids(&pool, &user_ids, form).await?; + +// Update instance +user.update(&pool, form).await?; +``` + +## Partial Updates + +Only fields set in the form are updated: + +```rust +// Only updates name, leaves email unchanged +User::update_by_id(&pool, &id, + User::update_form().with_name("New Name") +).await?; +``` + +## Version Field Auto-Increment + +If entity has `#[version]` field, it auto-increments with wrapping: + +```rust +#[derive(Entity)] +struct User { + #[primary_key] + id: Uuid, + name: String, + #[version] + version: u32, // Auto-increments on every update +} + +// Version increments automatically +User::update_by_id(&pool, &id, User::update_form().with_name("New")).await?; + +// Check current version +let version = User::get_version(&pool, &id).await?; +``` + +## Converting Entity to UpdateForm + +```rust +// Create form with all current values +let form = existing_user.to_update_form(); + +// Modify specific field +let form = form.with_name("Updated Name"); + +// Update +existing_user.update(&pool, form).await?; +``` +"#; + +const DATABASE_DIFFERENCES: &str = r#"# Database Differences + +sqlx-record handles database-specific SQL through conditional compilation. + +## Feature Flags + +```toml +[dependencies] +sqlx-record = { version = "0.1", features = ["mysql", "derive"] } +# or "postgres", "sqlite" +``` + +**Note:** Enable exactly one database feature. + +## Comparison Table + +| Feature | MySQL | PostgreSQL | SQLite | +|---------|-------|------------|--------| +| **Placeholder** | `?` | `$1, $2, ...` | `?` | +| **Table Quote** | `` ` `` | `"` | `"` | +| **UUID Storage** | `BINARY(16)` | Native `UUID` | `BLOB` | +| **JSON Type** | `JSON` | `JSONB` | `TEXT` | +| **ILIKE** | `LOWER() LIKE LOWER()` | Native | `LOWER() LIKE LOWER()` | +| **Index Hints** | `USE INDEX()` | N/A | N/A | +| **Unsigned Ints** | Native | Cast to signed | Cast to signed | + +## Placeholder Function + +```rust +use sqlx_record::prelude::placeholder; + +// Returns "?" for MySQL/SQLite, "$1" for PostgreSQL +let ph = placeholder(1); +``` + +## Index Hints (MySQL only) + +```rust +// MySQL: SELECT ... FROM users USE INDEX(idx_name) WHERE ... +// PostgreSQL/SQLite: Index hint ignored +let users = User::find(&pool, filters, Some("idx_users_email")).await?; +``` + +## COUNT Casting + +```rust +// MySQL: CAST(COUNT(*) AS SIGNED) +// PostgreSQL: COUNT(*)::BIGINT +// SQLite: COUNT(*) +``` + +## Version Field Overflow + +Uses `CASE WHEN` for portable overflow handling: + +```sql +CASE WHEN version = 4294967295 THEN 0 ELSE version + 1 END +``` +"#; + +const NEW_UUID: &str = r#"# Time-Ordered UUIDs + +Generate UUIDs with timestamp prefix for better database indexing. + +## Function + +```rust +use sqlx_record::new_uuid; + +let id = new_uuid(); +``` + +## Structure + +``` +Bytes 0-7: Millisecond timestamp (big-endian) +Bytes 8-15: Random data +``` + +## Benefits + +1. **Sequential inserts**: Timestamps cluster recent IDs together +2. **B-tree friendly**: Reduces page splits in indexes +3. **Sortable**: UUIDs sort chronologically +4. **Unique**: Random suffix prevents collisions + +## Usage + +```rust +use sqlx_record::prelude::*; + +#[derive(Entity)] +struct User { + #[primary_key] + id: Uuid, + name: String, +} + +let user = User { + id: new_uuid(), // Time-ordered UUID + name: "Alice".into(), +}; +user.insert(&pool).await?; +``` + +## Comparison with UUID v4 + +| Aspect | new_uuid() | UUID v4 | +|--------|------------|---------| +| Randomness | 64 bits | 122 bits | +| Sortable | Yes (by time) | No | +| Index performance | Better | Random distribution | +| Collision risk | Very low | Extremely low | +"#; + +const UPDATE_EXPR: &str = r#"# UpdateExpr - Advanced Updates + +Perform column arithmetic, CASE/WHEN, and conditional updates. + +## UpdateExpr Enum + +```rust +pub enum UpdateExpr { + Set(Value), // field = value + Add(Value), // field = field + value + Sub(Value), // field = field - value + Mul(Value), // field = field * value + Div(Value), // field = field / value + Mod(Value), // field = field % value + Case { branches: Vec<(Filter, Value)>, default: Value }, // CASE WHEN + AddIf { condition: Filter, value: Value }, // field = field + value IF condition + SubIf { condition: Filter, value: Value }, // field = field - value IF condition + Coalesce(Value), // COALESCE(field, value) + Greatest(Value), // GREATEST(field, value) + Least(Value), // LEAST(field, value) + Raw { sql: String, values: Vec }, // Custom SQL expression +} +``` + +## Generated Methods + +For each non-binary field, an `eval_*` method is generated: + +```rust +// For a field `score: i32` +fn eval_score(mut self, expr: UpdateExpr) -> Self + +// Usage +User::update_form() + .eval_score(UpdateExpr::Add(10.into())) // score = score + 10 +``` + +## eval_* vs with_* + +- `with_*` methods set field to a literal value +- `eval_*` methods set field using an expression +- If both are set, `eval_*` takes precedence + +```rust +User::update_form() + .with_score(100) // Ignored + .eval_score(UpdateExpr::Add(10.into())) // score = score + 10 +``` + +## Examples + +### Arithmetic Operations + +```rust +// Increment score +User::update_by_id(&pool, &id, + User::update_form().eval_score(UpdateExpr::Add(1.into())) +).await?; + +// Decrement balance +Account::update_by_id(&pool, &id, + Account::update_form().eval_balance(UpdateExpr::Sub(amount.into())) +).await?; + +// Double a value +Product::update_by_id(&pool, &id, + Product::update_form().eval_price(UpdateExpr::Mul(2.into())) +).await?; +``` + +### CASE/WHEN Updates + +```rust +// Status-based scoring +User::update_by_id(&pool, &id, + User::update_form().eval_score(UpdateExpr::Case { + branches: vec![ + (Filter::Equal("status", "gold".into()), 100.into()), + (Filter::Equal("status", "silver".into()), 50.into()), + ], + default: 10.into(), + }) +).await?; +``` + +### Conditional Updates + +```rust +// Add points only if active +User::update_by_id(&pool, &id, + User::update_form().eval_score(UpdateExpr::AddIf { + condition: Filter::Equal("is_active", true.into()), + value: 10.into(), + }) +).await?; +``` + +### Comparison Functions + +```rust +// Ensure minimum value +Account::update_by_id(&pool, &id, + Account::update_form().eval_balance(UpdateExpr::Greatest(0.into())) +).await?; + +// Cap at maximum +User::update_by_id(&pool, &id, + User::update_form().eval_score(UpdateExpr::Least(1000.into())) +).await?; + +// Use fallback for NULL +User::update_by_id(&pool, &id, + User::update_form().eval_nickname(UpdateExpr::Coalesce("Anonymous".into())) +).await?; +``` + +### Raw SQL Escape Hatch + +```rust +// Simple raw expression +User::update_by_id(&pool, &id, + User::update_form().raw("score", "score * 2 + bonus") +).await?; + +// Raw expression with bound values +User::update_by_id(&pool, &id, + User::update_form().raw_with_values( + "score", + &format!("COALESCE(score, {}) + {}", placeholder(1), placeholder(2)), + vec![0.into(), 10.into()] + ) +).await?; +``` + +## Database Compatibility + +UpdateExpr generates standard SQL that works across MySQL, PostgreSQL, and SQLite: +- CASE/WHEN syntax is portable +- COALESCE, GREATEST, LEAST are supported +- Placeholders adapt to database (? vs $1) +"#; + +const CONN_PROVIDER: &str = r#"# ConnProvider - Flexible Connection Management + +Manage borrowed vs pooled database connections uniformly. + +## ConnProvider Enum + +```rust +pub enum ConnProvider<'a> { + Borrowed(&'a Pool), // Reference to existing pool + Owned(Pool), // Owned pool instance +} +``` + +Where `Pool` is the database-specific pool type: +- MySQL: `sqlx::MySqlPool` +- PostgreSQL: `sqlx::PgPool` +- SQLite: `sqlx::SqlitePool` + +## Creating ConnProvider + +```rust +use sqlx_record::ConnProvider; + +// From borrowed pool +let provider = ConnProvider::Borrowed(&pool); + +// From owned pool +let provider = ConnProvider::Owned(pool); +``` + +## Getting Connection + +```rust +// Acquire connection from provider +let conn = provider.acquire().await?; + +// Use connection +let result = sqlx::query("SELECT 1") + .fetch_one(&mut *conn) + .await?; +``` + +## Use Cases + +### Dependency Injection + +```rust +struct UserService<'a> { + conn: ConnProvider<'a>, +} + +impl<'a> UserService<'a> { + pub fn new(conn: ConnProvider<'a>) -> Self { + Self { conn } + } + + pub async fn find_user(&self, id: &Uuid) -> Result, Error> { + User::get_by_id(&*self.conn, id).await + } +} + +// Use with borrowed pool +let service = UserService::new(ConnProvider::Borrowed(&pool)); + +// Or with owned pool +let service = UserService::new(ConnProvider::Owned(pool)); +``` + +### Transaction Support + +```rust +async fn transfer_funds( + conn: ConnProvider<'_>, + from: &Uuid, + to: &Uuid, + amount: i64, +) -> Result<(), Error> { + let mut tx = conn.begin().await?; + + // Debit from account + Account::update_by_id(&mut *tx, from, + Account::update_form().eval_balance(UpdateExpr::Sub(amount.into())) + ).await?; + + // Credit to account + Account::update_by_id(&mut *tx, to, + Account::update_form().eval_balance(UpdateExpr::Add(amount.into())) + ).await?; + + tx.commit().await?; + Ok(()) +} +``` + +### Testing + +```rust +#[tokio::test] +async fn test_user_service() { + let pool = test_pool().await; + let service = UserService::new(ConnProvider::Borrowed(&pool)); + + let user = service.create_user("test@example.com").await.unwrap(); + assert_eq!(user.email, "test@example.com"); +} +``` + +## Deref Implementation + +ConnProvider implements `Deref`, allowing direct use with sqlx-record methods: + +```rust +let provider = ConnProvider::Borrowed(&pool); + +// Direct use (via Deref) +let users = User::find(&*provider, filters![("active", true)], None).await?; +``` +"#; + +const CLI_TOOL: &str = r#"# sqlx-record-ctl CLI + +Command-line tool for managing audit tables. + +## Installation + +```bash +cargo install --path sqlx-record-ctl --features mysql +# or postgres, sqlite +``` + +## Usage + +```bash +# Create audit tables +sqlx-record-ctl --schema-name mydb --db-url "mysql://user:pass@localhost/mydb" + +# Using environment variable +export DATABASE_URL="mysql://user:pass@localhost/mydb" +sqlx-record-ctl --schema-name mydb + +# Load from .env file +sqlx-record-ctl --schema-name mydb --env .env + +# Delete audit tables +sqlx-record-ctl --schema-name mydb --delete +``` + +## Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--schema-name` | `-s` | Database schema name (required) | +| `--db-url` | `-d` | Database URL (or use DATABASE_URL env) | +| `--env` | | Path to .env file | +| `--delete` | | Drop tables instead of create | + +## Prerequisites + +Create metadata table first: + +```sql +CREATE TABLE entity_changes_metadata ( + table_name VARCHAR(255) PRIMARY KEY, + is_auditable BOOLEAN NOT NULL DEFAULT TRUE +); + +-- Register tables for auditing +INSERT INTO entity_changes_metadata (table_name, is_auditable) VALUES + ('users', TRUE), + ('orders', TRUE), + ('products', TRUE); +``` + +## Generated Tables + +For each auditable table, creates: + +```sql +CREATE TABLE entity_changes_{table_name} ( + id BINARY(16) PRIMARY KEY, -- MySQL + entity_id BINARY(16) NOT NULL, + action ENUM('insert','update','delete','restore','hard-delete'), + changed_at BIGINT, + actor_id BINARY(16), + session_id BINARY(16), + change_set_id BINARY(16), + new_value JSON +); + +-- 5 indexes for query optimization +CREATE INDEX idx_{table}_entity_id ... +CREATE INDEX idx_{table}_actor_id ... +CREATE INDEX idx_{table}_session_id ... +CREATE INDEX idx_{table}_change_set_id ... +CREATE INDEX idx_{table}_entity_id_actor_id ... +``` +"#; + +const EXAMPLES: &str = r#"# Complete Examples + +## Basic Entity CRUD + +```rust +use sqlx_record::prelude::*; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Entity, FromRow, Debug)] +#[table_name = "users"] +struct User { + #[primary_key] + id: Uuid, + name: String, + email: String, + age: i32, + is_active: bool, + #[version] + version: u32, +} + +#[tokio::main] +async fn main() -> Result<(), sqlx::Error> { + let pool = sqlx::MySqlPool::connect("mysql://localhost/mydb").await?; + + // INSERT + let user = User { + id: new_uuid(), + name: "Alice".into(), + email: "alice@example.com".into(), + age: 30, + is_active: true, + version: 0, + }; + user.insert(&pool).await?; + + // SELECT by primary key + let user = User::get_by_id(&pool, &user.id).await?; + + // SELECT with filters + let active_users = User::find(&pool, + filters![("is_active", true)], + None + ).await?; + + // SELECT with complex filters + let users = User::find(&pool, vec![ + Filter::And(vec![ + "age".ge(18), + filter_or![("name", "Alice"), ("name", "Bob")], + ]) + ], None).await?; + + // SELECT with ordering and pagination + let page = User::find_ordered_with_limit( + &pool, + filters![("is_active", true)], + None, + vec![("created_at", false)], // ORDER BY created_at DESC + Some((0, 10)), // OFFSET 0, LIMIT 10 + ).await?; + + // COUNT + let count = User::count(&pool, filters![("is_active", true)], None).await?; + + // UPDATE + User::update_by_id(&pool, &user.id, + User::update_form() + .with_name("Alice Smith") + .with_age(31) + ).await?; + + // Check version + let version = User::get_version(&pool, &user.id).await?; + println!("Current version: {:?}", version); + + Ok(()) +} +``` + +## Lookup Tables + +```rust +use sqlx_record::prelude::*; + +// Database-backed lookup table +lookup_table!(OrderStatus, + "pending", + "processing", + "shipped", + "delivered", + "cancelled" +); + +// Code-only options (no DB table) +lookup_options!(PaymentMethod, + "credit-card", + "debit-card", + "paypal", + "bank-transfer" +); + +#[derive(Entity, FromRow)] +struct Order { + #[primary_key] + id: Uuid, + customer_id: Uuid, + status: String, + payment_method: String, + total: i64, +} + +async fn process_order(pool: &Pool, order_id: &Uuid) -> Result<(), Error> { + // Use type-safe constants + Order::update_by_id(pool, order_id, + Order::update_form() + .with_status(OrderStatus::PROCESSING) + ).await?; + + // Match on enum + let order = Order::get_by_id(pool, order_id).await?.unwrap(); + match OrderStatusCode::try_from(order.status.as_str()) { + Ok(OrderStatusCode::Pending) => start_processing(), + Ok(OrderStatusCode::Processing) => continue_processing(), + Ok(OrderStatusCode::Shipped) => track_shipment(), + _ => {} + } + + Ok(()) +} +``` + +## Audit Trail Integration + +```rust +use sqlx_record::prelude::*; +use sqlx_record::models::{EntityChange, Action}; +use sqlx_record::repositories::*; + +async fn update_with_audit( + pool: &Pool, + user_id: &Uuid, + form: UserUpdateForm, + actor_id: &Uuid, + session_id: &Uuid, + change_set_id: &Uuid, +) -> Result<(), Error> { + // Get current state for diff + let user = User::get_by_id(pool, user_id).await?.unwrap(); + + // Calculate diff + let diff = User::model_diff(&form, &user); + + // Perform update + User::update_by_id(pool, user_id, form).await?; + + // Record change + let change = EntityChange { + id: new_uuid(), + entity_id: *user_id, + action: Action::Update.to_string(), + changed_at: chrono::Utc::now().timestamp_millis(), + actor_id: *actor_id, + session_id: *session_id, + change_set_id: *change_set_id, + new_value: Some(diff), + }; + + create_entity_change(pool, &User::entity_changes_table_name(), &change).await?; + + Ok(()) +} + +// Query audit history +async fn get_user_history(pool: &Pool, user_id: &Uuid) -> Result, Error> { + get_entity_changes_by_entity(pool, "entity_changes_users", user_id).await +} +``` +"#; + +// ============================================================================ +// Tool Implementations +// ============================================================================ + +fn generate_entity_code(params: &Value) -> String { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Entity"); + let table = params.get("table").and_then(|v| v.as_str()).unwrap_or("entities"); + let fields: Vec<(&str, &str)> = params.get("fields") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|f| { + let name = f.get("name")?.as_str()?; + let typ = f.get("type")?.as_str()?; + Some((name, typ)) + }) + .collect() + }) + .unwrap_or_default(); + let has_version = params.get("version").and_then(|v| v.as_bool()).unwrap_or(false); + + let mut code = format!( + r#"use sqlx_record::prelude::*; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Entity, FromRow, Debug, Clone)] +#[table_name = "{}"] +pub struct {} {{ + #[primary_key] + pub id: Uuid, +"#, + table, name + ); + + for (field_name, field_type) in &fields { + code.push_str(&format!(" pub {}: {},\n", field_name, field_type)); + } + + if has_version { + code.push_str(" #[version]\n pub version: u32,\n"); + } + + code.push_str("}\n"); + code +} + +fn generate_filter_code(params: &Value) -> String { + let conditions: Vec = params.get("conditions") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let field = c.get("field")?.as_str()?; + let op = c.get("op")?.as_str().unwrap_or("eq"); + let value = c.get("value")?.as_str()?; + + let filter = match op { + "eq" => format!("(\"{}\", {})", field, value), + "ne" => format!("Filter::NotEqual(\"{}\", {}.into())", field, value), + "gt" => format!("\"{}\".gt({})", field, value), + "ge" => format!("\"{}\".ge({})", field, value), + "lt" => format!("\"{}\".lt({})", field, value), + "le" => format!("\"{}\".le({})", field, value), + "like" => format!("Filter::Like(\"{}\", {}.into())", field, value), + "in" => format!("Filter::In(\"{}\", vec![{}])", field, value), + "null" => format!("Filter::IsNull(\"{}\")", field), + "not_null" => format!("Filter::IsNotNull(\"{}\")", field), + _ => format!("(\"{}\", {})", field, value), + }; + Some(filter) + }) + .collect() + }) + .unwrap_or_default(); + + let logic = params.get("logic").and_then(|v| v.as_str()).unwrap_or("and"); + + if conditions.is_empty() { + return "filters![]".to_string(); + } + + match logic { + "or" => format!("filter_or![{}]", conditions.join(", ")), + _ => format!("filters![{}]", conditions.join(", ")), + } +} + +fn generate_lookup_code(params: &Value) -> String { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Status"); + let codes: Vec<&str> = params.get("codes") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|c| c.as_str()).collect()) + .unwrap_or_default(); + let with_entity = params.get("with_entity").and_then(|v| v.as_bool()).unwrap_or(true); + + let codes_str = codes.iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + if with_entity { + format!("lookup_table!({}, {});", name, codes_str) + } else { + format!("lookup_options!({}, {});", name, codes_str) + } +} + +// ============================================================================ +// MCP Request Handlers +// ============================================================================ + +fn handle_initialize() -> Value { + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "sqlx-record-expert", + "version": "0.1.0" + } + }) +} + +fn handle_list_tools() -> Value { + json!({ + "tools": [ + { + "name": "generate_entity", + "description": "Generate a sqlx-record Entity struct with derive macros", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Entity struct name" }, + "table": { "type": "string", "description": "Database table name" }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" } + } + }, + "description": "Fields (excluding id)" + }, + "version": { "type": "boolean", "description": "Include version field" } + }, + "required": ["name"] + } + }, + { + "name": "generate_filter", + "description": "Generate sqlx-record filter expressions", + "inputSchema": { + "type": "object", + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { "type": "string" }, + "op": { "type": "string", "enum": ["eq", "ne", "gt", "ge", "lt", "le", "like", "in", "null", "not_null"] }, + "value": { "type": "string" } + } + } + }, + "logic": { "type": "string", "enum": ["and", "or"], "default": "and" } + } + } + }, + { + "name": "generate_lookup", + "description": "Generate lookup_table! or lookup_options! macro call", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Lookup name" }, + "codes": { "type": "array", "items": { "type": "string" }, "description": "Code values" }, + "with_entity": { "type": "boolean", "description": "true for lookup_table!, false for lookup_options!", "default": true } + }, + "required": ["name", "codes"] + } + }, + { + "name": "explain_feature", + "description": "Get detailed documentation for a sqlx-record feature", + "inputSchema": { + "type": "object", + "properties": { + "feature": { + "type": "string", + "enum": ["overview", "derive", "filters", "values", "lookup", "audit", "update_form", "update_expr", "conn_provider", "databases", "uuid", "cli", "examples"], + "description": "Feature to explain" + } + }, + "required": ["feature"] + } + } + ] + }) +} + +fn handle_call_tool(params: &Value) -> Value { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let arguments = params.get("arguments").cloned().unwrap_or(json!({})); + + match name { + "generate_entity" => { + let code = generate_entity_code(&arguments); + json!({ + "content": [{ + "type": "text", + "text": code + }] + }) + } + "generate_filter" => { + let code = generate_filter_code(&arguments); + json!({ + "content": [{ + "type": "text", + "text": code + }] + }) + } + "generate_lookup" => { + let code = generate_lookup_code(&arguments); + json!({ + "content": [{ + "type": "text", + "text": code + }] + }) + } + "explain_feature" => { + let feature = arguments.get("feature").and_then(|v| v.as_str()).unwrap_or("overview"); + let doc = match feature { + "overview" => OVERVIEW, + "derive" => DERIVE_ENTITY, + "filters" => FILTERS, + "values" => VALUE_TYPES, + "lookup" => LOOKUP_TABLES, + "audit" => AUDIT_TRAIL, + "update_form" => UPDATE_FORM, + "update_expr" => UPDATE_EXPR, + "conn_provider" => CONN_PROVIDER, + "databases" => DATABASE_DIFFERENCES, + "uuid" => NEW_UUID, + "cli" => CLI_TOOL, + "examples" => EXAMPLES, + _ => OVERVIEW, + }; + json!({ + "content": [{ + "type": "text", + "text": doc + }] + }) + } + _ => json!({ + "content": [{ + "type": "text", + "text": format!("Unknown tool: {}", name) + }], + "isError": true + }), + } +} + +fn handle_list_resources() -> Value { + json!({ + "resources": [ + { + "uri": "sqlx-record://docs/overview", + "name": "Overview", + "description": "sqlx-record library overview and quick start", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/derive", + "name": "Derive Macro", + "description": "#[derive(Entity)] macro documentation", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/filters", + "name": "Filter System", + "description": "Filter enum and query building", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/values", + "name": "Value Types", + "description": "Value enum and type binding", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/lookup", + "name": "Lookup Tables", + "description": "lookup_table! and lookup_options! macros", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/audit", + "name": "Audit Trail", + "description": "EntityChange and audit system", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/update_form", + "name": "UpdateForm Builder", + "description": "Partial update builder pattern", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/update_expr", + "name": "UpdateExpr", + "description": "Advanced updates with arithmetic, CASE/WHEN, conditionals", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/conn_provider", + "name": "ConnProvider", + "description": "Flexible connection management (borrowed or pooled)", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/databases", + "name": "Database Differences", + "description": "MySQL, PostgreSQL, SQLite specifics", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/uuid", + "name": "Time-Ordered UUIDs", + "description": "new_uuid() function", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/cli", + "name": "CLI Tool", + "description": "sqlx-record-ctl documentation", + "mimeType": "text/markdown" + }, + { + "uri": "sqlx-record://docs/examples", + "name": "Examples", + "description": "Complete usage examples", + "mimeType": "text/markdown" + } + ] + }) +} + +fn handle_read_resource(params: &Value) -> Value { + let uri = params.get("uri").and_then(|v| v.as_str()).unwrap_or(""); + + let content = match uri { + "sqlx-record://docs/overview" => OVERVIEW, + "sqlx-record://docs/derive" => DERIVE_ENTITY, + "sqlx-record://docs/filters" => FILTERS, + "sqlx-record://docs/values" => VALUE_TYPES, + "sqlx-record://docs/lookup" => LOOKUP_TABLES, + "sqlx-record://docs/audit" => AUDIT_TRAIL, + "sqlx-record://docs/update_form" => UPDATE_FORM, + "sqlx-record://docs/update_expr" => UPDATE_EXPR, + "sqlx-record://docs/conn_provider" => CONN_PROVIDER, + "sqlx-record://docs/databases" => DATABASE_DIFFERENCES, + "sqlx-record://docs/uuid" => NEW_UUID, + "sqlx-record://docs/cli" => CLI_TOOL, + "sqlx-record://docs/examples" => EXAMPLES, + _ => "Resource not found", + }; + + json!({ + "contents": [{ + "uri": uri, + "mimeType": "text/markdown", + "text": content + }] + }) +} + +// ============================================================================ +// Main Loop +// ============================================================================ + +fn main() { + let stdin = io::stdin(); + let mut stdout = io::stdout(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + if line.is_empty() { + continue; + } + + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(_) => continue, + }; + + let result = match request.method.as_str() { + "initialize" => Some(handle_initialize()), + "tools/list" => Some(handle_list_tools()), + "tools/call" => request.params.as_ref().map(handle_call_tool), + "resources/list" => Some(handle_list_resources()), + "resources/read" => request.params.as_ref().map(handle_read_resource), + "notifications/initialized" => None, // No response needed + _ => Some(json!({"error": "Unknown method"})), + }; + + if let Some(result) = result { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.unwrap_or(Value::Null), + result: Some(result), + error: None, + }; + + let response_str = serde_json::to_string(&response).unwrap(); + writeln!(stdout, "{}", response_str).unwrap(); + stdout.flush().unwrap(); + } + } +} diff --git a/sqlx-record-ctl/Cargo.toml b/sqlx-record-ctl/Cargo.toml index fa0f0b9..1bb110d 100644 --- a/sqlx-record-ctl/Cargo.toml +++ b/sqlx-record-ctl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-record-ctl" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "CLI tool for managing sqlx-record audit tables" diff --git a/sqlx-record-derive/Cargo.toml b/sqlx-record-derive/Cargo.toml index c2863e8..9a13e19 100644 --- a/sqlx-record-derive/Cargo.toml +++ b/sqlx-record-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlx-record-derive" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Derive macros for sqlx-record" diff --git a/sqlx-record-derive/src/lib.rs b/sqlx-record-derive/src/lib.rs index 01a24a8..541e4c7 100644 --- a/sqlx-record-derive/src/lib.rs +++ b/sqlx-record-derive/src/lib.rs @@ -728,6 +728,16 @@ fn generate_get_impl( } } } +/// Check if a type is a binary type (Vec) +fn is_binary_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + let path_str = quote!(#type_path).to_string().replace(" ", ""); + path_str == "Vec" || path_str == "std::vec::Vec" + } else { + false + } +} + fn generate_update_impl( name: &Ident, update_form_name: &Ident, @@ -779,6 +789,23 @@ fn generate_update_impl( } }); + // Generate eval_* methods for non-binary fields + let eval_methods: Vec<_> = update_fields.iter() + .filter(|f| !is_binary_type(&f.ty)) + .map(|field| { + let method_name = format_ident!("eval_{}", field.ident); + let db_name = &field.db_name; + quote! { + /// Set field using an UpdateExpr for complex operations (arithmetic, CASE/WHEN, etc.) + /// Takes precedence over with_* if both are set. + pub fn #method_name(mut self, expr: ::sqlx_record::prelude::UpdateExpr) -> Self { + self._exprs.insert(#db_name, expr); + self + } + } + }) + .collect(); + // Version increment - use CASE WHEN for cross-database compatibility let version_increment = if let Some(vfield) = version_field { let version_db_name = &vfield.db_name; @@ -807,9 +834,20 @@ fn generate_update_impl( }; quote! { - #[derive(Default)] + /// Update form with support for simple value updates and complex expressions pub struct #update_form_name #ty_generics #where_clause { #(pub #field_idents: Option<#field_types>,)* + /// Expression-based updates (eval_* methods). Takes precedence over with_* for same field. + pub _exprs: std::collections::HashMap<&'static str, ::sqlx_record::prelude::UpdateExpr>, + } + + impl #impl_generics Default for #update_form_name #ty_generics #where_clause { + fn default() -> Self { + Self { + #(#field_idents: None,)* + _exprs: std::collections::HashMap::new(), + } + } } impl #impl_generics #name #ty_generics #where_clause { @@ -827,32 +865,108 @@ fn generate_update_impl( #(#builder_methods)* - pub fn update_stmt(&self) -> String { + #(#eval_methods)* + + /// Raw SQL expression escape hatch for any field (no bind parameters). + /// For expressions with parameters, use `raw_with_values()`. + pub fn raw(mut self, field: &'static str, sql: impl Into) -> Self { + self._exprs.insert(field, ::sqlx_record::prelude::UpdateExpr::Raw { + sql: sql.into(), + values: vec![], + }); + self + } + + /// Raw SQL expression escape hatch with bind parameters. + /// Use `?` for placeholders - they will be converted to proper database placeholders. + pub fn raw_with_values(mut self, field: &'static str, sql: impl Into, values: Vec<::sqlx_record::prelude::Value>) -> Self { + self._exprs.insert(field, ::sqlx_record::prelude::UpdateExpr::Raw { + sql: sql.into(), + values, + }); + self + } + + /// Generate UPDATE SET clause and collect values for binding. + /// Expression fields (eval_*) take precedence over simple value fields (with_*). + pub fn update_stmt_with_values(&self) -> (String, Vec<::sqlx_record::prelude::Value>) { let mut parts = Vec::new(); + let mut values = Vec::new(); let mut idx = 1usize; + #( - if self.#field_idents.is_some() { + // Check if this field has an expression (takes precedence) + if let Some(expr) = self._exprs.get(#db_names) { + let (sql, expr_values) = expr.build_sql(#db_names, idx); + parts.push(format!("{} = {}", #db_names, sql)); + idx += expr_values.len(); + values.extend(expr_values); + } else if let Some(ref value) = self.#field_idents { parts.push(format!("{} = {}", #db_names, ::sqlx_record::prelude::placeholder(idx))); + values.push(::sqlx_record::prelude::Value::from(value.clone())); idx += 1; } )* #version_increment - parts.join(", ") + (parts.join(", "), values) } - pub fn bind_form_values<'q>(&'q self, mut query: sqlx::query::Query<'q, #db, #db_args>) - -> sqlx::query::Query<'q, #db, #db_args> + /// Generate UPDATE SET clause (backward compatible, without values). + /// For new code, prefer `update_stmt_with_values()`. + pub fn update_stmt(&self) -> String { + self.update_stmt_with_values().0 + } + + /// Bind all form values to query in correct order. + /// Handles both simple values and expression values, respecting expression precedence. + pub fn bind_all_values(&self, mut query: sqlx::query::Query<'_, #db, #db_args>) + -> sqlx::query::Query<'_, #db, #db_args> { #( - if let Some(ref value) = self.#field_idents { + // Expression takes precedence over simple value + if let Some(expr) = self._exprs.get(#db_names) { + let (_, expr_values) = expr.build_sql(#db_names, 1); + for value in expr_values { + query = ::sqlx_record::prelude::bind_value_owned(query, value); + } + } else if let Some(ref value) = self.#field_idents { query = query.bind(value); } )* query } + /// Legacy binding method - only binds simple Option values (ignores expressions). + /// For backward compatibility. New code should use bind_all_values(). + pub fn bind_form_values<'q>(&'q self, mut query: sqlx::query::Query<'q, #db, #db_args>) + -> sqlx::query::Query<'q, #db, #db_args> + { + if self._exprs.is_empty() { + // No expressions, use simple binding + #( + if let Some(ref value) = self.#field_idents { + query = query.bind(value); + } + )* + query + } else { + // Has expressions, use full binding + self.bind_all_values(query) + } + } + + /// Check if this form uses any expressions + pub fn has_expressions(&self) -> bool { + !self._exprs.is_empty() + } + + /// Get the number of bind parameters this form will use. + pub fn param_count(&self) -> usize { + self.update_stmt_with_values().1.len() + } + pub const fn table_name() -> &'static str { #table_name } diff --git a/src/conn_provider.rs b/src/conn_provider.rs new file mode 100644 index 0000000..5080bcb --- /dev/null +++ b/src/conn_provider.rs @@ -0,0 +1,145 @@ +use sqlx::pool::PoolConnection; + +#[cfg(feature = "mysql")] +use sqlx::{MySql, MySqlPool}; + +#[cfg(feature = "postgres")] +use sqlx::{Postgres, PgPool}; + +#[cfg(feature = "sqlite")] +use sqlx::{Sqlite, SqlitePool}; + +// ============================================================================ +// MySQL Implementation +// ============================================================================ + +#[cfg(feature = "mysql")] +pub enum ConnProvider<'a> { + /// Stores a reference to an existing connection + Borrowed { + conn: &'a mut PoolConnection, + }, + /// Stores an owned connection acquired from a pool + Owned { + pool: MySqlPool, + conn: Option>, + }, +} + +#[cfg(feature = "mysql")] +impl<'a> ConnProvider<'a> { + /// Create a ConnProvider from a borrowed connection reference + pub fn from_ref(conn: &'a mut PoolConnection) -> Self { + ConnProvider::Borrowed { conn } + } + + /// Create a ConnProvider that will lazily acquire a connection from the pool + pub fn from_pool(pool: MySqlPool) -> Self { + ConnProvider::Owned { pool, conn: None } + } + + /// Get a mutable reference to the underlying connection. + /// For borrowed connections, returns the reference directly. + /// For owned connections, lazily acquires from pool on first call. + pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + match self { + ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Owned { pool, conn } => { + if conn.is_none() { + *conn = Some(pool.acquire().await?); + } + Ok(conn.as_mut().unwrap()) + } + } + } +} + +// ============================================================================ +// PostgreSQL Implementation +// ============================================================================ + +#[cfg(feature = "postgres")] +pub enum ConnProvider<'a> { + /// Stores a reference to an existing connection + Borrowed { + conn: &'a mut PoolConnection, + }, + /// Stores an owned connection acquired from a pool + Owned { + pool: PgPool, + conn: Option>, + }, +} + +#[cfg(feature = "postgres")] +impl<'a> ConnProvider<'a> { + /// Create a ConnProvider from a borrowed connection reference + pub fn from_ref(conn: &'a mut PoolConnection) -> Self { + ConnProvider::Borrowed { conn } + } + + /// Create a ConnProvider that will lazily acquire a connection from the pool + pub fn from_pool(pool: PgPool) -> Self { + ConnProvider::Owned { pool, conn: None } + } + + /// Get a mutable reference to the underlying connection. + /// For borrowed connections, returns the reference directly. + /// For owned connections, lazily acquires from pool on first call. + pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + match self { + ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Owned { pool, conn } => { + if conn.is_none() { + *conn = Some(pool.acquire().await?); + } + Ok(conn.as_mut().unwrap()) + } + } + } +} + +// ============================================================================ +// SQLite Implementation +// ============================================================================ + +#[cfg(feature = "sqlite")] +pub enum ConnProvider<'a> { + /// Stores a reference to an existing connection + Borrowed { + conn: &'a mut PoolConnection, + }, + /// Stores an owned connection acquired from a pool + Owned { + pool: SqlitePool, + conn: Option>, + }, +} + +#[cfg(feature = "sqlite")] +impl<'a> ConnProvider<'a> { + /// Create a ConnProvider from a borrowed connection reference + pub fn from_ref(conn: &'a mut PoolConnection) -> Self { + ConnProvider::Borrowed { conn } + } + + /// Create a ConnProvider that will lazily acquire a connection from the pool + pub fn from_pool(pool: SqlitePool) -> Self { + ConnProvider::Owned { pool, conn: None } + } + + /// Get a mutable reference to the underlying connection. + /// For borrowed connections, returns the reference directly. + /// For owned connections, lazily acquires from pool on first call. + pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + match self { + ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Owned { pool, conn } => { + if conn.is_none() { + *conn = Some(pool.acquire().await?); + } + Ok(conn.as_mut().unwrap()) + } + } + } +} diff --git a/src/filter.rs b/src/filter.rs index 3682caf..54371bd 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -112,6 +112,27 @@ pub fn placeholder(index: usize) -> String { } impl Filter<'_> { + /// Returns the number of bind parameters this filter will use + pub fn param_count(&self) -> usize { + match self { + Filter::Equal(_, _) => 1, + Filter::NotEqual(_, _) => 1, + Filter::GreaterThan(_, _) => 1, + Filter::GreaterThanOrEqual(_, _) => 1, + Filter::LessThan(_, _) => 1, + Filter::LessThanOrEqual(_, _) => 1, + Filter::Like(_, _) => 1, + Filter::ILike(_, _) => 1, + Filter::NotLike(_, _) => 1, + Filter::In(_, values) => values.len(), + Filter::NotIn(_, values) => values.len(), + Filter::IsNull(_) => 0, + Filter::IsNotNull(_) => 0, + Filter::And(filters) => filters.iter().map(|f| f.param_count()).sum(), + Filter::Or(filters) => filters.iter().map(|f| f.param_count()).sum(), + } + } + pub fn build_where_clause(filters: &[Filter]) -> (String, Vec) { Self::build_where_clause_with_offset(filters, 1) } diff --git a/src/lib.rs b/src/lib.rs index b70936f..4611b73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,183 @@ +use chrono::Utc; +use rand::random; +use uuid::Uuid; + pub mod models; pub mod repositories; mod helpers; mod value; mod filter; +mod conn_provider; // Re-export the sqlx_record_derive module on feature flag #[cfg(feature = "derive")] pub use sqlx_record_derive::{Entity, Update}; +/// Creates a time-ordered UUID with timestamp prefix for better database indexing. +/// First 8 bytes: millisecond timestamp (big-endian) +/// Last 8 bytes: random data +#[inline] +pub fn new_uuid() -> Uuid { + let timestamp = Utc::now().timestamp_millis() as u64; + let random = random::(); + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(×tamp.to_be_bytes()); + bytes[8..].copy_from_slice(&random.to_be_bytes()); + + Uuid::from_bytes(bytes) +} + +/// Creates a lookup table entity struct with an associated code enum. +/// +/// Generates: +/// - A struct with `code`, `name`, `description`, and `is_active` fields +/// - An enum `{Name}Code` with variants for each code +/// - Constants on the struct for each code string +/// - `Display` impl for the enum +/// - `TryFrom<&str>` impl for the enum +/// +/// # Example +/// ```ignore +/// lookup_table!(UserStatus, "active", "inactive", "suspended"); +/// // Creates: +/// // - struct UserStatus { code, name, description, is_active } +/// // - enum UserStatusCode { Active, Inactive, Suspended } +/// // - UserStatus::ACTIVE, UserStatus::INACTIVE, UserStatus::SUSPENDED constants +/// ``` +#[macro_export] +macro_rules! lookup_table { + ($name:ident, $($code:literal),+ $(,)?) => { + #[allow(dead_code)] + #[derive(Debug, Clone, sqlx_record::Entity, sqlx::FromRow)] + pub struct $name { + #[primary_key] + pub code: String, + pub name: String, + pub description: String, + pub is_active: bool, + } + + paste::paste! { + #[allow(dead_code)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum [<$name Code>] { + $( + #[allow(non_camel_case_types)] + [<$code:camel>], + )+ + } + + impl std::fmt::Display for [<$name Code>] { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + $( + [<$name Code>]::[<$code:camel>] => write!(f, $code), + )+ + } + } + } + + #[allow(dead_code)] + impl $name { + $( + pub const [<$code:upper>]: &'static str = $code; + )+ + } + + impl TryFrom<&str> for [<$name Code>] { + type Error = String; + + fn try_from(s: &str) -> Result { + match s { + $( + $code => Ok([<$name Code>]::[<$code:camel>]), + )+ + _ => Err(format!("Unknown {} code: {}", stringify!($name).to_lowercase(), s)), + } + } + } + } + }; +} + +/// Creates a code enum without an associated entity struct. +/// +/// Use this when you need type-safe code constants but the lookup data +/// is stored elsewhere or doesn't need CRUD operations. +/// +/// Generates: +/// - An enum `{Name}Code` with variants for each code +/// - Constants on a unit struct for each code string +/// - `Display` impl for the enum +/// - `TryFrom<&str>` impl for the enum +/// +/// # Example +/// ```ignore +/// lookup_options!(RefundPolicy, "pro-rata", "full-24-hours", "no-refunds"); +/// // Creates: +/// // - enum RefundPolicyCode { ProRata, Full24Hours, NoRefunds } +/// // - RefundPolicy::PRO_RATA, RefundPolicy::FULL_24_HOURS, etc. +/// ``` +#[macro_export] +macro_rules! lookup_options { + ($name:ident, $($code:literal),+ $(,)?) => { + paste::paste! { + #[allow(dead_code)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum [<$name Code>] { + $( + #[allow(non_camel_case_types)] + [<$code:camel>], + )+ + } + + impl std::fmt::Display for [<$name Code>] { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + $( + [<$name Code>]::[<$code:camel>] => write!(f, $code), + )+ + } + } + } + + /// Constants for code string values + #[allow(dead_code)] + pub struct $name; + + #[allow(dead_code)] + impl $name { + $( + pub const [<$code:upper>]: &'static str = $code; + )+ + } + + impl TryFrom<&str> for [<$name Code>] { + type Error = String; + + fn try_from(s: &str) -> Result { + match s { + $( + $code => Ok([<$name Code>]::[<$code:camel>]), + )+ + _ => Err(format!("Unknown {} code: {}", stringify!($name).to_lowercase(), s)), + } + } + } + } + }; +} + pub mod prelude { pub use crate::value::*; pub use crate::filter::*; pub use crate::{filter_or, filter_and, filters, update_entity_func}; pub use crate::{filter_or as or, filter_and as and}; pub use crate::values; + pub use crate::{new_uuid, lookup_table, lookup_options}; + + #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] + pub use crate::conn_provider::ConnProvider; #[cfg(feature = "derive")] pub use sqlx_record_derive::{Entity, Update}; diff --git a/src/value.rs b/src/value.rs index f9b9e00..30eaeac 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1,5 +1,6 @@ use sqlx::query::{Query, QueryAs, QueryScalar}; use sqlx::types::chrono::{NaiveDate, NaiveDateTime}; +use crate::filter::placeholder; // Database type alias based on enabled feature #[cfg(feature = "mysql")] @@ -11,7 +12,7 @@ pub type DB = sqlx::Postgres; #[cfg(feature = "sqlite")] pub type DB = sqlx::Sqlite; -// Arguments type alias +// Arguments type alias (used for non-lifetime-sensitive contexts) #[cfg(feature = "mysql")] pub type Arguments = sqlx::mysql::MySqlArguments; @@ -21,6 +22,16 @@ pub type Arguments = sqlx::postgres::PgArguments; #[cfg(feature = "sqlite")] pub type Arguments = sqlx::sqlite::SqliteArguments<'static>; +// Lifetime-aware arguments type for SQLite +#[cfg(feature = "sqlite")] +pub type Arguments_<'q> = sqlx::sqlite::SqliteArguments<'q>; + +#[cfg(feature = "mysql")] +pub type Arguments_<'q> = sqlx::mysql::MySqlArguments; + +#[cfg(feature = "postgres")] +pub type Arguments_<'q> = sqlx::postgres::PgArguments; + #[derive(Clone, Debug)] pub enum Value { Int8(i8), @@ -39,15 +50,206 @@ pub enum Value { NaiveDateTime(NaiveDateTime), } -pub enum Updater<'a> { - Set(&'a str, Value), - Increment(&'a str, Value), - Decrement(&'a str, Value), +/// Expression for column updates beyond simple value assignment. +/// Used with `eval_*` methods on UpdateForm. +#[derive(Clone, Debug)] +pub enum UpdateExpr { + /// column = value (equivalent to with_* methods) + Set(Value), + + /// column = column + value + Add(Value), + + /// column = column - value + Sub(Value), + + /// column = column * value + Mul(Value), + + /// column = column / value + Div(Value), + + /// column = column % value + Mod(Value), + + /// column = CASE WHEN cond1 THEN val1 WHEN cond2 THEN val2 ... ELSE default END + Case { + /// Vec of (condition, value) pairs for WHEN branches + branches: Vec<(crate::filter::Filter<'static>, Value)>, + /// Default value for ELSE branch + default: Value, + }, + + /// column = CASE WHEN condition THEN column + value ELSE column END + AddIf { + condition: crate::filter::Filter<'static>, + value: Value, + }, + + /// column = CASE WHEN condition THEN column - value ELSE column END + SubIf { + condition: crate::filter::Filter<'static>, + value: Value, + }, + + /// column = COALESCE(column, value) + Coalesce(Value), + + /// column = GREATEST(column, value) - MySQL/PostgreSQL only + Greatest(Value), + + /// column = LEAST(column, value) - MySQL/PostgreSQL only + Least(Value), + + /// Raw SQL expression escape hatch: column = {sql} + /// Placeholders in sql should use `?` and will be replaced with proper placeholders + Raw { + sql: String, + values: Vec, + }, +} + +impl UpdateExpr { + /// Build SQL expression and collect values for binding. + /// Returns (sql_fragment, values_to_bind) + pub fn build_sql(&self, column: &str, start_idx: usize) -> (String, Vec) { + use crate::filter::Filter; + + match self { + UpdateExpr::Set(v) => { + (placeholder(start_idx), vec![v.clone()]) + } + UpdateExpr::Add(v) => { + (format!("{} + {}", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Sub(v) => { + (format!("{} - {}", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Mul(v) => { + (format!("{} * {}", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Div(v) => { + (format!("{} / {}", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Mod(v) => { + (format!("{} % {}", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Case { branches, default } => { + let mut sql_parts = vec!["CASE".to_string()]; + let mut values = Vec::new(); + let mut idx = start_idx; + + for (condition, value) in branches { + let (cond_sql, cond_values) = Filter::build_where_clause_with_offset( + &[condition.clone()], + idx, + ); + idx += cond_values.len(); + values.extend(cond_values); + + sql_parts.push(format!("WHEN {} THEN {}", cond_sql, placeholder(idx))); + values.push(value.clone()); + idx += 1; + } + + sql_parts.push(format!("ELSE {} END", placeholder(idx))); + values.push(default.clone()); + + (sql_parts.join(" "), values) + } + UpdateExpr::AddIf { condition, value } => { + let (cond_sql, cond_values) = Filter::build_where_clause_with_offset( + &[condition.clone()], + start_idx, + ); + let mut values = cond_values; + let val_idx = start_idx + values.len(); + + let sql = format!( + "CASE WHEN {} THEN {} + {} ELSE {} END", + cond_sql, + column, + placeholder(val_idx), + column + ); + values.push(value.clone()); + + (sql, values) + } + UpdateExpr::SubIf { condition, value } => { + let (cond_sql, cond_values) = Filter::build_where_clause_with_offset( + &[condition.clone()], + start_idx, + ); + let mut values = cond_values; + let val_idx = start_idx + values.len(); + + let sql = format!( + "CASE WHEN {} THEN {} - {} ELSE {} END", + cond_sql, + column, + placeholder(val_idx), + column + ); + values.push(value.clone()); + + (sql, values) + } + UpdateExpr::Coalesce(v) => { + (format!("COALESCE({}, {})", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Greatest(v) => { + (format!("GREATEST({}, {})", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Least(v) => { + (format!("LEAST({}, {})", column, placeholder(start_idx)), vec![v.clone()]) + } + UpdateExpr::Raw { sql, values } => { + // Replace ? placeholders with proper database placeholders + let mut result_sql = String::new(); + let mut placeholder_count = 0; + + for ch in sql.chars() { + if ch == '?' { + result_sql.push_str(&placeholder(start_idx + placeholder_count)); + placeholder_count += 1; + } else { + result_sql.push(ch); + } + } + + (result_sql, values.clone()) + } + } + } + + /// Returns the number of bind parameters this expression will use + pub fn param_count(&self) -> usize { + match self { + UpdateExpr::Set(_) => 1, + UpdateExpr::Add(_) => 1, + UpdateExpr::Sub(_) => 1, + UpdateExpr::Mul(_) => 1, + UpdateExpr::Div(_) => 1, + UpdateExpr::Mod(_) => 1, + UpdateExpr::Case { branches, default: _ } => { + branches.iter().map(|(f, _)| f.param_count() + 1).sum::() + 1 + } + UpdateExpr::AddIf { condition, value: _ } => condition.param_count() + 1, + UpdateExpr::SubIf { condition, value: _ } => condition.param_count() + 1, + UpdateExpr::Coalesce(_) => 1, + UpdateExpr::Greatest(_) => 1, + UpdateExpr::Least(_) => 1, + UpdateExpr::Raw { sql: _, values } => values.len(), + } + } } #[deprecated(since = "0.1.0", note = "Please use Value instead")] pub type SqlValue = Value; +// MySQL supports unsigned integers natively +#[cfg(feature = "mysql")] macro_rules! bind_value { ($query:expr, $value: expr) => {{ let query = match $value { @@ -70,8 +272,32 @@ macro_rules! bind_value { }}; } +// PostgreSQL and SQLite don't support unsigned integers - convert to signed +#[cfg(any(feature = "postgres", feature = "sqlite"))] +macro_rules! bind_value { + ($query:expr, $value: expr) => {{ + let query = match $value { + Value::Int8(v) => $query.bind(v), + Value::Uint8(v) => $query.bind(*v as i16), + Value::Int16(v) => $query.bind(v), + Value::Uint16(v) => $query.bind(*v as i32), + Value::Int32(v) => $query.bind(v), + Value::Uint32(v) => $query.bind(*v as i64), + Value::Int64(v) => $query.bind(v), + Value::Uint64(v) => $query.bind(*v as i64), + Value::VecU8(v) => $query.bind(v), + Value::String(v) => $query.bind(v), + Value::Bool(v) => $query.bind(v), + Value::Uuid(v) => $query.bind(v), + Value::NaiveDate(v) => $query.bind(v), + Value::NaiveDateTime(v) => $query.bind(v), + }; + query + }}; +} + #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -pub fn bind_values<'q>(query: Query<'q, DB, Arguments>, values: &'q [Value]) -> Query<'q, DB, Arguments> { +pub fn bind_values<'q>(query: Query<'q, DB, Arguments_<'q>>, values: &'q [Value]) -> Query<'q, DB, Arguments_<'q>> { let mut query = query; for value in values { query = bind_value!(query, value); @@ -79,15 +305,48 @@ pub fn bind_values<'q>(query: Query<'q, DB, Arguments>, values: &'q [Value]) -> query } +/// Bind a single owned Value to a query #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -pub fn bind_as_values<'q, O>(query: QueryAs<'q, DB, O, Arguments>, values: &'q [Value]) -> QueryAs<'q, DB, O, Arguments> { +pub fn bind_value_owned<'q>(query: Query<'q, DB, Arguments_<'q>>, value: Value) -> Query<'q, DB, Arguments_<'q>> { + match value { + Value::Int8(v) => query.bind(v), + Value::Int16(v) => query.bind(v), + Value::Int32(v) => query.bind(v), + Value::Int64(v) => query.bind(v), + #[cfg(feature = "mysql")] + Value::Uint8(v) => query.bind(v), + #[cfg(feature = "mysql")] + Value::Uint16(v) => query.bind(v), + #[cfg(feature = "mysql")] + Value::Uint32(v) => query.bind(v), + #[cfg(feature = "mysql")] + Value::Uint64(v) => query.bind(v), + #[cfg(any(feature = "postgres", feature = "sqlite"))] + Value::Uint8(v) => query.bind(v as i16), + #[cfg(any(feature = "postgres", feature = "sqlite"))] + Value::Uint16(v) => query.bind(v as i32), + #[cfg(any(feature = "postgres", feature = "sqlite"))] + Value::Uint32(v) => query.bind(v as i64), + #[cfg(any(feature = "postgres", feature = "sqlite"))] + Value::Uint64(v) => query.bind(v as i64), + Value::VecU8(v) => query.bind(v), + Value::String(v) => query.bind(v), + Value::Bool(v) => query.bind(v), + Value::Uuid(v) => query.bind(v), + Value::NaiveDate(v) => query.bind(v), + Value::NaiveDateTime(v) => query.bind(v), + } +} + +#[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] +pub fn bind_as_values<'q, O>(query: QueryAs<'q, DB, O, Arguments_<'q>>, values: &'q [Value]) -> QueryAs<'q, DB, O, Arguments_<'q>> { values.into_iter().fold(query, |query, value| { bind_value!(query, value) }) } #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -pub fn bind_scalar_values<'q, O>(query: QueryScalar<'q, DB, O, Arguments>, values: &'q [Value]) -> QueryScalar<'q, DB, O, Arguments> { +pub fn bind_scalar_values<'q, O>(query: QueryScalar<'q, DB, O, Arguments_<'q>>, values: &'q [Value]) -> QueryScalar<'q, DB, O, Arguments_<'q>> { let mut query = query; for value in values { query = bind_value!(query, value); @@ -101,24 +360,61 @@ pub fn query_fields(fields: Vec<&str>) -> String { .collect::>().join(", ") } +// From implementations for owned values impl From for Value { fn from(value: String) -> Self { Value::String(value) } } +impl From for Value { + fn from(value: i8) -> Self { + Value::Int8(value) + } +} + +impl From for Value { + fn from(value: u8) -> Self { + Value::Uint8(value) + } +} + +impl From for Value { + fn from(value: i16) -> Self { + Value::Int16(value) + } +} + +impl From for Value { + fn from(value: u16) -> Self { + Value::Uint16(value) + } +} + impl From for Value { fn from(value: i32) -> Self { Value::Int32(value) } } +impl From for Value { + fn from(value: u32) -> Self { + Value::Uint32(value) + } +} + impl From for Value { fn from(value: i64) -> Self { Value::Int64(value) } } +impl From for Value { + fn from(value: u64) -> Self { + Value::Uint64(value) + } +} + impl From for Value { fn from(value: bool) -> Self { Value::Bool(value) @@ -131,33 +427,9 @@ impl From for Value { } } -impl From<&str> for Value { - fn from(value: &str) -> Self { - Value::String(value.to_string()) - } -} - -impl From<&i32> for Value { - fn from(value: &i32) -> Self { - Value::Int32(*value) - } -} - -impl From<&i64> for Value { - fn from(value: &i64) -> Self { - Value::Int64(*value) - } -} - -impl From<&bool> for Value { - fn from(value: &bool) -> Self { - Value::Bool(*value) - } -} - -impl From<&uuid::Uuid> for Value { - fn from(value: &uuid::Uuid) -> Self { - Value::Uuid(*value) +impl From> for Value { + fn from(value: Vec) -> Self { + Value::VecU8(value) } } @@ -173,6 +445,91 @@ impl From for Value { } } +// From implementations for references +impl From<&str> for Value { + fn from(value: &str) -> Self { + Value::String(value.to_string()) + } +} + +impl From<&String> for Value { + fn from(value: &String) -> Self { + Value::String(value.clone()) + } +} + +impl From<&i8> for Value { + fn from(value: &i8) -> Self { + Value::Int8(*value) + } +} + +impl From<&u8> for Value { + fn from(value: &u8) -> Self { + Value::Uint8(*value) + } +} + +impl From<&i16> for Value { + fn from(value: &i16) -> Self { + Value::Int16(*value) + } +} + +impl From<&u16> for Value { + fn from(value: &u16) -> Self { + Value::Uint16(*value) + } +} + +impl From<&i32> for Value { + fn from(value: &i32) -> Self { + Value::Int32(*value) + } +} + +impl From<&u32> for Value { + fn from(value: &u32) -> Self { + Value::Uint32(*value) + } +} + +impl From<&i64> for Value { + fn from(value: &i64) -> Self { + Value::Int64(*value) + } +} + +impl From<&u64> for Value { + fn from(value: &u64) -> Self { + Value::Uint64(*value) + } +} + +impl From<&bool> for Value { + fn from(value: &bool) -> Self { + Value::Bool(*value) + } +} + +impl From<&uuid::Uuid> for Value { + fn from(value: &uuid::Uuid) -> Self { + Value::Uuid(*value) + } +} + +impl From<&NaiveDate> for Value { + fn from(value: &NaiveDate) -> Self { + Value::NaiveDate(*value) + } +} + +impl From<&NaiveDateTime> for Value { + fn from(value: &NaiveDateTime) -> Self { + Value::NaiveDateTime(*value) + } +} + pub trait BindValues<'q> { type Output; @@ -180,8 +537,8 @@ pub trait BindValues<'q> { } #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -impl<'q> BindValues<'q> for Query<'q, DB, Arguments> { - type Output = Query<'q, DB, Arguments>; +impl<'q> BindValues<'q> for Query<'q, DB, Arguments_<'q>> { + type Output = Query<'q, DB, Arguments_<'q>>; fn bind_values(self, values: &'q [Value]) -> Self::Output { let mut query = self; @@ -193,8 +550,8 @@ impl<'q> BindValues<'q> for Query<'q, DB, Arguments> { } #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -impl<'q, O> BindValues<'q> for QueryAs<'q, DB, O, Arguments> { - type Output = QueryAs<'q, DB, O, Arguments>; +impl<'q, O> BindValues<'q> for QueryAs<'q, DB, O, Arguments_<'q>> { + type Output = QueryAs<'q, DB, O, Arguments_<'q>>; fn bind_values(self, values: &'q [Value]) -> Self::Output { values.into_iter().fold(self, |query, value| { @@ -204,8 +561,8 @@ impl<'q, O> BindValues<'q> for QueryAs<'q, DB, O, Arguments> { } #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] -impl<'q, O> BindValues<'q> for QueryScalar<'q, DB, O, Arguments> { - type Output = QueryScalar<'q, DB, O, Arguments>; +impl<'q, O> BindValues<'q> for QueryScalar<'q, DB, O, Arguments_<'q>> { + type Output = QueryScalar<'q, DB, O, Arguments_<'q>>; fn bind_values(self, values: &'q [Value]) -> Self::Output { let mut query = self;