Skip to main content

sql_orm/
audit_runtime.rs

1use crate::AuditEntity;
2use sql_orm_core::{ColumnMetadata, ColumnValue, EntityMetadata, OrmError};
3use std::collections::BTreeSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6/// Persistence operation currently requesting audit values.
7pub enum AuditOperation {
8    /// Insert path for a new row.
9    Insert,
10    /// Update path for an existing row.
11    Update,
12}
13
14#[derive(Debug, Clone, Default, PartialEq)]
15/// Low-level request-scoped audit values.
16///
17/// Most applications can use `with_audit_values(Audit { ... })` with a struct
18/// derived from `AuditFields`. This type remains available for callers that
19/// need to construct `ColumnValue`s manually.
20pub struct AuditRequestValues {
21    values: Vec<ColumnValue>,
22}
23
24impl AuditRequestValues {
25    /// Creates request-scoped audit values from explicit column values.
26    pub fn new(values: Vec<ColumnValue>) -> Self {
27        Self { values }
28    }
29
30    /// Returns the raw audit column values.
31    pub fn values(&self) -> &[ColumnValue] {
32        &self.values
33    }
34}
35
36#[derive(Debug, Clone, Copy)]
37/// Context passed to an `AuditProvider`.
38pub struct AuditContext<'a> {
39    /// Entity metadata for the row being inserted or updated.
40    pub entity: &'static EntityMetadata,
41    /// Persistence operation requesting values.
42    pub operation: AuditOperation,
43    /// Request values already configured on the shared connection.
44    pub request_values: Option<&'a AuditRequestValues>,
45}
46
47/// Runtime provider for audit column values.
48///
49/// Providers are consulted by insert/update paths for entities that declare
50/// `#[orm(audit = Audit)]`. Explicit mutation values win over request values,
51/// and request values win over provider values.
52pub trait AuditProvider: Send + Sync {
53    /// Returns candidate audit values for the current context.
54    fn values(&self, context: AuditContext<'_>) -> Result<Vec<ColumnValue>, OrmError>;
55}
56
57/// Typed audit value conversion generated by `#[derive(AuditFields)]`.
58pub trait AuditValues {
59    /// Consumes the typed audit struct and returns physical column values.
60    fn audit_values(self) -> Vec<ColumnValue>;
61}
62
63pub(crate) fn apply_audit_values<E: AuditEntity>(
64    operation: AuditOperation,
65    values: Vec<ColumnValue>,
66    audit_provider: Option<&dyn AuditProvider>,
67    request_values: Option<&AuditRequestValues>,
68) -> Result<Vec<ColumnValue>, OrmError> {
69    validate_no_duplicate_columns("audit values", &values)?;
70
71    let Some(policy) = E::audit_policy() else {
72        return Ok(values);
73    };
74
75    let context = AuditContext {
76        entity: E::metadata(),
77        operation,
78        request_values,
79    };
80    let provider_values = match audit_provider {
81        Some(provider) => {
82            let values = provider.values(context)?;
83            validate_no_duplicate_columns("audit provider values", &values)?;
84            values
85        }
86        None => Vec::new(),
87    };
88
89    if let Some(request_values) = request_values {
90        validate_no_duplicate_columns("audit request values", request_values.values())?;
91    }
92
93    let mut resolved = values;
94    let mut seen = resolved
95        .iter()
96        .map(|value| value.column_name)
97        .collect::<BTreeSet<_>>();
98
99    for value in &resolved {
100        if let Some(column) = policy
101            .columns
102            .iter()
103            .find(|column| column.column_name == value.column_name)
104        {
105            validate_audit_column_value(operation, column, &value.value)?;
106        }
107    }
108
109    if let Some(request_values) = request_values {
110        append_missing_audit_values(
111            operation,
112            policy.columns,
113            &mut resolved,
114            &mut seen,
115            request_values.values(),
116        )?;
117    }
118
119    append_missing_audit_values(
120        operation,
121        policy.columns,
122        &mut resolved,
123        &mut seen,
124        &provider_values,
125    )?;
126
127    Ok(resolved)
128}
129
130#[doc(hidden)]
131pub fn resolve_audit_values(
132    values: Vec<ColumnValue>,
133    context: AuditContext<'_>,
134    audit_provider: Option<&dyn AuditProvider>,
135) -> Result<Vec<ColumnValue>, OrmError> {
136    validate_no_duplicate_columns("audit values", &values)?;
137
138    let mut resolved = values;
139    let mut seen = resolved
140        .iter()
141        .map(|value| value.column_name)
142        .collect::<BTreeSet<_>>();
143
144    if let Some(request_values) = context.request_values {
145        validate_no_duplicate_columns("audit request values", request_values.values())?;
146        append_missing_values(&mut resolved, &mut seen, request_values.values());
147    }
148
149    if let Some(provider) = audit_provider {
150        let provider_values = provider.values(context)?;
151        validate_no_duplicate_columns("audit provider values", &provider_values)?;
152        append_missing_values(&mut resolved, &mut seen, &provider_values);
153    }
154
155    Ok(resolved)
156}
157
158fn append_missing_audit_values(
159    operation: AuditOperation,
160    columns: &'static [ColumnMetadata],
161    resolved: &mut Vec<ColumnValue>,
162    seen: &mut BTreeSet<&'static str>,
163    values: &[ColumnValue],
164) -> Result<(), OrmError> {
165    for value in values {
166        let Some(column) = columns
167            .iter()
168            .find(|column| column.column_name == value.column_name)
169        else {
170            continue;
171        };
172
173        validate_audit_column_value(operation, column, &value.value)?;
174
175        if seen.insert(value.column_name) {
176            resolved.push(value.clone());
177        }
178    }
179
180    Ok(())
181}
182
183fn validate_audit_column_value(
184    operation: AuditOperation,
185    column: &ColumnMetadata,
186    value: &sql_orm_core::SqlValue,
187) -> Result<(), OrmError> {
188    match operation {
189        AuditOperation::Insert if !column.insertable => {
190            return Err(OrmError::new(format!(
191                "audit insert column `{}` is not insertable",
192                column.column_name
193            )));
194        }
195        AuditOperation::Update if !column.updatable => {
196            return Err(OrmError::new(format!(
197                "audit update column `{}` is not updatable",
198                column.column_name
199            )));
200        }
201        _ => {}
202    }
203
204    if value.is_null() && !column.nullable {
205        return Err(OrmError::new(format!(
206            "audit column `{}` is not nullable",
207            column.column_name
208        )));
209    }
210
211    Ok(())
212}
213
214fn validate_no_duplicate_columns(label: &str, values: &[ColumnValue]) -> Result<(), OrmError> {
215    let mut seen = BTreeSet::new();
216
217    for value in values {
218        if !seen.insert(value.column_name) {
219            return Err(OrmError::new(format!(
220                "duplicate column `{}` in {label}",
221                value.column_name
222            )));
223        }
224    }
225
226    Ok(())
227}
228
229fn append_missing_values(
230    resolved: &mut Vec<ColumnValue>,
231    seen: &mut BTreeSet<&'static str>,
232    values: &[ColumnValue],
233) {
234    for value in values {
235        if seen.insert(value.column_name) {
236            resolved.push(value.clone());
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{
244        AuditContext, AuditOperation, AuditProvider, AuditRequestValues, apply_audit_values,
245        resolve_audit_values,
246    };
247    use crate::AuditEntity;
248    use sql_orm_core::{
249        ColumnMetadata, ColumnValue, Entity, EntityMetadata, EntityPolicyMetadata, OrmError,
250        PrimaryKeyMetadata, SqlServerType, SqlValue,
251    };
252
253    struct TestAuditedEntity;
254
255    static TEST_ENTITY_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
256        rust_field: "id",
257        column_name: "id",
258        renamed_from: None,
259        sql_type: SqlServerType::BigInt,
260        nullable: false,
261        primary_key: true,
262        identity: None,
263        default_sql: None,
264        computed_sql: None,
265        rowversion: false,
266        insertable: false,
267        updatable: false,
268        max_length: None,
269        precision: None,
270        scale: None,
271    }];
272
273    static TEST_ENTITY_METADATA: EntityMetadata = EntityMetadata {
274        rust_name: "AuditedEntity",
275        schema: "dbo",
276        table: "audited_entities",
277        renamed_from: None,
278        columns: &TEST_ENTITY_COLUMNS,
279        primary_key: PrimaryKeyMetadata::new(None, &["id"]),
280        indexes: &[],
281        foreign_keys: &[],
282        navigations: &[],
283    };
284
285    static TEST_AUDIT_COLUMNS: [ColumnMetadata; 3] = [
286        ColumnMetadata {
287            rust_field: "created_at",
288            column_name: "created_at",
289            renamed_from: None,
290            sql_type: SqlServerType::DateTime2,
291            nullable: false,
292            primary_key: false,
293            identity: None,
294            default_sql: Some("SYSUTCDATETIME()"),
295            computed_sql: None,
296            rowversion: false,
297            insertable: false,
298            updatable: false,
299            max_length: None,
300            precision: None,
301            scale: None,
302        },
303        ColumnMetadata {
304            rust_field: "created_by",
305            column_name: "created_by",
306            renamed_from: None,
307            sql_type: SqlServerType::BigInt,
308            nullable: false,
309            primary_key: false,
310            identity: None,
311            default_sql: None,
312            computed_sql: None,
313            rowversion: false,
314            insertable: true,
315            updatable: true,
316            max_length: None,
317            precision: None,
318            scale: None,
319        },
320        ColumnMetadata {
321            rust_field: "updated_by",
322            column_name: "updated_by",
323            renamed_from: None,
324            sql_type: SqlServerType::NVarChar,
325            nullable: true,
326            primary_key: false,
327            identity: None,
328            default_sql: None,
329            computed_sql: None,
330            rowversion: false,
331            insertable: true,
332            updatable: true,
333            max_length: Some(120),
334            precision: None,
335            scale: None,
336        },
337    ];
338
339    impl Entity for TestAuditedEntity {
340        fn metadata() -> &'static EntityMetadata {
341            &TEST_ENTITY_METADATA
342        }
343    }
344
345    impl AuditEntity for TestAuditedEntity {
346        fn audit_policy() -> Option<EntityPolicyMetadata> {
347            Some(EntityPolicyMetadata::new("audit", &TEST_AUDIT_COLUMNS))
348        }
349    }
350
351    struct FixedAuditProvider;
352
353    impl AuditProvider for FixedAuditProvider {
354        fn values(&self, context: AuditContext<'_>) -> Result<Vec<ColumnValue>, OrmError> {
355            assert_eq!(context.entity.rust_name, "AuditedEntity");
356            assert_eq!(context.operation, AuditOperation::Insert);
357            assert!(context.request_values.is_some());
358
359            Ok(vec![
360                ColumnValue::new(
361                    "created_at",
362                    SqlValue::String("provider-created-at".to_string()),
363                ),
364                ColumnValue::new(
365                    "updated_by",
366                    SqlValue::String("provider-updated-by".to_string()),
367                ),
368            ])
369        }
370    }
371
372    fn context<'a>(request_values: Option<&'a AuditRequestValues>) -> AuditContext<'a> {
373        AuditContext {
374            entity: &TEST_ENTITY_METADATA,
375            operation: AuditOperation::Insert,
376            request_values,
377        }
378    }
379
380    #[test]
381    fn apply_audit_values_completes_only_missing_insertable_audit_columns() {
382        let request_values = AuditRequestValues::new(vec![
383            ColumnValue::new("created_by", SqlValue::I64(7)),
384            ColumnValue::new(
385                "ignored",
386                SqlValue::String("not an audit column".to_string()),
387            ),
388        ]);
389
390        struct Provider;
391
392        impl AuditProvider for Provider {
393            fn values(&self, _context: AuditContext<'_>) -> Result<Vec<ColumnValue>, OrmError> {
394                Ok(vec![
395                    ColumnValue::new("created_by", SqlValue::I64(9)),
396                    ColumnValue::new("updated_by", SqlValue::String("provider".to_string())),
397                    ColumnValue::new("other", SqlValue::String("not an audit column".to_string())),
398                ])
399            }
400        }
401
402        let values = apply_audit_values::<TestAuditedEntity>(
403            AuditOperation::Insert,
404            vec![ColumnValue::new(
405                "name",
406                SqlValue::String("existing".to_string()),
407            )],
408            Some(&Provider),
409            Some(&request_values),
410        )
411        .expect("audit insert values should resolve");
412
413        assert_eq!(
414            values,
415            vec![
416                ColumnValue::new("name", SqlValue::String("existing".to_string())),
417                ColumnValue::new("created_by", SqlValue::I64(7)),
418                ColumnValue::new("updated_by", SqlValue::String("provider".to_string())),
419            ]
420        );
421    }
422
423    #[test]
424    fn apply_audit_values_rejects_duplicate_insert_columns() {
425        let error = apply_audit_values::<TestAuditedEntity>(
426            AuditOperation::Insert,
427            vec![
428                ColumnValue::new("created_by", SqlValue::I64(7)),
429                ColumnValue::new("created_by", SqlValue::I64(8)),
430            ],
431            None,
432            None,
433        )
434        .unwrap_err();
435
436        assert_eq!(
437            error,
438            OrmError::new("duplicate column `created_by` in audit values")
439        );
440    }
441
442    #[test]
443    fn apply_audit_values_rejects_non_insertable_audit_column() {
444        let request_values = AuditRequestValues::new(vec![ColumnValue::new(
445            "created_at",
446            SqlValue::String("runtime".to_string()),
447        )]);
448
449        let error = apply_audit_values::<TestAuditedEntity>(
450            AuditOperation::Insert,
451            Vec::new(),
452            None,
453            Some(&request_values),
454        )
455        .unwrap_err();
456
457        assert_eq!(
458            error,
459            OrmError::new("audit insert column `created_at` is not insertable")
460        );
461    }
462
463    #[test]
464    fn apply_audit_values_rejects_null_for_non_nullable_audit_column() {
465        let request_values =
466            AuditRequestValues::new(vec![ColumnValue::new("created_by", SqlValue::Null)]);
467
468        let error = apply_audit_values::<TestAuditedEntity>(
469            AuditOperation::Insert,
470            Vec::new(),
471            None,
472            Some(&request_values),
473        )
474        .unwrap_err();
475
476        assert_eq!(
477            error,
478            OrmError::new("audit column `created_by` is not nullable")
479        );
480    }
481
482    #[test]
483    fn apply_audit_values_completes_only_missing_updatable_audit_columns() {
484        struct Provider;
485
486        impl AuditProvider for Provider {
487            fn values(&self, _context: AuditContext<'_>) -> Result<Vec<ColumnValue>, OrmError> {
488                Ok(vec![
489                    ColumnValue::new("created_by", SqlValue::I64(9)),
490                    ColumnValue::new("updated_by", SqlValue::String("provider".to_string())),
491                ])
492            }
493        }
494
495        let values = apply_audit_values::<TestAuditedEntity>(
496            AuditOperation::Update,
497            vec![
498                ColumnValue::new("name", SqlValue::String("updated".to_string())),
499                ColumnValue::new("updated_by", SqlValue::String("explicit".to_string())),
500            ],
501            Some(&Provider),
502            None,
503        )
504        .expect("audit update values should resolve");
505
506        assert_eq!(
507            values,
508            vec![
509                ColumnValue::new("name", SqlValue::String("updated".to_string())),
510                ColumnValue::new("updated_by", SqlValue::String("explicit".to_string())),
511                ColumnValue::new("created_by", SqlValue::I64(9)),
512            ]
513        );
514    }
515
516    #[test]
517    fn apply_audit_values_rejects_non_updatable_audit_column() {
518        let error = apply_audit_values::<TestAuditedEntity>(
519            AuditOperation::Update,
520            vec![ColumnValue::new(
521                "created_at",
522                SqlValue::String("runtime".to_string()),
523            )],
524            None,
525            None,
526        )
527        .unwrap_err();
528
529        assert_eq!(
530            error,
531            OrmError::new("audit update column `created_at` is not updatable")
532        );
533    }
534
535    #[test]
536    fn resolve_audit_values_preserves_user_values_before_request_and_provider_values() {
537        let request_values = AuditRequestValues::new(vec![
538            ColumnValue::new(
539                "created_at",
540                SqlValue::String("request-created-at".to_string()),
541            ),
542            ColumnValue::new(
543                "created_by",
544                SqlValue::String("request-created-by".to_string()),
545            ),
546        ]);
547
548        let resolved = resolve_audit_values(
549            vec![ColumnValue::new(
550                "created_at",
551                SqlValue::String("user-created-at".to_string()),
552            )],
553            context(Some(&request_values)),
554            Some(&FixedAuditProvider),
555        )
556        .expect("audit values should resolve");
557
558        assert_eq!(
559            resolved,
560            vec![
561                ColumnValue::new(
562                    "created_at",
563                    SqlValue::String("user-created-at".to_string())
564                ),
565                ColumnValue::new(
566                    "created_by",
567                    SqlValue::String("request-created-by".to_string())
568                ),
569                ColumnValue::new(
570                    "updated_by",
571                    SqlValue::String("provider-updated-by".to_string())
572                ),
573            ]
574        );
575    }
576
577    #[test]
578    fn resolve_audit_values_uses_request_values_without_provider() {
579        let request_values = AuditRequestValues::new(vec![ColumnValue::new(
580            "updated_by",
581            SqlValue::String("request-updated-by".to_string()),
582        )]);
583
584        let resolved = resolve_audit_values(vec![], context(Some(&request_values)), None)
585            .expect("request audit values should resolve");
586
587        assert_eq!(
588            resolved,
589            vec![ColumnValue::new(
590                "updated_by",
591                SqlValue::String("request-updated-by".to_string())
592            )]
593        );
594    }
595
596    #[test]
597    fn resolve_audit_values_rejects_duplicate_user_columns() {
598        let error = resolve_audit_values(
599            vec![
600                ColumnValue::new("created_at", SqlValue::String("first".to_string())),
601                ColumnValue::new("created_at", SqlValue::String("second".to_string())),
602            ],
603            context(None),
604            None,
605        )
606        .unwrap_err();
607
608        assert_eq!(
609            error,
610            OrmError::new("duplicate column `created_at` in audit values")
611        );
612    }
613
614    #[test]
615    fn resolve_audit_values_rejects_duplicate_request_columns() {
616        let request_values = AuditRequestValues::new(vec![
617            ColumnValue::new("created_by", SqlValue::String("first".to_string())),
618            ColumnValue::new("created_by", SqlValue::String("second".to_string())),
619        ]);
620
621        let error = resolve_audit_values(vec![], context(Some(&request_values)), None).unwrap_err();
622
623        assert_eq!(
624            error,
625            OrmError::new("duplicate column `created_by` in audit request values")
626        );
627    }
628
629    #[test]
630    fn resolve_audit_values_rejects_duplicate_provider_columns() {
631        struct DuplicateProvider;
632
633        impl AuditProvider for DuplicateProvider {
634            fn values(&self, _context: AuditContext<'_>) -> Result<Vec<ColumnValue>, OrmError> {
635                Ok(vec![
636                    ColumnValue::new("updated_at", SqlValue::String("first".to_string())),
637                    ColumnValue::new("updated_at", SqlValue::String("second".to_string())),
638                ])
639            }
640        }
641
642        let error =
643            resolve_audit_values(vec![], context(None), Some(&DuplicateProvider)).unwrap_err();
644
645        assert_eq!(
646            error,
647            OrmError::new("duplicate column `updated_at` in audit provider values")
648        );
649    }
650}