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    /// Canonical casing-neutral path using KSML property names.
115    pub fn model_path(&self) -> String {
116        self.render_path(|name| name.to_owned())
117    }
118
119    /// Rust diagnostic path. KSML snake_case is already idiomatic Rust.
120    pub fn native_path(&self) -> String {
121        self.render_path(|name| name.to_owned())
122    }
123
124    /// RFC 6901 JSON pointer using TeaQL's default lower-camel wire policy.
125    pub fn instance_path(&self) -> String {
126        self.segments
127            .iter()
128            .map(|segment| match segment {
129                LocationSegment::Member(member) => {
130                    format!("/{}", escape_json_pointer(&lower_camel(member)))
131                }
132                LocationSegment::Index(index) => format!("/{index}"),
133            })
134            .collect()
135    }
136
137    fn render_path(&self, property_name: impl Fn(&str) -> String) -> String {
138        let mut result = String::new();
139        for segment in &self.segments {
140            match segment {
141                LocationSegment::Member(member) => {
142                    if !result.is_empty() {
143                        result.push('.');
144                    }
145                    result.push_str(&property_name(member));
146                }
147                LocationSegment::Index(index) => result.push_str(&format!("[{index}]")),
148            }
149        }
150        result
151    }
152}
153
154fn lower_camel(name: &str) -> String {
155    let mut parts = name.split('_');
156    let mut result = parts.next().unwrap_or_default().to_owned();
157    for part in parts {
158        let mut chars = part.chars();
159        if let Some(first) = chars.next() {
160            result.extend(first.to_uppercase());
161            result.extend(chars);
162        }
163    }
164    result
165}
166
167fn escape_json_pointer(value: &str) -> String {
168    value.replace('~', "~0").replace('/', "~1")
169}
170
171impl std::fmt::Display for ObjectLocation {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        if self.segments.is_empty() {
174            return write!(f, "$");
175        }
176        let mut first = true;
177        for segment in &self.segments {
178            match segment {
179                LocationSegment::Member(member) => {
180                    if !first {
181                        write!(f, ".")?;
182                    }
183                    write!(f, "{member}")?;
184                }
185                LocationSegment::Index(index) => {
186                    write!(f, "[{index}]")?;
187                }
188            }
189            first = false;
190        }
191        Ok(())
192    }
193}
194
195#[derive(Debug, Clone, PartialEq)]
196pub struct CheckResult {
197    pub rule: CheckRule,
198    pub location: ObjectLocation,
199    pub input_value: Option<Value>,
200    pub system_value: Option<Value>,
201    pub message: Option<String>,
202}
203
204impl CheckResult {
205    pub fn new(rule: CheckRule, location: ObjectLocation) -> Self {
206        Self {
207            rule,
208            location,
209            input_value: None,
210            system_value: None,
211            message: None,
212        }
213    }
214
215    pub fn required(location: ObjectLocation) -> Self {
216        Self::new(CheckRule::Required, location)
217    }
218
219    pub fn min(location: ObjectLocation, min: impl Into<Value>, current: impl Into<Value>) -> Self {
220        Self::new(CheckRule::Min, location)
221            .with_system_value(min)
222            .with_input_value(current)
223    }
224
225    pub fn max(location: ObjectLocation, max: impl Into<Value>, current: impl Into<Value>) -> Self {
226        Self::new(CheckRule::Max, location)
227            .with_system_value(max)
228            .with_input_value(current)
229    }
230
231    pub fn min_str(location: ObjectLocation, min_len: u64, current: impl Into<Value>) -> Self {
232        Self::new(CheckRule::MinStringLength, location)
233            .with_system_value(min_len)
234            .with_input_value(current)
235    }
236
237    pub fn max_str(location: ObjectLocation, max_len: u64, current: impl Into<Value>) -> Self {
238        Self::new(CheckRule::MaxStringLength, location)
239            .with_system_value(max_len)
240            .with_input_value(current)
241    }
242
243    pub fn with_input_value(mut self, value: impl Into<Value>) -> Self {
244        self.input_value = Some(value.into());
245        self
246    }
247
248    pub fn with_system_value(mut self, value: impl Into<Value>) -> Self {
249        self.system_value = Some(value.into());
250        self
251    }
252
253    pub fn with_message(mut self, message: impl Into<String>) -> Self {
254        self.message = Some(message.into());
255        self
256    }
257}
258
259impl std::fmt::Display for CheckResult {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match &self.message {
262            Some(message) => write!(f, "{message}"),
263            None => write!(f, "{}: {:?}", self.location, self.rule),
264        }
265    }
266}
267
268pub type CheckResults = Vec<CheckResult>;
269
270pub trait Checker: Send + Sync {
271    fn entity(&self) -> &str;
272
273    fn check_and_fix(
274        &self,
275        context: &UserContext,
276        values: &mut EntityValues,
277        location: &ObjectLocation,
278        results: &mut CheckResults,
279    );
280
281    fn required(
282        &self,
283        values: &EntityValues,
284        field: &str,
285        location: &ObjectLocation,
286        results: &mut CheckResults,
287    ) {
288        if matches!(values.get(field), None | Some(Value::Null)) {
289            results.push(CheckResult::required(location.clone().member(field)));
290        }
291    }
292
293    fn min_string_length(
294        &self,
295        values: &EntityValues,
296        field: &str,
297        min_len: usize,
298        location: &ObjectLocation,
299        results: &mut CheckResults,
300    ) {
301        if let Some(Value::Text(value)) = values.get(field) {
302            if value.chars().count() < min_len {
303                results.push(CheckResult::min_str(
304                    location.clone().member(field),
305                    min_len as u64,
306                    value.clone(),
307                ));
308            }
309        }
310    }
311
312    fn max_string_length(
313        &self,
314        values: &EntityValues,
315        field: &str,
316        max_len: usize,
317        location: &ObjectLocation,
318        results: &mut CheckResults,
319    ) {
320        if let Some(Value::Text(value)) = values.get(field) {
321            if value.chars().count() > max_len {
322                results.push(CheckResult::max_str(
323                    location.clone().member(field),
324                    max_len as u64,
325                    value.clone(),
326                ));
327            }
328        }
329    }
330}
331
332pub trait CheckerRegistry: Send + Sync {
333    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>>;
334}
335
336#[derive(Default, Clone)]
337pub struct InMemoryCheckerRegistry {
338    checkers: BTreeMap<String, Arc<dyn Checker>>,
339}
340
341impl InMemoryCheckerRegistry {
342    pub fn new() -> Self {
343        Self::default()
344    }
345
346    pub fn register(&mut self, checker: impl Checker + 'static) {
347        self.checkers
348            .insert(checker.entity().to_owned(), Arc::new(checker));
349    }
350
351    pub fn with_checker(mut self, checker: impl Checker + 'static) -> Self {
352        self.register(checker);
353        self
354    }
355}
356
357impl CheckerRegistry for InMemoryCheckerRegistry {
358    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>> {
359        self.checkers.get(entity).cloned()
360    }
361}
362
363// ---------------------------------------------------------------------------
364// TypedChecker & TypedEntityChecker
365// ---------------------------------------------------------------------------
366
367/// Typed version of [`Checker`] that works with concrete entity types (`T`)
368/// instead of generic value maps.
369///
370/// Implement this trait for per-entity checker logic structs, then wrap
371/// them in [`TypedEntityChecker`] so they satisfy the [`Checker`] trait
372/// expected by [`InMemoryCheckerRegistry`].
373pub trait TypedChecker<T>: Send + Sync {
374    fn check_and_fix_typed(
375        &self,
376        context: &UserContext,
377        entity: &mut T,
378        status: CheckObjectStatus,
379        location: &ObjectLocation,
380        results: &mut CheckResults,
381    );
382}
383
384/// Adapter that turns a [`TypedChecker<T>`] into a [`Checker`].
385///
386/// On [`Checker::check_and_fix`], it:
387/// 1. Extracts [`CheckObjectStatus`] from the entity values.
388/// 2. Materializes `T` from a compact row.
389/// 3. Delegates to [`TypedChecker::check_and_fix_typed`].
390/// 4. Serializes the (possibly mutated) `T` back into entity values.
391pub struct TypedEntityChecker<T, C> {
392    checker: C,
393    entity_name: String,
394    _marker: std::marker::PhantomData<fn() -> T>,
395}
396
397impl<T, C> TypedEntityChecker<T, C>
398where
399    T: TeaqlEntity,
400{
401    /// Create a new `TypedEntityChecker` wrapping `checker`.
402    pub fn new(checker: C) -> Self {
403        let entity_name = T::entity_descriptor().name.clone();
404        Self {
405            checker,
406            entity_name,
407            _marker: std::marker::PhantomData,
408        }
409    }
410}
411
412impl<T, C> Checker for TypedEntityChecker<T, C>
413where
414    T: Entity + TeaqlEntity + Send + Sync + Clone,
415    C: TypedChecker<T>,
416{
417    fn entity(&self) -> &str {
418        &self.entity_name
419    }
420
421    fn check_and_fix(
422        &self,
423        context: &UserContext,
424        values: &mut EntityValues,
425        location: &ObjectLocation,
426        results: &mut CheckResults,
427    ) {
428        let status = CheckObjectStatus::from_values(values);
429        // Materializing a partial update necessarily fills omitted Rust fields
430        // with their type defaults. Those defaults are only a checker view;
431        // they must never become mutation intent. Keep the original sparse
432        // record and merge back only fields the typed checker actually changed.
433        let mut original_values = std::mem::take(values);
434        let loaded_fields = match original_values.remove("_loaded_fields") {
435            Some(Value::List(fields)) => Some(
436                fields
437                    .into_iter()
438                    .filter_map(|field| match field {
439                        Value::Text(field) => Some(field),
440                        _ => None,
441                    })
442                    .collect::<std::collections::BTreeSet<_>>(),
443            ),
444            _ => None,
445        };
446        let owned_record = original_values.clone().into();
447        match T::from_compact_row(teaql_core::CompactRow::from_map(owned_record)) {
448            Ok(mut entity) => {
449                if let Some(loaded_fields) = loaded_fields {
450                    entity.set_checker_loaded_fields(loaded_fields);
451                }
452                let before_check = entity.clone().into_values();
453                self.checker
454                    .check_and_fix_typed(context, &mut entity, status, location, results);
455                let after_check = entity.into_values();
456                let descriptor = T::entity_descriptor();
457                for property in descriptor.properties.iter().filter(|property| {
458                    !property.nullable && !property.is_id && !property.is_version
459                }) {
460                    let was_absent_or_null = original_values
461                        .get(&property.name)
462                        .is_none_or(|value| matches!(value, Value::Null));
463                    if was_absent_or_null
464                        && after_check.get(&property.name).is_none_or(|value| {
465                            matches!(value, Value::Null)
466                                || before_check.get(&property.name) == Some(value)
467                        })
468                    {
469                        results.push(CheckResult::required(
470                            location.clone().member(&property.name),
471                        ));
472                    }
473                }
474                *values = original_values;
475                for (field, after_value) in after_check {
476                    if before_check.get(&field) != Some(&after_value) {
477                        values.insert(field, after_value);
478                    }
479                }
480            }
481            Err(error) => {
482                // A malformed value is not an absent required value. Preserve
483                // the caller's mutation boundary and report the materialization
484                // error so the offending field and actual value remain visible.
485                *values = original_values;
486                results.push(
487                    CheckResult::new(CheckRule::InvalidType, location.clone()).with_message(
488                        format!(
489                            "failed to materialize {} for checker: {error}",
490                            self.entity_name
491                        ),
492                    ),
493                );
494            }
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_object_location_formatting_and_nesting_levels() {
505        // Test root
506        let root = ObjectLocation::root();
507        assert_eq!(root.to_string(), "$");
508        assert!(root.is_root());
509        assert_eq!(root.level(), 0);
510
511        // Test hash_root
512        let hash = ObjectLocation::hash_root("user");
513        assert_eq!(hash.to_string(), "user");
514        assert!(!hash.is_root());
515        assert_eq!(hash.level(), 1);
516
517        // Test array_root
518        let arr = ObjectLocation::array_root(5);
519        assert_eq!(arr.to_string(), "[5]");
520        assert!(!arr.is_root());
521        assert_eq!(arr.level(), 1);
522
523        // Test nesting
524        let nested = ObjectLocation::root()
525            .member("users")
526            .element(2)
527            .member("address")
528            .member("city");
529
530        assert_eq!(nested.to_string(), "users[2].address.city");
531        assert_eq!(nested.level(), 4);
532    }
533
534    #[test]
535    fn object_location_renders_model_native_and_external_paths() {
536        let location = ObjectLocation::hash_root("order_items")
537            .element(2)
538            .member("user_url");
539
540        assert_eq!(location.model_path(), "order_items[2].user_url");
541        assert_eq!(location.native_path(), "order_items[2].user_url");
542        assert_eq!(location.instance_path(), "/orderItems/2/userUrl");
543        assert_eq!(location.to_string(), "order_items[2].user_url");
544    }
545
546    #[test]
547    fn object_location_escapes_json_pointer_members() {
548        assert_eq!(ObjectLocation::hash_root("a~/b").instance_path(), "/a~0~1b");
549    }
550
551    #[test]
552    fn test_check_object_status_inference_and_explicit_markers() {
553        let mut values = EntityValues::default();
554
555        // No id -> Create
556        assert_eq!(
557            CheckObjectStatus::from_values(&values),
558            CheckObjectStatus::Create
559        );
560
561        // Has id -> Update
562        values.insert("id".to_string(), Value::I64(1));
563        assert_eq!(
564            CheckObjectStatus::from_values(&values),
565            CheckObjectStatus::Update
566        );
567
568        // Explicit marker Create overrides id
569        mark_entity_status(&mut values, CheckObjectStatus::Create);
570        assert_eq!(
571            CheckObjectStatus::from_values(&values),
572            CheckObjectStatus::Create
573        );
574
575        // Explicit marker Update
576        mark_entity_status(&mut values, CheckObjectStatus::Update);
577        assert_eq!(
578            CheckObjectStatus::from_values(&values),
579            CheckObjectStatus::Update
580        );
581
582        // Clear marker
583        clear_entity_status(&mut values);
584        assert_eq!(
585            CheckObjectStatus::from_values(&values),
586            CheckObjectStatus::Update
587        ); // falls back to id -> Update
588    }
589}