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 <noreply@anthropic.com>
This commit is contained in:
Michael Netshipise 2026-01-28 16:21:14 +02:00
parent e89041b9c6
commit b960e235e6
19 changed files with 4652 additions and 54 deletions

View File

@ -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<Value>, // 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<Uuid, Error> {
// 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<Vec<EntityChange>, 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<Vec<EntityChange>, 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(())
}
```

View File

@ -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<DB>,
},
/// Lazy acquisition from pool
Owned {
pool: Pool,
conn: Option<PoolConnection<DB>>,
},
}
```
## 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<DB>
```
- **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<MySql>) -> 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<MySql>` |
| `postgres` | `PgPool` | `PoolConnection<Postgres>` |
| `sqlite` | `SqlitePool` | `PoolConnection<Sqlite>` |
## 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<Uuid, Error> {
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)
```

View File

@ -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<E>(&self, executor: E) -> Result<PkType, sqlx::Error>
```
### Get Methods
```rust
// By single primary key
pub async fn get_by_id(executor, id: &Uuid) -> Result<Option<Self>, Error>
// By multiple primary keys
pub async fn get_by_ids(executor, ids: &[Uuid]) -> Result<Vec<Self>, Error>
// Generic primary key access
pub async fn get_by_primary_key(executor, pk: &PkType) -> Result<Option<Self>, Error>
```
### Find Methods
```rust
// Basic find
pub async fn find(executor, filters: Vec<Filter>, index: Option<&str>) -> Result<Vec<Self>, Error>
// Find first match
pub async fn find_one(executor, filters: Vec<Filter>, index: Option<&str>) -> Result<Option<Self>, Error>
// With ordering
pub async fn find_ordered(
executor,
filters: Vec<Filter>,
index: Option<&str>,
order_by: Vec<(&str, bool)> // (field, is_ascending)
) -> Result<Vec<Self>, Error>
// With ordering and pagination
pub async fn find_ordered_with_limit(
executor,
filters: Vec<Filter>,
index: Option<&str>,
order_by: Vec<(&str, bool)>,
offset_limit: Option<(u32, u32)> // (offset, limit)
) -> Result<Vec<Self>, Error>
// Count matching
pub async fn count(executor, filters: Vec<Filter>, index: Option<&str>) -> Result<u64, Error>
```
### 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<Value, Error>
// 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<Option<VersionType>, Error>
pub async fn get_versions(executor, pks: &[PkType]) -> Result<HashMap<PkType, VersionType>, 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<T>.
```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<String>,
}
// 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(())
}
```

View File

@ -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<Value>),
NotIn(&'a str, Vec<Value>),
// Null checks
IsNull(&'a str),
IsNotNull(&'a str),
// Composition
And(Vec<Filter<'a>>),
Or(Vec<Filter<'a>>),
}
```
## 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<Filter<'_>>) -> Result<Vec<User>> {
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<Filter<'static>> {
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<Filter<'static>> {
vec![
"created_at".ge(start),
"created_at".le(end),
]
}
```
### Optional Filters
```rust
fn build_filters(
status: Option<&str>,
min_age: Option<i32>,
role: Option<&str>,
) -> Vec<Filter<'static>> {
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
}
```

View File

@ -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);
```

View File

@ -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<String>,
}
```
### 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 |

View File

@ -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<Value> },
}
```
## 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<u8>`) 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.

View File

@ -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<u8>),
// 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<Value>
```
## Custom Queries with Values
```rust
use sqlx_record::prelude::*;
async fn complex_query(pool: &Pool, status: &str, min_age: i32) -> Result<Vec<User>> {
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<Value>) {
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<Value> {
users.iter().flat_map(|u| {
vec![
Value::Uuid(u.id),
Value::String(u.name.clone()),
Value::String(u.email.clone()),
]
}).collect()
}
```

137
CLAUDE.md
View File

@ -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

View File

@ -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]

14
mcp/Cargo.toml Normal file
View File

@ -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"] }

1724
mcp/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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"

View File

@ -728,6 +728,16 @@ fn generate_get_impl(
}
}
}
/// Check if a type is a binary type (Vec<u8>)
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<u8>" || path_str == "std::vec::Vec<u8>"
} 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,30 +865,106 @@ 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<String>) -> 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<String>, 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)
}
/// 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>
{
#(
// 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 {

145
src/conn_provider.rs Normal file
View File

@ -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<MySql>,
},
/// Stores an owned connection acquired from a pool
Owned {
pool: MySqlPool,
conn: Option<PoolConnection<MySql>>,
},
}
#[cfg(feature = "mysql")]
impl<'a> ConnProvider<'a> {
/// Create a ConnProvider from a borrowed connection reference
pub fn from_ref(conn: &'a mut PoolConnection<MySql>) -> 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<MySql>, 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<Postgres>,
},
/// Stores an owned connection acquired from a pool
Owned {
pool: PgPool,
conn: Option<PoolConnection<Postgres>>,
},
}
#[cfg(feature = "postgres")]
impl<'a> ConnProvider<'a> {
/// Create a ConnProvider from a borrowed connection reference
pub fn from_ref(conn: &'a mut PoolConnection<Postgres>) -> 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<Postgres>, 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<Sqlite>,
},
/// Stores an owned connection acquired from a pool
Owned {
pool: SqlitePool,
conn: Option<PoolConnection<Sqlite>>,
},
}
#[cfg(feature = "sqlite")]
impl<'a> ConnProvider<'a> {
/// Create a ConnProvider from a borrowed connection reference
pub fn from_ref(conn: &'a mut PoolConnection<Sqlite>) -> 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<Sqlite>, 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())
}
}
}
}

View File

@ -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<Value>) {
Self::build_where_clause_with_offset(filters, 1)
}

View File

@ -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::<u64>();
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&timestamp.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<Self, Self::Error> {
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<Self, Self::Error> {
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};

View File

@ -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<Value>,
},
}
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<Value>) {
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::<usize>() + 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::<Vec<_>>().join(", ")
}
// From implementations for owned values
impl From<String> for Value {
fn from(value: String) -> Self {
Value::String(value)
}
}
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)
@ -131,33 +427,9 @@ impl From<uuid::Uuid> 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<Vec<u8>> for Value {
fn from(value: Vec<u8>) -> Self {
Value::VecU8(value)
}
}
@ -173,6 +445,91 @@ impl From<NaiveDateTime> 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;