use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, SecondsFormat, Utc};
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::migration::DbBackend;
use crate::validation::ErrorBag;
use crate::{Db, Text};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum AttrValue {
Null,
Text(String),
Int(i64),
Float(f64),
Bool(bool),
DateTime(DateTime<Utc>),
Json(Value),
}
impl AttrValue {
pub fn is_null(&self) -> bool {
matches!(self, AttrValue::Null)
}
pub fn to_text(&self) -> Option<String> {
match self {
AttrValue::Null => None,
AttrValue::Text(s) => Some(s.clone()),
AttrValue::Int(i) => Some(i.to_string()),
AttrValue::Float(f) => Some(f.to_string()),
AttrValue::Bool(b) => Some(b.to_string()),
AttrValue::DateTime(dt) => Some(dt.to_rfc3339_opts(SecondsFormat::Micros, true)),
AttrValue::Json(v) => Some(v.to_string()),
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
AttrValue::Text(s) => Some(s),
_ => None,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
AttrValue::Int(i) => Some(*i),
_ => None,
}
}
pub fn as_float(&self) -> Option<f64> {
match self {
AttrValue::Float(f) => Some(*f),
AttrValue::Int(i) => Some(*i as f64),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
AttrValue::Bool(b) => Some(*b),
_ => None,
}
}
pub fn as_datetime(&self) -> Option<DateTime<Utc>> {
match self {
AttrValue::DateTime(dt) => Some(*dt),
_ => None,
}
}
pub fn as_json(&self) -> Option<&Value> {
match self {
AttrValue::Json(v) => Some(v),
_ => None,
}
}
fn to_json(&self) -> Value {
match self {
AttrValue::Null => Value::Null,
AttrValue::Text(s) => Value::String(s.clone()),
AttrValue::Int(i) => Value::from(*i),
AttrValue::Float(f) => Value::from(*f),
AttrValue::Bool(b) => Value::Bool(*b),
AttrValue::DateTime(dt) => {
Value::String(dt.to_rfc3339_opts(SecondsFormat::Micros, true))
}
AttrValue::Json(v) => v.clone(),
}
}
}
impl From<&str> for AttrValue {
fn from(v: &str) -> Self {
AttrValue::Text(v.to_string())
}
}
impl From<String> for AttrValue {
fn from(v: String) -> Self {
AttrValue::Text(v)
}
}
impl From<i64> for AttrValue {
fn from(v: i64) -> Self {
AttrValue::Int(v)
}
}
impl From<f64> for AttrValue {
fn from(v: f64) -> Self {
AttrValue::Float(v)
}
}
impl From<bool> for AttrValue {
fn from(v: bool) -> Self {
AttrValue::Bool(v)
}
}
impl From<DateTime<Utc>> for AttrValue {
fn from(v: DateTime<Utc>) -> Self {
AttrValue::DateTime(v)
}
}
impl From<Value> for AttrValue {
fn from(v: Value) -> Self {
AttrValue::Json(v)
}
}
impl<T: Into<AttrValue>> From<Option<T>> for AttrValue {
fn from(v: Option<T>) -> Self {
v.map_or(AttrValue::Null, Into::into)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Op {
Create,
Update,
}
#[derive(Debug, Clone, Default)]
pub struct Record {
entity: String,
id: Option<i64>,
attrs: BTreeMap<String, AttrValue>,
original: Option<BTreeMap<String, AttrValue>>,
}
impl Record {
pub fn new(entity: impl Into<String>) -> Self {
Self {
entity: entity.into(),
id: None,
attrs: BTreeMap::new(),
original: None,
}
}
pub fn with_id(entity: impl Into<String>, id: i64) -> Self {
Self {
entity: entity.into(),
id: Some(id),
attrs: BTreeMap::new(),
original: None,
}
}
pub fn entity(&self) -> &str {
&self.entity
}
pub fn id(&self) -> Option<i64> {
self.id
}
pub fn set_id(&mut self, id: i64) {
self.id = Some(id);
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<AttrValue>) -> &mut Self {
self.attrs.insert(key.into(), value.into());
self
}
pub fn get(&self, key: &str) -> Option<&AttrValue> {
self.attrs.get(key)
}
pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
self.attrs.remove(key)
}
pub fn contains(&self, key: &str) -> bool {
self.attrs.contains_key(key)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
self.attrs.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn len(&self) -> usize {
self.attrs.len()
}
pub fn is_empty(&self) -> bool {
self.attrs.is_empty()
}
pub fn text(&self, key: &str) -> Option<&str> {
self.get(key).and_then(AttrValue::as_str)
}
pub fn int(&self, key: &str) -> Option<i64> {
self.get(key).and_then(AttrValue::as_int)
}
pub fn float(&self, key: &str) -> Option<f64> {
self.get(key).and_then(AttrValue::as_float)
}
pub fn bool(&self, key: &str) -> Option<bool> {
self.get(key).and_then(AttrValue::as_bool)
}
pub fn datetime(&self, key: &str) -> Option<DateTime<Utc>> {
self.get(key).and_then(AttrValue::as_datetime)
}
pub fn json(&self, key: &str) -> Option<&Value> {
self.get(key).and_then(AttrValue::as_json)
}
pub fn set_original(&mut self, previous: Record) {
self.original = Some(previous.attrs);
}
pub fn has_original(&self) -> bool {
self.original.is_some()
}
pub fn original(&self, key: &str) -> Option<&AttrValue> {
self.original.as_ref().and_then(|prev| prev.get(key))
}
pub fn changed(&self, key: &str) -> bool {
match (&self.original, self.attrs.get(key)) {
(Some(prev), Some(next)) => prev.get(key) != Some(next),
_ => false,
}
}
pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
let map = self
.attrs
.iter()
.map(|(k, v)| (k.clone(), v.to_json()))
.collect::<serde_json::Map<_, _>>();
serde_json::from_value(Value::Object(map))
}
pub fn from_text_map(entity: impl Into<String>, data: &HashMap<String, String>) -> Self {
let mut rec = Record::new(entity);
for (k, v) in data {
rec.set(k.clone(), v.clone());
}
rec
}
pub fn to_text_map(&self) -> HashMap<String, String> {
self.attrs
.iter()
.map(|(k, v)| (k.clone(), v.to_text().unwrap_or_default()))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Actor {
User { id: i64, username: String },
System { process: String },
}
impl Actor {
pub fn user(id: i64, username: impl Into<String>) -> Self {
Actor::User {
id,
username: username.into(),
}
}
pub fn system(process: impl Into<String>) -> Self {
Actor::System {
process: process.into(),
}
}
pub fn user_id(&self) -> Option<i64> {
match self {
Actor::User { id, .. } => Some(*id),
_ => None,
}
}
pub fn label(&self) -> &str {
match self {
Actor::User { username, .. } => username,
Actor::System { process } => process,
}
}
}
pub struct SaveCx<'c> {
backend: DbBackend,
conn: &'c mut sqlx::AnyConnection,
actor: Actor,
}
impl<'c> SaveCx<'c> {
pub fn new(backend: DbBackend, conn: &'c mut sqlx::AnyConnection, actor: Actor) -> Self {
Self {
backend,
conn,
actor,
}
}
pub fn backend(&self) -> DbBackend {
self.backend
}
pub fn conn(&mut self) -> &mut sqlx::AnyConnection {
self.conn
}
pub fn actor(&self) -> &Actor {
&self.actor
}
}
pub struct SavedCx<'a> {
db: &'a Db,
actor: &'a Actor,
}
impl<'a> SavedCx<'a> {
pub fn new(db: &'a Db, actor: &'a Actor) -> Self {
Self { db, actor }
}
pub fn db(&self) -> &Db {
self.db
}
pub fn backend(&self) -> DbBackend {
self.db.backend
}
pub fn actor(&self) -> &Actor {
self.actor
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListenerTarget {
Entity(String),
All,
}
impl ListenerTarget {
pub fn matches(&self, entity: &str) -> bool {
match self {
ListenerTarget::Entity(name) => name == entity,
ListenerTarget::All => true,
}
}
}
#[async_trait]
pub trait ModelListener: Send + Sync + 'static {
async fn before_save(
&self,
cx: &mut SaveCx<'_>,
rec: &mut Record,
op: Op,
) -> Result<(), ErrorBag> {
let _ = (cx, rec, op);
Ok(())
}
async fn after_save(&self, cx: &SavedCx<'_>, rec: &Record, op: Op) {
let _ = (cx, rec, op);
}
async fn before_delete(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<(), Text> {
let _ = (cx, rec);
Ok(())
}
async fn after_delete(&self, cx: &SavedCx<'_>, rec: &Record) {
let _ = (cx, rec);
}
}
pub struct ModelListenerReg {
pub target: ListenerTarget,
pub listener: Arc<dyn ModelListener>,
}
impl ModelListenerReg {
pub fn for_entity(entity: impl Into<String>, listener: Arc<dyn ModelListener>) -> Self {
Self {
target: ListenerTarget::Entity(entity.into()),
listener,
}
}
pub fn all(listener: Arc<dyn ModelListener>) -> Self {
Self {
target: ListenerTarget::All,
listener,
}
}
pub fn matches(&self, entity: &str) -> bool {
self.target.matches(entity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use serde::Deserialize;
fn instant() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 8, 12, 30, 45).unwrap()
}
#[test]
fn attributes_round_trip_through_typed_accessors() {
let mut rec = Record::new("products");
rec.set("name", "Chair")
.set("stock", 4i64)
.set("price", 19.5f64)
.set("active", true)
.set("created_at", instant())
.set("meta", serde_json::json!({"colour": "red"}));
assert_eq!(rec.entity(), "products");
assert_eq!(rec.id(), None);
assert_eq!(rec.text("name"), Some("Chair"));
assert_eq!(rec.int("stock"), Some(4));
assert_eq!(rec.float("price"), Some(19.5));
assert_eq!(rec.bool("active"), Some(true));
assert_eq!(rec.datetime("created_at"), Some(instant()));
assert_eq!(rec.json("meta").unwrap()["colour"], "red");
assert_eq!(rec.len(), 6);
assert_eq!(rec.int("name"), None);
}
#[test]
fn an_int_widens_to_float_but_nothing_else_coerces() {
let mut rec = Record::new("products");
rec.set("stock", 4i64);
assert_eq!(rec.float("stock"), Some(4.0));
assert_eq!(rec.bool("stock"), None);
assert_eq!(rec.text("stock"), None);
}
#[test]
fn absent_and_null_are_different() {
let mut rec = Record::new("products");
rec.set("note", None::<String>);
assert!(rec.contains("note"));
assert!(rec.get("note").unwrap().is_null());
assert!(rec.get("missing").is_none());
assert_eq!(AttrValue::Null.to_text(), None);
}
#[test]
fn timestamps_render_in_the_one_stored_format() {
assert_eq!(
AttrValue::DateTime(instant()).to_text().unwrap(),
"2026-09-08T12:30:45.000000Z"
);
}
#[test]
fn the_text_bridge_survives_a_round_trip() {
let mut data = HashMap::new();
data.insert("name".to_string(), "Chair".to_string());
data.insert("note".to_string(), String::new());
let rec = Record::from_text_map("products", &data);
assert_eq!(rec.text("name"), Some("Chair"));
assert_eq!(rec.text("note"), Some(""));
assert_eq!(rec.to_text_map(), data);
}
#[test]
fn a_null_attribute_writes_empty_through_the_text_bridge() {
let mut rec = Record::new("products");
rec.set("name", "Chair").set("note", None::<String>);
let map = rec.to_text_map();
assert_eq!(map.get("note").map(String::as_str), Some(""));
}
#[test]
fn typed_values_render_for_a_text_storage_layer() {
let mut rec = Record::new("products");
rec.set("stock", 4i64).set("active", false);
let map = rec.to_text_map();
assert_eq!(map.get("stock").map(String::as_str), Some("4"));
assert_eq!(map.get("active").map(String::as_str), Some("false"));
}
#[test]
fn deserialize_gives_a_struct_view_and_ignores_extra_attributes() {
#[derive(Deserialize, PartialEq, Debug)]
struct Product {
name: String,
stock: i64,
active: bool,
}
let mut rec = Record::new("products");
rec.set("name", "Chair")
.set("stock", 4i64)
.set("active", true)
.set("internal_note", "ignore me");
let product: Product = rec.deserialize().unwrap();
assert_eq!(
product,
Product {
name: "Chair".into(),
stock: 4,
active: true
}
);
}
#[test]
fn an_id_is_absent_on_create_and_recorded_after() {
let mut rec = Record::new("products");
assert_eq!(rec.id(), None);
rec.set_id(7);
assert_eq!(rec.id(), Some(7));
assert_eq!(Record::with_id("products", 3).id(), Some(3));
}
#[test]
fn a_target_selects_the_entities_it_runs_for() {
let one = ListenerTarget::Entity("products".into());
assert!(one.matches("products"));
assert!(!one.matches("orders"));
assert!(ListenerTarget::All.matches("anything"));
}
#[test]
fn a_registration_carries_its_target() {
struct Noop;
impl ModelListener for Noop {}
let reg = ModelListenerReg::for_entity("products", Arc::new(Noop));
assert!(reg.matches("products"));
assert!(!reg.matches("orders"));
assert!(ModelListenerReg::all(Arc::new(Noop)).matches("orders"));
}
#[test]
fn a_snapshot_reports_what_this_write_changes() {
let mut previous = Record::with_id("products", 1);
previous.set("name", "Chair").set("stock", 4i64);
let mut rec = Record::with_id("products", 1);
rec.set("name", "Stool").set("stock", 4i64);
assert!(!rec.has_original());
assert!(!rec.changed("name"));
rec.set_original(previous);
assert!(rec.has_original());
assert!(rec.changed("name"));
assert!(!rec.changed("stock"), "an equal value is not a change");
assert!(!rec.changed("absent"));
assert_eq!(
rec.original("name").and_then(AttrValue::as_str),
Some("Chair")
);
}
#[test]
fn attributes_iterate_in_key_order() {
let mut rec = Record::new("products");
rec.set("zebra", "z").set("alpha", "a");
let keys: Vec<_> = rec.iter().map(|(k, _)| k).collect();
assert_eq!(keys, ["alpha", "zebra"]);
}
}