Skip to main content

ferro_projection/
entity.rs

1//! SeaORM `Entity` / `Model` / `ActiveModel` / `Column` / `Relation` for
2//! the `projection_snapshots` table (D-24, D-25, D-26, D-27).
3//!
4//! Schema authority is `migration.rs` (`CreateProjectionSnapshotsTable`).
5//! This module's `Model` shape must match the migration's column
6//! declarations exactly.
7//!
8//! Composite primary key on `(projection_name, key)` is signaled to
9//! `DeriveEntityModel` by annotating BOTH fields with
10//! `#[sea_orm(primary_key, auto_increment = false)]`. SeaORM generates
11//! the composite `PrimaryKey` impl automatically. Composite-PK lookups
12//! use a tuple: `Entity::find_by_id((name_value, key_value))`.
13
14use sea_orm::entity::prelude::*;
15use serde_json::Value as JsonValue;
16
17#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
18#[sea_orm(table_name = "projection_snapshots")]
19pub struct Model {
20    /// Projection logical name, e.g. `"inventory.dashboard"`. First
21    /// half of the composite primary key (D-24).
22    #[sea_orm(primary_key, auto_increment = false)]
23    pub projection_name: String,
24
25    /// Per-row key inside the projection, e.g. `"warehouse-a"`.
26    /// Second half of the composite primary key (D-24).
27    #[sea_orm(primary_key, auto_increment = false)]
28    pub key: String,
29
30    /// Serialized `P::State` (D-26 — JSON column).
31    pub state: JsonValue,
32
33    /// Monotonic counter (D-25); +1 per apply, reset on rebuild.
34    pub version: i64,
35
36    /// App-set `Utc::now()` inside the upsert (D-27).
37    pub updated_at: DateTime,
38}
39
40#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
41pub enum Relation {}
42
43impl ActiveModelBehavior for ActiveModel {}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use sea_orm::{ActiveValue, Database, EntityTrait};
49    use sea_orm_migration::MigratorTrait;
50
51    struct TestMigrator;
52
53    #[async_trait::async_trait]
54    impl MigratorTrait for TestMigrator {
55        fn migrations() -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
56            vec![Box::new(crate::migration::Migration)]
57        }
58    }
59
60    async fn fresh_db() -> sea_orm::DatabaseConnection {
61        let conn = Database::connect("sqlite::memory:").await.expect("connect");
62        TestMigrator::up(&conn, None).await.expect("migrate");
63        conn
64    }
65
66    #[tokio::test]
67    async fn round_trip_with_composite_pk() {
68        let conn = fresh_db().await;
69
70        let name = "test.projection";
71        let key = "test-key";
72        let state = serde_json::json!({ "count": 7 });
73        let version: i64 = 1;
74        let updated_at = chrono::Utc::now().naive_utc();
75
76        let am = ActiveModel {
77            projection_name: ActiveValue::Set(name.to_string()),
78            key: ActiveValue::Set(key.to_string()),
79            state: ActiveValue::Set(state.clone()),
80            version: ActiveValue::Set(version),
81            updated_at: ActiveValue::Set(updated_at),
82        };
83        Entity::insert(am).exec(&conn).await.expect("insert");
84
85        // Composite-PK lookup form: tuple of (projection_name, key)
86        let fetched = Entity::find_by_id((name.to_string(), key.to_string()))
87            .one(&conn)
88            .await
89            .expect("query")
90            .expect("found");
91
92        assert_eq!(fetched.projection_name, name);
93        assert_eq!(fetched.key, key);
94        assert_eq!(fetched.state, state);
95        assert_eq!(fetched.version, version);
96        assert_eq!(fetched.updated_at, updated_at);
97    }
98
99    #[tokio::test]
100    async fn duplicate_composite_pk_is_constraint_violation() {
101        let conn = fresh_db().await;
102
103        let name = "dup.projection";
104        let key = "dup-key";
105        let state = serde_json::json!({});
106        let now = chrono::Utc::now().naive_utc();
107
108        let am1 = ActiveModel {
109            projection_name: ActiveValue::Set(name.to_string()),
110            key: ActiveValue::Set(key.to_string()),
111            state: ActiveValue::Set(state.clone()),
112            version: ActiveValue::Set(1),
113            updated_at: ActiveValue::Set(now),
114        };
115        Entity::insert(am1).exec(&conn).await.expect("first insert");
116
117        // Second insert with same (name, key) MUST fail — proves the
118        // composite PK constraint actually fires at the DB level.
119        let am2 = ActiveModel {
120            projection_name: ActiveValue::Set(name.to_string()),
121            key: ActiveValue::Set(key.to_string()),
122            state: ActiveValue::Set(state),
123            version: ActiveValue::Set(2),
124            updated_at: ActiveValue::Set(now),
125        };
126        let result = Entity::insert(am2).exec(&conn).await;
127        assert!(
128            result.is_err(),
129            "second insert with same composite PK should fail"
130        );
131    }
132}