Skip to main content

ferro_audit/
migration.rs

1//! `CreateAuditLogTable` — SeaORM migration that creates the `audit_log`
2//! table (D-18..D-22) and its two composite indexes (D-20).
3//!
4//! Consumers register this migration in their own `Migrator`:
5//! ```rust,ignore
6//! impl MigratorTrait for Migrator {
7//!     fn migrations() -> Vec<Box<dyn MigrationTrait>> {
8//!         vec![
9//!             Box::new(ferro_audit::CreateAuditLogTable),
10//!             // ... your app migrations
11//!         ]
12//!     }
13//! }
14//! ```
15
16use sea_orm_migration::prelude::*;
17
18#[derive(DeriveMigrationName)]
19pub struct Migration;
20
21#[async_trait::async_trait]
22impl MigrationTrait for Migration {
23    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
24        manager
25            .create_table(
26                Table::create()
27                    .table(AuditLog::Table)
28                    .if_not_exists()
29                    .col(ColumnDef::new(AuditLog::Id).uuid().not_null().primary_key())
30                    .col(ColumnDef::new(AuditLog::TenantId).string().null())
31                    .col(ColumnDef::new(AuditLog::ActorKind).string().not_null())
32                    .col(ColumnDef::new(AuditLog::ActorId).string().null())
33                    .col(ColumnDef::new(AuditLog::Action).string().not_null())
34                    .col(ColumnDef::new(AuditLog::TargetKind).string().null())
35                    .col(ColumnDef::new(AuditLog::TargetId).string().null())
36                    .col(ColumnDef::new(AuditLog::Before).json().null())
37                    .col(ColumnDef::new(AuditLog::After).json().null())
38                    .col(ColumnDef::new(AuditLog::Reason).string().null())
39                    .col(ColumnDef::new(AuditLog::CorrelationId).uuid().null())
40                    .col(
41                        ColumnDef::new(AuditLog::CreatedAt)
42                            .timestamp()
43                            .not_null()
44                            .default(Expr::current_timestamp()),
45                    )
46                    .to_owned(),
47            )
48            .await?;
49
50        // idx_audit_target: (tenant_id, target_kind, target_id, created_at)
51        manager
52            .create_index(
53                Index::create()
54                    .name("idx_audit_target")
55                    .table(AuditLog::Table)
56                    .col(AuditLog::TenantId)
57                    .col(AuditLog::TargetKind)
58                    .col(AuditLog::TargetId)
59                    .col(AuditLog::CreatedAt)
60                    .to_owned(),
61            )
62            .await?;
63
64        // idx_audit_actor: (tenant_id, actor_kind, actor_id, created_at)
65        manager
66            .create_index(
67                Index::create()
68                    .name("idx_audit_actor")
69                    .table(AuditLog::Table)
70                    .col(AuditLog::TenantId)
71                    .col(AuditLog::ActorKind)
72                    .col(AuditLog::ActorId)
73                    .col(AuditLog::CreatedAt)
74                    .to_owned(),
75            )
76            .await
77    }
78
79    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
80        manager
81            .drop_table(Table::drop().table(AuditLog::Table).to_owned())
82            .await
83    }
84}
85
86#[derive(DeriveIden)]
87enum AuditLog {
88    Table,
89    Id,
90    TenantId,
91    ActorKind,
92    ActorId,
93    Action,
94    TargetKind,
95    TargetId,
96    Before,
97    After,
98    Reason,
99    CorrelationId,
100    CreatedAt,
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement};
107    use sea_orm_migration::MigratorTrait;
108
109    struct TestMigrator;
110
111    #[async_trait::async_trait]
112    impl MigratorTrait for TestMigrator {
113        fn migrations() -> Vec<Box<dyn MigrationTrait>> {
114            vec![Box::new(super::Migration)]
115        }
116    }
117
118    #[tokio::test]
119    async fn migration_creates_table_and_indexes() {
120        let conn = Database::connect("sqlite::memory:")
121            .await
122            .expect("connect to in-memory sqlite");
123
124        TestMigrator::up(&conn, None)
125            .await
126            .expect("run migration up");
127
128        // Verify the audit_log table exists by querying sqlite_master.
129        let table_row = conn
130            .query_one(Statement::from_string(
131                DatabaseBackend::Sqlite,
132                "SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'"
133                    .to_string(),
134            ))
135            .await
136            .expect("query sqlite_master for table");
137        assert!(
138            table_row.is_some(),
139            "audit_log table not created by migration"
140        );
141
142        // Verify both indexes exist.
143        for idx_name in &["idx_audit_target", "idx_audit_actor"] {
144            let idx_row = conn
145                .query_one(Statement::from_string(
146                    DatabaseBackend::Sqlite,
147                    format!(
148                        "SELECT name FROM sqlite_master WHERE type='index' AND name='{idx_name}'"
149                    ),
150                ))
151                .await
152                .expect("query sqlite_master for index");
153            assert!(
154                idx_row.is_some(),
155                "index {idx_name} not created by migration"
156            );
157        }
158
159        // Verify the migration's `down()` cleans up.
160        TestMigrator::down(&conn, None)
161            .await
162            .expect("run migration down");
163        let table_after_down = conn
164            .query_one(Statement::from_string(
165                DatabaseBackend::Sqlite,
166                "SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'"
167                    .to_string(),
168            ))
169            .await
170            .expect("query sqlite_master after down");
171        assert!(
172            table_after_down.is_none(),
173            "audit_log table should be dropped by down()"
174        );
175    }
176}