Import entity-changes project

Initial import of the entity-changes codebase as starting point for sqlx-record.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Netshipise 2026-01-28 15:19:38 +02:00
parent 92d559574e
commit e9079834c7
22 changed files with 5925 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/target
/entity-update_derive/target
/entity-changes-ctl/target
.idea
/Cargo.lock

68
CLAUDE.md Normal file
View File

@ -0,0 +1,68 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Rust library called `entity-changes` that provides entity change tracking functionality for MySQL databases using SQLx. The library supports tracking entity modifications with actors, sessions, and change sets, along with procedural macros for automatic derivation of entity tracking capabilities.
## Architecture
### Workspace Structure
- **Main library** (`src/`): Core entity change tracking functionality
- **entity-update_derive** (`entity-update_derive/`): Procedural macro crate for deriving Entity and Update traits
- **entity-changes-ctl** (`entity-changes-ctl/`): Command-line utility for the library
### Core Components
- **models.rs**: Defines `EntityChange` struct and `Action` enum for tracking entity modifications
- **repositories.rs**: Database query functions for creating and retrieving entity changes by various criteria (ID, entity, session, actor, change set)
- **value.rs**: Type-safe value system supporting MySQL types (integers, strings, UUIDs, dates, etc.) with `Value` enum and `Updater` for SQL operations
- **condition.rs**: Query condition system with `Condition` enum supporting SQL operations (Equal, Like, In, And, Or, etc.)
- **helpers.rs**: Utility functions and macros for the library
### Features
- `derive`: Enables procedural macro support for Entity and Update traits
- `static-validation`: Enables static SQLx validation during compilation
## Development Commands
### Building
```bash
cargo build
```
### Testing
```bash
cargo test
```
### Building with all features
```bash
cargo build --all-features
```
### Working with workspace members
```bash
# Build specific workspace member
cargo build -p entity-update_derive
cargo build -p entity-changes-ctl
# Test specific workspace member
cargo test -p entity-changes
```
### Releasing
The project uses a Makefile for tagging releases:
```bash
make tag
```
This creates a git tag based on the version in Cargo.toml and pushes it to the remote repository.
## Important Notes
- The library is designed specifically for MySQL databases via SQLx
- All entity changes include metadata: actor_id, session_id, change_set_id, and timestamps
- The `new_value` field stores JSON data for tracking actual changes
- Procedural macros are optional and gated behind the "derive" feature
- The library uses UUIDs for all entity identifiers
- Query conditions support both simple field-value pairs and complex nested And/Or logic

21
Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "entity-changes"
version = "0.1.37"
edition = "2021"
[dependencies]
entity-update_derive = { path = "entity-update_derive", optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "uuid", "chrono", "json"] }
serde_json = "1.0"
uuid = { version = "1", features = ["v4"]}
[workspace]
members = [
"entity-update_derive",
"entity-changes-ctl"
]
[features]
default = []
derive = ["dep:entity-update_derive"]
static-validation = ["entity-update_derive?/static-validation"]

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
TAG ?= $(shell cargo pkgid --offline | cut -d\# -f2 | cut -d: -f2)
tag: # Tag current release
#git commit -am "Release $(TAG)"
#git push
git tag "v$(TAG)" -a -m "Release $(TAG)" && git push --tags

2003
entity-changes-ctl/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
[package]
name = "entity-changes-ctl"
version = "0.1.0"
edition = "2021"
[dependencies]
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
dotenv = "0.15"
sqlx = { version = "0.8", features = ["mysql", "runtime-tokio-native-tls"] }
tokio = { version = "1", features = ["rt", "macros", "time", "net", "rt-multi-thread"] }
clap = { version = "4.1", features = ["derive"] }
url = "2"

View File

@ -0,0 +1,6 @@
CREATE TABLE entity_changes_metadata (
table_name VARCHAR(255) PRIMARY KEY,
is_auditable BOOLEAN NOT NULL DEFAULT TRUE
);
-- INSERT INTO entity_changes_metadata (table_name) VALUES ('my_table');

View File

@ -0,0 +1,36 @@
DELIMITER $$
CREATE PROCEDURE DropEntityChangeTables()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE table_name VARCHAR(255);
DECLARE drop_stmt VARCHAR(512);
DECLARE cur CURSOR FOR
SELECT CONCAT('DROP TABLE IF EXISTS entity_changes_', table_name)
FROM entity_changes_metadata
WHERE is_auditable = TRUE;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur;
read_loop: LOOP
FETCH cur INTO drop_stmt;
IF done THEN
LEAVE read_loop;
END IF;
SET @s = drop_stmt;
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur;
END$$
DELIMITER ;
CALL DropEntityChangeTables();

View File

@ -0,0 +1,127 @@
use clap::Parser;
use dotenv::dotenv;
use sqlx::{mysql::MySqlPool, Row};
use std::env;
use url::Url;
/// Command line arguments
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// The name of the schema to operate on
#[arg(long, short)]
schema_name: Option<String>,
/// The database URL, optionally provided. Defaults to the DATABASE_URL environment variable.
#[arg(long, short)]
db_url: Option<String>,
#[arg(long, short)]
env: Option<String>,
#[arg(long)]
delete: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// Parse command-line arguments
let args = Args::parse();
// Load environment variables from .env file
if let Some(env) = args.env {
dotenv::from_filename(env).ok();
} else {
dotenv().ok();
}
// Use provided --db-url or fallback to the DATABASE_URL from .env
let database_url = args
.db_url
.unwrap_or_else(|| env::var("DATABASE_URL").expect("DATABASE_URL must be set"));
// Connect to the TiDB database
let pool = MySqlPool::connect(&database_url).await?;
// Use the schema name from command line arguments
let schema_name = args.schema_name.unwrap_or_else(|| {
let parsed_url = Url::parse(&database_url).expect("Failed to parse database URL");
parsed_url
.path_segments()
.and_then(|segments| segments.last())
.map(|db_name| db_name.to_string())
.expect("No schema (database) name found in the database URL")
});
// Find all tables with 'actor_id' and 'session_id' columns in the specified schema,
// excluding tables that start with 'entity_changes_'
let tables: Vec<String> = sqlx::query(
"SELECT table_name FROM entity_changes_metadata WHERE is_auditable = TRUE"
)
.fetch_all(&pool)
.await?
.iter()
.map(|row| row.get::<String, _>("table_name"))
.collect();
// Iterate over each table and create an entity_changes table and triggers
for table_name in tables {
// Create the corresponding entity_changes table
println!("table_name: {}", table_name);
if args.delete {
let entity_changes_table = format!("entity_changes_{}", table_name);
println!("delete table: {}", entity_changes_table);
sqlx::query(&format!(
"DROP TABLE IF EXISTS {}.{}",
schema_name, entity_changes_table
)).execute(&pool).await?;
} else {
let entity_changes_table = format!("entity_changes_{}", table_name);
println!("create table: {}", entity_changes_table);
sqlx::query(&format!(
"CREATE TABLE IF NOT EXISTS {}.{} (
id BINARY(16) PRIMARY KEY /* T! CLUSTERED */,
entity_id BINARY(16) NOT NULL,
action ENUM('insert', 'update', 'delete', 'restore', 'hard-delete') NOT NULL,
changed_at BIGINT NOT NULL,
actor_id BINARY(16) NOT NULL,
session_id BINARY(16) NOT NULL,
change_set_id BINARY(16) NOT NULL,
new_value JSON
);",
schema_name, entity_changes_table,
)).execute(&pool).await?;
println!("create index _ entity_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_entity_id ON {} (entity_id);",
entity_changes_table, entity_changes_table,
)).execute(&pool).await?;
println!("create index _ change_set_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_change_set_id ON {} (change_set_id);",
entity_changes_table, entity_changes_table,
)).execute(&pool).await?;
println!("create index _ session_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_session_id ON {} (session_id);",
entity_changes_table, entity_changes_table,
)).execute(&pool).await?;
println!("create index _ actor_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_actor_id ON {} (actor_id);",
entity_changes_table, entity_changes_table,
)).execute(&pool).await?;
println!("create index _ entity_id_actor_id");
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_entity_id_actor_id ON {} (entity_id, actor_id);",
entity_changes_table, entity_changes_table,
)).execute(&pool).await?;
}
}
Ok(())
}

1487
entity-update_derive/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
[package]
name = "entity-update_derive"
version = "0.1.6"
edition = "2021"
[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"
sqlx = { version = "0.8", features = ["macros"] }
futures = "0.3"
[features]
default = []
static-validation = []
[lib]
proc-macro = true

View File

@ -0,0 +1,47 @@
# Nandie Software Proprietary License
Copyright (c) 2024 Nandie Software. All rights reserved.
## License Grant
Nandie Software grants you a non-exclusive, non-transferable, revocable license to use the Update Macro for SQLx ("the Software") solely for your internal business purposes, subject to the terms and conditions of this license.
## Restrictions
You may not:
1. Modify, adapt, alter, translate, or create derivative works of the Software.
2. Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software.
3. Remove, alter, or obscure any proprietary notices on the Software.
4. Use the Software for any unlawful purpose.
5. Sublicense, rent, lease, loan, or distribute the Software to any third party.
6. Use the Software to create a competing product.
## Ownership
Nandie Software retains all right, title, and interest in and to the Software, including all intellectual property rights therein.
## Termination
This license is effective until terminated. Nandie Software may terminate this license at any time if you fail to comply with any term of this license. Upon termination, you must cease all use of the Software and destroy all copies.
## Warranty Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. NANDIE SOFTWARE DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING WITHOUT LIMITATION ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
## Limitation of Liability
IN NO EVENT SHALL NANDIE SOFTWARE BE LIABLE FOR ANY SPECIAL, INCIDENTAL, INDIRECT, OR CONSEQUENTIAL DAMAGES WHATSOEVER ARISING OUT OF OR RELATED TO YOUR USE OR INABILITY TO USE THE SOFTWARE.
## Governing Law
This license shall be governed by and construed in accordance with the laws of [Insert Jurisdiction], without regard to its conflict of law provisions.
## Contact Information
If you have any questions about this license, please contact:
Nandie Software
Bryanston, Johannesburg
michael@nandie.com
By using the Software, you acknowledge that you have read this license, understand it, and agree to be bound by its terms and conditions.

View File

@ -0,0 +1,218 @@
# Update Macro for SQLx
## Overview
This derive macro provides a convenient way to generate update forms and related methods for database entities using SQLx. It's designed to work with MySQL databases and provides flexibility in naming conventions for tables and fields. Additionally, it now supports the use of `id`, `code`, or any custom primary key field through the `primary_key` attribute.
## Features
- Generates an update form struct with optional fields
- Creates methods for generating SQL update statements
- Implements a builder pattern for setting update values
- Provides methods for comparing the update form with both the original model and the database record, returning JSON objects
- Allows custom table names and field names through optional attributes
- Supports specifying custom primary keys with the `primary_key` attribute
- Generates a method to get the table name for use in SQL queries
- Includes an `initial_diff` method to get a JSON representation of all fields in the model
- Supports database transactions for atomic operations
- Optimized for better performance with large structs
## Installation
Add the following to your `Cargo.toml`:
```toml
[dependencies]
entity_update_derive = { path = "path/to/entity_update_derive" }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "json"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
```
## Usage
### Basic Usage with Default Primary Key (`id` or `code`)
```rust
use entity_update_derive::Update;
use sqlx::FromRow;
#[derive(Update, FromRow, Debug)]
struct User {
id: i32,
name: String,
email: Option<String>,
}
```
This will generate a `UserUpdateForm` struct and associated methods, with `id` as the primary key. If your struct uses `code` as the primary key, the macro will automatically detect and use it.
### Specifying a Custom Primary Key
You can specify a custom primary key field using the `primary_key` attribute:
```rust
#[derive(Update, FromRow, Debug)]
struct User {
#[primary_key]
code: String,
name: String,
email: Option<String>,
}
```
### Custom Table Name
You can specify a custom table name using the `table_name` attribute:
```rust
#[derive(Update, FromRow, Debug)]
#[table_name("customers")]
struct User {
id: i32,
name: String,
email: Option<String>,
}
```
### Custom Field Names
You can specify custom field names for the database using the `rename` attribute:
```rust
#[derive(Update, FromRow, Debug)]
struct User {
id: i32,
#[rename("user_name")]
name: String,
#[rename("user_email")]
email: Option<String>,
}
```
### Using the Generated Update Form with Transactions
```rust
use sqlx::mysql::MySqlPool;
use entity_update_derive::Update;
use sqlx::FromRow;
#[derive(Update, FromRow, Debug)]
struct User {
id: i32,
name: String,
email: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = MySqlPool::connect("mysql://username:password@localhost/database_name").await?;
let form = User::update_form()
.with_name("new_username".to_string())
.with_email(Some("new_email@example.com".to_string()));
let user_id = 1;
let mut tx = pool.begin().await?;
form.execute_update(&user_id, &mut tx).await?;
let diff = form.transactional_db_diff(&user_id, &mut tx).await?;
println!("Diff: {:?}", diff);
tx.commit().await?;
println!("Update completed successfully");
Ok(())
}
```
## Generated Methods
### `update_form()`
Creates a new instance of the update form.
```rust
let form = User::update_form();
```
### `with_field_name()`
Builder method for each field in the struct.
```rust
let form = User::update_form()
.with_name("New Name")
.with_email(Some("new_email@example.com"));
```
### `update_stmt()`
Generates the SQL SET clause for the update statement.
```rust
let stmt = form.update_stmt();
```
### `bind_values()`
Binds the update values to a SQLx query.
```rust
let query = sqlx::query("UPDATE users SET ...");
let query = form.bind_values(query);
```
### `model_diff()`
Compares the update form with an instance of the original struct and returns a JSON object.
```rust
let diff: serde_json::Value = form.model_diff(&original_user);
```
### `db_diff()`
Compares the update form with the current database record and returns a JSON object.
```rust
let diff: serde_json::Value = form.db_diff(&user_id, &pool).await?;
```
### `table_name()`
Returns the table name as a static string.
```rust
let table_name = UserUpdateForm::table_name();
```
### `initial_diff()`
Returns a JSON object containing all fields of the original model and their current values.
```rust
let user = User {
id: 1,
name: "John Doe".to_string(),
email: Some("john@example.com".to_string()),
};
let initial_diff: serde_json::Value = user.initial_diff();
```
## Notes
- This macro now supports custom primary key fields using the `primary_key` attribute. If not specified, it defaults to checking for an `id` or `code` field.
- The macro assumes you're using MySQL. Modifications may be needed for other database types.
- Error handling is minimal in these examples. In a production environment, you should implement proper error handling and logging.
- The `model_diff()`, `db_diff()`, and `initial_diff()` methods return `serde_json::Value` objects, which are easy to work with when dealing with JSON data.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the Nandie Software Proprietary License. See the LICENSE file in the project root for the full license text.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,158 @@
pub(crate) fn to_snake_case(name: &str) -> String {
let mut result = String::with_capacity(name.len() + 4);
let mut chars = name.chars().peekable();
while let Some(current) = chars.next() {
if let Some(_next) = chars.peek() {
if current.is_uppercase() {
if !result.is_empty() {
result.push('_');
}
result.push(current.to_lowercase().next().unwrap());
} else {
result.push(current);
}
} else {
// Last character
result.push(current.to_lowercase().next().unwrap());
}
}
pluralize(&result)
}
pub(crate) fn pluralize(word: &str) -> String {
if word.is_empty() {
return String::new();
}
// Handle possessives and existing plurals
if word.ends_with("'s") || word.ends_with("'") ||
word.ends_with("s's") || word.ends_with("s'") {
return word.to_string();
}
// Compound words (handle last word)
if word.contains(" of ") {
let parts: Vec<&str> = word.split(" of ").collect();
return format!("{} of {}", pluralize(parts[0]), parts[1]);
}
// Compound words with hyphens
if word.contains('-') {
let parts: Vec<&str> = word.split('-').collect();
return format!("{}-{}",
pluralize(parts[0]),
parts[1..].join("-")
);
}
// Invariant words (same singular and plural)
match word.to_lowercase().as_str() {
"sheep" | "deer" | "moose" | "swine" | "buffalo" | "fish" | "trout" |
"salmon" | "pike" | "aircraft" | "series" | "species" | "means" |
"crossroads" | "swiss" | "portuguese" | "vietnamese" | "japanese" |
"chinese" | "chassis" | "corps" | "headquarters" | "diabetes" |
"news" | "odds" | "innings" => return word.to_string(),
_ => {}
}
// Irregular plurals
match word.to_lowercase().as_str() {
// People
"person" => "people",
"man" => "men",
"woman" => "women",
"child" => "children",
"tooth" => "teeth",
"foot" => "feet",
"mouse" => "mice",
"louse" => "lice",
"goose" => "geese",
"ox" => "oxen",
// Latin/Greek endings
"alumnus" => "alumni",
"alga" => "algae",
"larva" => "larvae",
"vertex" => "vertices",
"index" => "indices",
"matrix" => "matrices",
"criterion" => "criteria",
"phenomenon" => "phenomena",
"datum" => "data",
"medium" => "media",
"analysis" => "analyses",
"thesis" => "theses",
"crisis" => "crises",
"appendix" => "appendices",
"stimulus" => "stimuli",
"radius" => "radii",
"axis" => "axes",
"hypothesis" => "hypotheses",
"basis" => "bases",
"diagnosis" => "diagnoses",
"formula" => "formulae",
"fungus" => "fungi",
"nucleus" => "nuclei",
"syllabus" => "syllabi",
"focus" => "foci",
"cactus" => "cacti",
"bacterium" => "bacteria",
"curriculum" => "curricula",
"memorandum" => "memoranda",
"millennium" => "millennia",
_ => return apply_general_rules(word),
}.to_string()
}
fn apply_general_rules(word: &str) -> String {
// Words ending in -o
if word.ends_with('o') {
match word.to_lowercase().as_str() {
// -o → -oes
w if matches!(w, "hero" | "potato" | "tomato" | "echo" |
"tornado" | "torpedo" | "veto" | "mosquito" |
"volcano" | "buffalo" | "domino" | "embargo") => {
return format!("{}es", word);
}
// -o → -os
_ => return format!("{}s", word),
}
}
// Words ending in -f or -fe
if word.ends_with('f') {
match word.to_lowercase().as_str() {
w if matches!(w, "roof" | "belief" | "chief" | "reef") => {
return format!("{}s", word);
}
_ => return format!("{}ves", &word[..word.len() - 1]),
}
}
if word.ends_with("fe") {
return format!("{}ves", &word[..word.len() - 2]);
}
// Words ending in -y
if word.ends_with('y') {
let chars: Vec<char> = word.chars().collect();
if chars.len() > 1 {
let before_y = chars[chars.len() - 2];
if !matches!(before_y, 'a' | 'e' | 'i' | 'o' | 'u') {
return format!("{}ies", &word[..word.len() - 1]);
}
}
return format!("{}s", word);
}
// Words ending in sibilants (-s, -ss, -sh, -ch, -x, -z)
if word.ends_with('s') || word.ends_with("ss") ||
word.ends_with("sh") || word.ends_with("ch") ||
word.ends_with('x') || word.ends_with('z') {
return format!("{}es", word);
}
// Default case: add 's'
format!("{}s", word)
}

18
scripts/tag-version.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# Fetch the version from Cargo.toml
version=$(grep '^version =' Cargo.toml | sed -E 's/version = "(.*)"/\1/')
# Check if the version is retrieved
if [ -z "$version" ]; then
echo "Version not found in Cargo.toml"
exit 1
fi
# Create a git tag with the version
git tag -a "v$version" -m "Version $version"
# Push the tag to the remote repository
git push origin "v$version"
echo "Tagged the current commit with version v$version"

176
src/condition.rs Normal file
View File

@ -0,0 +1,176 @@
use crate::prelude::Value;
#[derive(Clone, Debug)]
pub enum Condition<'a> {
Equal(&'a str, Value),
NotEqual(&'a str, Value),
GreaterThan(&'a str, Value),
GreaterThanOrEqual(&'a str, Value),
LessThan(&'a str, Value),
LessThanOrEqual(&'a str, Value),
Like(&'a str, Value),
ILike(&'a str, Value),
NotLike(&'a str, Value),
In(&'a str, Vec<Value>),
NotIn(&'a str, Vec<Value>),
IsNull(&'a str),
IsNotNull(&'a str),
And(Vec<Condition<'a>>),
Or(Vec<Condition<'a>>),
}
impl<'a, T: Into<Value>> From<(&'a str, T)> for Condition<'a> {
fn from((field, value): (&'a str, T)) -> Self {
Condition::Equal(field, value.into())
}
}
#[macro_export]
macro_rules! condition_or {
($x:expr) => (
$crate::prelude::Condition::Or(vec![<$crate::prelude::Condition<'_>>::from($x)])
);
($($x:expr),+ $(,)?) => (
$crate::prelude::Condition::Or(vec![$(<$crate::prelude::Condition<'_>>::from($x)),+])
);
}
#[macro_export]
macro_rules! condition_and {
($x:expr) => (
$crate::prelude::Condition::And(vec![<$crate::prelude::Condition<'_>>::from($x)])
);
($($x:expr),+ $(,)?) => (
$crate::prelude::Condition::And(vec![$(<$crate::prelude::Condition<'_>>::from($x)),+])
);
}
#[macro_export]
macro_rules! conditions {
() => {
vec![]
};
($x:expr) => {
vec![<$crate::prelude::Condition<'_>>::from($x)]
};
($($x:expr),+ $(,)?) => {
vec![$(<$crate::prelude::Condition<'_>>::from($x)),+]
};
}
pub trait ConditionOps<Rhs = Self> {
type Output;
fn gt(self, rhs: Rhs) -> Self::Output;
fn ge(self, rhs: Rhs) -> Self::Output;
fn lt(self, rhs: Rhs) -> Self::Output;
fn le(self, rhs: Rhs) -> Self::Output;
fn eq(self, rhs: Rhs) -> Self::Output;
fn ne(self, rhs: Rhs) -> Self::Output;
}
// For types that can be converted into Value
impl<T: Into<&'static str>, V: Into<Value>> ConditionOps<V> for T {
type Output = Condition<'static>;
fn gt(self, rhs: V) -> Self::Output {
Condition::GreaterThan(self.into(), rhs.into())
}
fn ge(self, rhs: V) -> Self::Output {
Condition::GreaterThanOrEqual(self.into(), rhs.into())
}
fn lt(self, rhs: V) -> Self::Output {
Condition::LessThan(self.into(), rhs.into())
}
fn le(self, rhs: V) -> Self::Output {
Condition::LessThanOrEqual(self.into(), rhs.into())
}
fn eq(self, rhs: V) -> Self::Output {
Condition::Equal(self.into(), rhs.into())
}
fn ne(self, rhs: V) -> Self::Output {
Condition::NotEqual(self.into(), rhs.into())
}
}
impl Condition<'_> {
pub fn build_where_clause(conditions: &[Condition]) -> (String, Vec<Value>) {
let mut values = Vec::new();
let conditions: Vec<String> = conditions
.iter()
.map(|condition| {
match condition {
Condition::Equal(field, value) => {
values.push(value.clone());
format!("{} = ?", field)
}
Condition::NotEqual(field, value) => {
values.push(value.clone());
format!("{} != ?", field)
}
Condition::GreaterThan(field, value) => {
values.push(value.clone());
format!("{} > ?", field)
}
Condition::GreaterThanOrEqual(field, value) => {
values.push(value.clone());
format!("{} >= ?", field)
}
Condition::LessThan(field, value) => {
values.push(value.clone());
format!("{} < ?", field)
}
Condition::LessThanOrEqual(field, value) => {
values.push(value.clone());
format!("{} <= ?", field)
}
Condition::Like(field, value) => {
values.push(value.clone());
format!("{} LIKE ?", field)
}
Condition::ILike(field, value) => {
values.push(value.clone());
format!("{} ILIKE ?", field)
}
Condition::NotLike(field, value) => {
values.push(value.clone());
format!("{} NOT LIKE ?", field)
}
Condition::In(field, value_vec) => {
let placeholders = vec!["?"; value_vec.len()].join(", ");
values.extend(value_vec.clone());
format!("{} IN ({})", field, placeholders)
}
Condition::NotIn(field, value_vec) => {
let placeholders = vec!["?"; value_vec.len()].join(", ");
values.extend(value_vec.clone());
format!("{} NOT IN ({})", field, placeholders)
}
Condition::IsNull(field) => {
format!("{} IS NULL", field)
}
Condition::IsNotNull(field) => {
format!("{} IS NOT NULL", field)
}
Condition::And(nested_conditions) => {
let (nested_clause, nested_values) = Self::build_where_clause(nested_conditions);
values.extend(nested_values);
format!("({})", nested_clause)
}
Condition::Or(nested_conditions) => {
let (nested_clause, nested_values) = Self::build_where_clause(nested_conditions);
values.extend(nested_values);
format!("({})", nested_clause.split(" AND ").collect::<Vec<_>>().join(" OR "))
}
}
})
.collect();
(conditions.join(" AND "), values)
}
}

29
src/helpers.rs Normal file
View File

@ -0,0 +1,29 @@
#[macro_export]
macro_rules! update_entity_func {
($form_type:ident, $func_name:ident) => { // Changed $form_type:ty to $form_type:ident
pub async fn $func_name<'a, E>(executor: E, id: &Uuid, form: $form_type) -> Result<(), RepositoryError>
where
E: sqlx::Executor<'a, Database=sqlx::MySql>,
{
//If the section exists, we update it
let result = sqlx::query(
format!(r#"
UPDATE {}
SET {}
WHERE id = ?
"#,
$form_type::table_name(),
form.update_stmt()).as_str())
.bind_form_values(form)
.bind(id)
.execute(executor)
.await;
if let Err(err) = result {
tracing::error!("Error updating entity: {:?}", err);
return Err(RepositoryError::from(err));
}
Ok(())
}
};
}

20
src/lib.rs Normal file
View File

@ -0,0 +1,20 @@
pub mod models;
pub mod repositories;
mod helpers;
mod value;
mod condition;
// Re-export the entity_update_derive module on feature flag
#[cfg(feature = "derive")]
pub use entity_update_derive::{Entity, Update};
pub mod prelude {
pub use crate::value::*;
pub use crate::condition::*;
pub use crate::{condition_or, condition_and, conditions, update_entity_func};
pub use crate::{condition_or as or, condition_and as and};
pub use crate::values;
#[cfg(feature = "derive")]
pub use entity_update_derive::{Entity, Update};
}

53
src/models.rs Normal file
View File

@ -0,0 +1,53 @@
use std::fmt::Display;
use sqlx::FromRow;
use uuid::Uuid;
use serde_json::Value;
#[derive(Debug, FromRow)]
pub struct EntityChange {
pub id: Uuid,
pub entity_id: Uuid,
pub action: String,
pub changed_at: i64,
pub actor_id: Uuid,
pub session_id: Uuid,
pub change_set_id: Uuid,
pub new_value: Option<Value>,
}
#[derive(Debug)]
pub enum Action {
Insert,
Update,
Delete,
Restore,
HardDelete,
Unknown(String),
}
impl From<String> for Action {
fn from(value: String) -> Self {
match &value.to_lowercase()[..] {
"insert" => Action::Insert,
"update" => Action::Update,
"delete" => Action::Delete,
"restore" => Action::Restore,
"hard-delete" => Action::HardDelete,
_ => Action::Unknown(value),
}
}
}
impl Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
Action::Insert => "insert".to_string(),
Action::Update => "update".to_string(),
Action::Delete => "delete".to_string(),
Action::Restore => "restore".to_string(),
Action::HardDelete => "hard-delete".to_string(),
Action::Unknown(value) => value.to_string(),
};
write!(f, "{}", str)
}
}

176
src/repositories.rs Normal file
View File

@ -0,0 +1,176 @@
use sqlx::{Error, MySqlExecutor};
use uuid::Uuid;
use crate::models::EntityChange;
pub async fn create_entity_change<'q>(
conn: impl MySqlExecutor<'q>,
table_name: &str,
change: &EntityChange,
) -> Result<(), sqlx::Error> {
let query = format!(
r#"INSERT INTO `{}` (
id, entity_id, action, changed_at, actor_id,
session_id, change_set_id, new_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"#,
table_name);
sqlx::query(&query)
.bind(&change.id)
.bind(&change.entity_id)
.bind(&change.action)
.bind(&change.changed_at)
.bind(&change.actor_id)
.bind(&change.session_id)
.bind(&change.change_set_id)
.bind(&change.new_value)
.execute(conn)
.await?;
Ok(())
}
pub async fn get_entity_changes_by_id<'q>(
conn: impl MySqlExecutor<'q>,
table_name: &str, id: &Uuid) -> Result<Vec<EntityChange>, Error> {
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(id)
.fetch_all(conn)
.await?;
Ok(changes)
}
pub async fn get_entity_changes_by_entity<'q>(
conn: impl MySqlExecutor<'q>,
table_name: &str, entity_id: &Uuid) -> Result<Vec<EntityChange>, Error> {
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE entity_id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(entity_id)
.fetch_all(conn)
.await?;
Ok(changes)
}
pub async fn get_entity_changes_session<'q>(
conn: impl MySqlExecutor<'q>,
table_name: &str, session_id: &Uuid,
) -> Result<Vec<EntityChange>, Error>{
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE session_id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(session_id)
.fetch_all(conn)
.await?;
Ok(changes)
}
pub async fn get_entity_changes_actor<'q>(
conn: impl MySqlExecutor<'q>,
table_name: &str, actor_id: &Uuid) -> Result<Vec<EntityChange>, Error>{
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE actor_id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(actor_id)
.fetch_all(conn)
.await?;
Ok(changes)
}
pub async fn get_entity_changes_by_change_set<'q>(
conn: impl MySqlExecutor<'q>, table_name: &str, change_set_id: &Uuid) -> Result<Vec<EntityChange>, Error>
{
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE change_set_id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(change_set_id)
.fetch_all(conn)
.await?;
Ok(changes)
}
pub async fn get_entity_changes_by_entity_and_actor<'q>(
conn: impl MySqlExecutor<'q>, table_name: &str, entity_id: &Uuid, actor_id: &Uuid) -> Result<Vec<EntityChange>, Error>{
let query = format!(
r#"SELECT
id,
entity_id,
action,
changed_at,
actor_id,
session_id,
change_set_id,
new_value
FROM `{}` WHERE entity_id = ? AND actor_id = ?"#,
table_name);
let changes = sqlx::query_as::<_, EntityChange>(&query)
.bind(entity_id)
.bind(actor_id)
.fetch_all(conn)
.await?;
Ok(changes)
}

208
src/value.rs Normal file
View File

@ -0,0 +1,208 @@
use sqlx::MySql;
use sqlx::mysql::MySqlArguments;
use sqlx::query::{Query, QueryAs, QueryScalar};
use sqlx::types::chrono::{NaiveDate, NaiveDateTime};
pub type DB = MySql;
#[derive(Clone, Debug)]
pub enum Value {
Int8(i8),
Uint8(u8),
Int16(i16),
Uint16(u16),
Int32(i32),
Uint32(u32),
Int64(i64),
Uint64(u64),
VecU8(Vec<u8>),
String(String),
Bool(bool),
Uuid(uuid::Uuid),
NaiveDate(NaiveDate),
NaiveDateTime(NaiveDateTime),
}
pub enum Updater<'a> {
Set(&'a str, Value),
Increment(&'a str, Value),
Decrement(&'a str, Value),
}
#[deprecated(since = "0.1.0", note = "Please use Value instead")]
pub type SqlValue = Value;
macro_rules! bind_value {
($query:expr, $value: expr) => {{
let query = match $value {
Value::Int8(v) => $query.bind(v),
Value::Uint8(v) => $query.bind(v),
Value::Int16(v) => $query.bind(v),
Value::Uint16(v) => $query.bind(v),
Value::Int32(v) => $query.bind(v),
Value::Uint32(v) => $query.bind(v),
Value::Int64(v) => $query.bind(v),
Value::Uint64(v) => $query.bind(v),
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
}};
}
pub fn bind_values<'q>(query: Query<'q, DB, MySqlArguments>, values: &'q [Value]) -> Query<'q, DB, MySqlArguments> {
let mut query = query;
for value in values {
query = bind_value!(query, value);
}
query
}
pub fn bind_as_values<'q, O>(query: QueryAs<'q, DB, O, MySqlArguments>, values: &'q [Value]) -> QueryAs<'q, DB, O, MySqlArguments> {
values.into_iter().fold(query, |query, value| {
bind_value!(query, value)
})
}
pub fn bind_scalar_values<'q, O>(query: QueryScalar<'q, DB, O, MySqlArguments>, values: &'q [Value]) -> QueryScalar<'q, DB, O, MySqlArguments> {
let mut query = query;
for value in values {
query = bind_value!(query, value);
}
query
}
#[inline]
pub fn query_fields(fields: Vec<&str>) -> String {
fields.iter().filter_map(|e| e.split(" ").next())
.collect::<Vec<_>>().join(", ")
}
impl From<String> for Value {
fn from(value: String) -> Self {
Value::String(value)
}
}
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<&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<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;
fn bind_values(self, values: &'q [Value]) -> Self::Output;
}
impl<'q> BindValues<'q> for Query<'q, DB, MySqlArguments> {
type Output = Query<'q, DB, MySqlArguments>;
fn bind_values(self, values: &'q [Value]) -> Self::Output {
let mut query = self;
for value in values {
query = bind_value!(query, value);
}
query
}
}
impl<'q, O> BindValues<'q> for QueryAs<'q, DB, O, MySqlArguments> {
type Output = QueryAs<'q, DB, O, MySqlArguments>;
fn bind_values(self, values: &'q [Value]) -> Self::Output {
values.into_iter().fold(self, |query, value| {
bind_value!(query, value)
})
}
}
impl<'q, O> BindValues<'q> for QueryScalar<'q, DB, O, MySqlArguments> {
type Output = QueryScalar<'q, DB, O, MySqlArguments>;
fn bind_values(self, values: &'q [Value]) -> Self::Output {
let mut query = self;
for value in values {
query = bind_value!(query, value);
}
query
}
}
#[macro_export]
macro_rules! values {
() => {
vec![]
};
($x:expr) => {
vec![<$crate::prelude::Value>::from($x)]
};
($($x:expr),+ $(,)?) => {
vec![$(<$crate::prelude::Value>::from($x)),+]
};
}