Skip to main content

teaql_runtime/
checker.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use teaql_core::{Entity, TeaqlEntity, Value};
5
6use crate::{EntityValues, UserContext};
7
8pub const CHECK_OBJECT_STATUS_FIELD: &str = "__teaql_object_status";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CheckObjectStatus {
12    Create,
13    Update,
14    Unknown,
15}
16
17impl CheckObjectStatus {
18    pub fn as_str(self) -> &'static str {
19        match self {
20            Self::Create => "create",
21            Self::Update => "update",
22            Self::Unknown => "unknown",
23        }
24    }
25
26    pub fn from_values(values: &EntityValues) -> Self {
27        match values.get(CHECK_OBJECT_STATUS_FIELD) {
28            Some(Value::Text(value)) if value == Self::Create.as_str() => Self::Create,
29            Some(Value::Text(value)) if value == Self::Update.as_str() => Self::Update,
30            _ => match values.get("id") {
31                None | Some(Value::Null) => Self::Create,
32                Some(_) => Self::Update,
33            },
34        }
35    }
36
37    pub fn is_create(self) -> bool {
38        matches!(self, Self::Create)
39    }
40
41    pub fn is_update(self) -> bool {
42        matches!(self, Self::Update)
43    }
44}
45
46impl From<CheckObjectStatus> for Value {
47    fn from(value: CheckObjectStatus) -> Self {
48        Value::Text(value.as_str().to_owned())
49    }
50}
51
52pub fn mark_entity_status(values: &mut EntityValues, status: CheckObjectStatus) {
53    values.insert(CHECK_OBJECT_STATUS_FIELD.to_owned(), status.into());
54}
55
56pub fn clear_entity_status(values: &mut EntityValues) {
57    values.remove(CHECK_OBJECT_STATUS_FIELD);
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum CheckRule {
62    Required,
63    InvalidType,
64    Min,
65    Max,
66    MinStringLength,
67    MaxStringLength,
68    ContextRootMissing,
69    ContextRootMismatch,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum LocationSegment {
74    Member(String),
75    Index(usize),
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
79pub struct ObjectLocation {
80    segments: Vec<LocationSegment>,
81}
82
83impl ObjectLocation {
84    pub fn root() -> Self {
85        Self::default()
86    }
87
88    pub fn hash_root(member: impl Into<String>) -> Self {
89        Self::root().member(member)
90    }
91
92    pub fn array_root(index: usize) -> Self {
93        Self::root().element(index)
94    }
95
96    pub fn member(mut self, member: impl Into<String>) -> Self {
97        self.segments.push(LocationSegment::Member(member.into()));
98        self
99    }
100
101    pub fn element(mut self, index: usize) -> Self {
102        self.segments.push(LocationSegment::Index(index));
103        self
104    }
105
106    pub fn is_root(&self) -> bool {
107        self.segments.is_empty()
108    }
109
110    pub fn level(&self) -> usize {
111        self.segments.len()
112    }
113}
114
115impl std::fmt::Display for ObjectLocation {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        if self.segments.is_empty() {
118            return write!(f, "$");
119        }
120        let mut first = true;
121        for segment in &self.segments {
122            match segment {
123                LocationSegment::Member(member) => {
124                    if !first {
125                        write!(f, ".")?;
126                    }
127                    write!(f, "{member}")?;
128                }
129                LocationSegment::Index(index) => {
130                    write!(f, "[{index}]")?;
131                }
132            }
133            first = false;
134        }
135        Ok(())
136    }
137}
138
139#[derive(Debug, Clone, PartialEq)]
140pub struct CheckResult {
141    pub rule: CheckRule,
142    pub location: ObjectLocation,
143    pub input_value: Option<Value>,
144    pub system_value: Option<Value>,
145    pub message: Option<String>,
146}
147
148impl CheckResult {
149    pub fn new(rule: CheckRule, location: ObjectLocation) -> Self {
150        Self {
151            rule,
152            location,
153            input_value: None,
154            system_value: None,
155            message: None,
156        }
157    }
158
159    pub fn required(location: ObjectLocation) -> Self {
160        Self::new(CheckRule::Required, location)
161    }
162
163    pub fn min(location: ObjectLocation, min: impl Into<Value>, current: impl Into<Value>) -> Self {
164        Self::new(CheckRule::Min, location)
165            .with_system_value(min)
166            .with_input_value(current)
167    }
168
169    pub fn max(location: ObjectLocation, max: impl Into<Value>, current: impl Into<Value>) -> Self {
170        Self::new(CheckRule::Max, location)
171            .with_system_value(max)
172            .with_input_value(current)
173    }
174
175    pub fn min_str(location: ObjectLocation, min_len: u64, current: impl Into<Value>) -> Self {
176        Self::new(CheckRule::MinStringLength, location)
177            .with_system_value(min_len)
178            .with_input_value(current)
179    }
180
181    pub fn max_str(location: ObjectLocation, max_len: u64, current: impl Into<Value>) -> Self {
182        Self::new(CheckRule::MaxStringLength, location)
183            .with_system_value(max_len)
184            .with_input_value(current)
185    }
186
187    pub fn with_input_value(mut self, value: impl Into<Value>) -> Self {
188        self.input_value = Some(value.into());
189        self
190    }
191
192    pub fn with_system_value(mut self, value: impl Into<Value>) -> Self {
193        self.system_value = Some(value.into());
194        self
195    }
196
197    pub fn with_message(mut self, message: impl Into<String>) -> Self {
198        self.message = Some(message.into());
199        self
200    }
201}
202
203impl std::fmt::Display for CheckResult {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        match &self.message {
206            Some(message) => write!(f, "{message}"),
207            None => write!(f, "{}: {:?}", self.location, self.rule),
208        }
209    }
210}
211
212pub type CheckResults = Vec<CheckResult>;
213
214pub trait Checker: Send + Sync {
215    fn entity(&self) -> &str;
216
217    fn check_and_fix(
218        &self,
219        context: &UserContext,
220        values: &mut EntityValues,
221        location: &ObjectLocation,
222        results: &mut CheckResults,
223    );
224
225    fn required(
226        &self,
227        values: &EntityValues,
228        field: &str,
229        location: &ObjectLocation,
230        results: &mut CheckResults,
231    ) {
232        if matches!(values.get(field), None | Some(Value::Null)) {
233            results.push(CheckResult::required(location.clone().member(field)));
234        }
235    }
236
237    fn min_string_length(
238        &self,
239        values: &EntityValues,
240        field: &str,
241        min_len: usize,
242        location: &ObjectLocation,
243        results: &mut CheckResults,
244    ) {
245        if let Some(Value::Text(value)) = values.get(field) {
246            if value.chars().count() < min_len {
247                results.push(CheckResult::min_str(
248                    location.clone().member(field),
249                    min_len as u64,
250                    value.clone(),
251                ));
252            }
253        }
254    }
255
256    fn max_string_length(
257        &self,
258        values: &EntityValues,
259        field: &str,
260        max_len: usize,
261        location: &ObjectLocation,
262        results: &mut CheckResults,
263    ) {
264        if let Some(Value::Text(value)) = values.get(field) {
265            if value.chars().count() > max_len {
266                results.push(CheckResult::max_str(
267                    location.clone().member(field),
268                    max_len as u64,
269                    value.clone(),
270                ));
271            }
272        }
273    }
274}
275
276pub trait CheckerRegistry: Send + Sync {
277    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>>;
278}
279
280#[derive(Default, Clone)]
281pub struct InMemoryCheckerRegistry {
282    checkers: BTreeMap<String, Arc<dyn Checker>>,
283}
284
285impl InMemoryCheckerRegistry {
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    pub fn register(&mut self, checker: impl Checker + 'static) {
291        self.checkers
292            .insert(checker.entity().to_owned(), Arc::new(checker));
293    }
294
295    pub fn with_checker(mut self, checker: impl Checker + 'static) -> Self {
296        self.register(checker);
297        self
298    }
299}
300
301impl CheckerRegistry for InMemoryCheckerRegistry {
302    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>> {
303        self.checkers.get(entity).cloned()
304    }
305}
306
307// ---------------------------------------------------------------------------
308// TypedChecker & TypedEntityChecker
309// ---------------------------------------------------------------------------
310
311/// Typed version of [`Checker`] that works with concrete entity types (`T`)
312/// instead of generic value maps.
313///
314/// Implement this trait for per-entity checker logic structs, then wrap
315/// them in [`TypedEntityChecker`] so they satisfy the [`Checker`] trait
316/// expected by [`InMemoryCheckerRegistry`].
317pub trait TypedChecker<T>: Send + Sync {
318    fn check_and_fix_typed(
319        &self,
320        context: &UserContext,
321        entity: &mut T,
322        status: CheckObjectStatus,
323        location: &ObjectLocation,
324        results: &mut CheckResults,
325    );
326}
327
328/// Adapter that turns a [`TypedChecker<T>`] into a [`Checker`].
329///
330/// On [`Checker::check_and_fix`], it:
331/// 1. Extracts [`CheckObjectStatus`] from the entity values.
332/// 2. Materializes `T` from a compact row.
333/// 3. Delegates to [`TypedChecker::check_and_fix_typed`].
334/// 4. Serializes the (possibly mutated) `T` back into entity values.
335pub struct TypedEntityChecker<T, C> {
336    checker: C,
337    entity_name: String,
338    _marker: std::marker::PhantomData<fn() -> T>,
339}
340
341impl<T, C> TypedEntityChecker<T, C>
342where
343    T: TeaqlEntity,
344{
345    /// Create a new `TypedEntityChecker` wrapping `checker`.
346    pub fn new(checker: C) -> Self {
347        let entity_name = T::entity_descriptor().name.clone();
348        Self {
349            checker,
350            entity_name,
351            _marker: std::marker::PhantomData,
352        }
353    }
354}
355
356impl<T, C> Checker for TypedEntityChecker<T, C>
357where
358    T: Entity + TeaqlEntity + Send + Sync + Clone,
359    C: TypedChecker<T>,
360{
361    fn entity(&self) -> &str {
362        &self.entity_name
363    }
364
365    fn check_and_fix(
366        &self,
367        context: &UserContext,
368        values: &mut EntityValues,
369        location: &ObjectLocation,
370        results: &mut CheckResults,
371    ) {
372        let status = CheckObjectStatus::from_values(values);
373        // Materializing a partial update necessarily fills omitted Rust fields
374        // with their type defaults. Those defaults are only a checker view;
375        // they must never become mutation intent. Keep the original sparse
376        // record and merge back only fields the typed checker actually changed.
377        let original_values = std::mem::take(values);
378        let owned_record = original_values.clone().into();
379        match T::from_compact_row(teaql_core::CompactRow::from_map(owned_record)) {
380            Ok(mut entity) => {
381                let before_check = entity.clone().into_values();
382                self.checker
383                    .check_and_fix_typed(context, &mut entity, status, location, results);
384                let after_check = entity.into_values();
385                *values = original_values;
386                for (field, after_value) in after_check {
387                    if before_check.get(&field) != Some(&after_value) {
388                        values.insert(field, after_value);
389                    }
390                }
391            }
392            Err(error) => {
393                // A malformed value is not an absent required value. Preserve
394                // the caller's mutation boundary and report the materialization
395                // error so the offending field and actual value remain visible.
396                *values = original_values;
397                results.push(
398                    CheckResult::new(CheckRule::InvalidType, location.clone()).with_message(
399                        format!(
400                            "failed to materialize {} for checker: {error}",
401                            self.entity_name
402                        ),
403                    ),
404                );
405            }
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_object_location_formatting_and_nesting_levels() {
416        // Test root
417        let root = ObjectLocation::root();
418        assert_eq!(root.to_string(), "$");
419        assert!(root.is_root());
420        assert_eq!(root.level(), 0);
421
422        // Test hash_root
423        let hash = ObjectLocation::hash_root("user");
424        assert_eq!(hash.to_string(), "user");
425        assert!(!hash.is_root());
426        assert_eq!(hash.level(), 1);
427
428        // Test array_root
429        let arr = ObjectLocation::array_root(5);
430        assert_eq!(arr.to_string(), "[5]");
431        assert!(!arr.is_root());
432        assert_eq!(arr.level(), 1);
433
434        // Test nesting
435        let nested = ObjectLocation::root()
436            .member("users")
437            .element(2)
438            .member("address")
439            .member("city");
440
441        assert_eq!(nested.to_string(), "users[2].address.city");
442        assert_eq!(nested.level(), 4);
443    }
444
445    #[test]
446    fn test_check_object_status_inference_and_explicit_markers() {
447        let mut values = EntityValues::default();
448
449        // No id -> Create
450        assert_eq!(
451            CheckObjectStatus::from_values(&values),
452            CheckObjectStatus::Create
453        );
454
455        // Has id -> Update
456        values.insert("id".to_string(), Value::I64(1));
457        assert_eq!(
458            CheckObjectStatus::from_values(&values),
459            CheckObjectStatus::Update
460        );
461
462        // Explicit marker Create overrides id
463        mark_entity_status(&mut values, CheckObjectStatus::Create);
464        assert_eq!(
465            CheckObjectStatus::from_values(&values),
466            CheckObjectStatus::Create
467        );
468
469        // Explicit marker Update
470        mark_entity_status(&mut values, CheckObjectStatus::Update);
471        assert_eq!(
472            CheckObjectStatus::from_values(&values),
473            CheckObjectStatus::Update
474        );
475
476        // Clear marker
477        clear_entity_status(&mut values);
478        assert_eq!(
479            CheckObjectStatus::from_values(&values),
480            CheckObjectStatus::Update
481        ); // falls back to id -> Update
482    }
483}