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
72impl CheckRule {
73    pub fn wire_id(self) -> &'static str {
74        match self {
75            Self::Required => "required",
76            Self::InvalidType => "invalid_type",
77            Self::Min => "min",
78            Self::Max => "max",
79            Self::MinStringLength => "min_string_length",
80            Self::MaxStringLength => "max_string_length",
81            Self::ContextRootMissing => "context_root_missing",
82            Self::ContextRootMismatch => "context_root_mismatch",
83        }
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum LocationSegment {
89    Member(String),
90    Index(usize),
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum JsonFieldNamingProfile {
95    #[default]
96    CamelCase,
97    SnakeCase,
98    PascalCase,
99}
100
101impl JsonFieldNamingProfile {
102    pub fn from_model_value(value: &str) -> Result<Self, String> {
103        match value {
104            "" | "camelCase" => Ok(Self::CamelCase),
105            "snake_case" => Ok(Self::SnakeCase),
106            "PascalCase" => Ok(Self::PascalCase),
107            other => Err(format!("unsupported json_field_naming: {other}")),
108        }
109    }
110
111    fn render(self, name: &str) -> String {
112        match self {
113            Self::SnakeCase => name.to_owned(),
114            Self::CamelCase => lower_camel(name),
115            Self::PascalCase => {
116                let camel = lower_camel(name);
117                let mut chars = camel.chars();
118                chars
119                    .next()
120                    .map(|first| first.to_uppercase().chain(chars).collect())
121                    .unwrap_or_default()
122            }
123        }
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Default)]
128pub struct ObjectLocation {
129    segments: Vec<LocationSegment>,
130}
131
132impl ObjectLocation {
133    pub fn root() -> Self {
134        Self::default()
135    }
136
137    pub fn hash_root(member: impl Into<String>) -> Self {
138        Self::root().member(member)
139    }
140
141    pub fn array_root(index: usize) -> Self {
142        Self::root().element(index)
143    }
144
145    pub fn member(mut self, member: impl Into<String>) -> Self {
146        self.segments.push(LocationSegment::Member(member.into()));
147        self
148    }
149
150    pub fn element(mut self, index: usize) -> Self {
151        self.segments.push(LocationSegment::Index(index));
152        self
153    }
154
155    pub fn is_root(&self) -> bool {
156        self.segments.is_empty()
157    }
158
159    pub fn level(&self) -> usize {
160        self.segments.len()
161    }
162
163    /// Canonical casing-neutral path using KSML property names.
164    pub fn model_path(&self) -> String {
165        self.render_path(|name| name.to_owned())
166    }
167
168    /// Rust diagnostic path. KSML snake_case is already idiomatic Rust.
169    pub fn native_path(&self) -> String {
170        self.render_path(|name| name.to_owned())
171    }
172
173    /// RFC 6901 JSON pointer using TeaQL's default lower-camel wire policy.
174    pub fn instance_path(&self) -> String {
175        self.instance_path_with(JsonFieldNamingProfile::CamelCase)
176    }
177
178    pub fn instance_path_with(&self, profile: JsonFieldNamingProfile) -> String {
179        self.segments
180            .iter()
181            .map(|segment| match segment {
182                LocationSegment::Member(member) => {
183                    format!("/{}", escape_json_pointer(&profile.render(member)))
184                }
185                LocationSegment::Index(index) => format!("/{index}"),
186            })
187            .collect()
188    }
189
190    fn render_path(&self, property_name: impl Fn(&str) -> String) -> String {
191        let mut result = String::new();
192        for segment in &self.segments {
193            match segment {
194                LocationSegment::Member(member) => {
195                    if !result.is_empty() {
196                        result.push('.');
197                    }
198                    result.push_str(&property_name(member));
199                }
200                LocationSegment::Index(index) => result.push_str(&format!("[{index}]")),
201            }
202        }
203        result
204    }
205}
206
207fn lower_camel(name: &str) -> String {
208    let mut parts = name.split('_');
209    let mut result = parts.next().unwrap_or_default().to_owned();
210    for part in parts {
211        let mut chars = part.chars();
212        if let Some(first) = chars.next() {
213            result.extend(first.to_uppercase());
214            result.extend(chars);
215        }
216    }
217    result
218}
219
220fn escape_json_pointer(value: &str) -> String {
221    value.replace('~', "~0").replace('/', "~1")
222}
223
224impl std::fmt::Display for ObjectLocation {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        if self.segments.is_empty() {
227            return write!(f, "$");
228        }
229        let mut first = true;
230        for segment in &self.segments {
231            match segment {
232                LocationSegment::Member(member) => {
233                    if !first {
234                        write!(f, ".")?;
235                    }
236                    write!(f, "{member}")?;
237                }
238                LocationSegment::Index(index) => {
239                    write!(f, "[{index}]")?;
240                }
241            }
242            first = false;
243        }
244        Ok(())
245    }
246}
247
248#[derive(Debug, Clone, PartialEq)]
249pub struct CheckResult {
250    pub rule: CheckRule,
251    pub location: ObjectLocation,
252    pub input_value: Option<Value>,
253    pub system_value: Option<Value>,
254    pub message: Option<String>,
255    pub entity_type: Option<String>,
256    pub source_instance_path: Option<String>,
257}
258
259#[derive(Debug, Clone, PartialEq, serde::Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct WireCheckResult {
262    pub rule_id: String,
263    pub entity_type: Option<String>,
264    pub location: Vec<WireLocationSegment>,
265    pub instance_path: String,
266    pub source_instance_path: Option<String>,
267    pub input_value: Option<serde_json::Value>,
268    pub system_value: Option<serde_json::Value>,
269    pub message: Option<String>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
273#[serde(tag = "kind", rename_all = "camelCase")]
274pub enum WireLocationSegment {
275    Property { name: String },
276    Index { index: usize },
277}
278
279impl CheckResult {
280    pub fn new(rule: CheckRule, location: ObjectLocation) -> Self {
281        Self {
282            rule,
283            location,
284            input_value: None,
285            system_value: None,
286            message: None,
287            entity_type: None,
288            source_instance_path: None,
289        }
290    }
291
292    pub fn required(location: ObjectLocation) -> Self {
293        Self::new(CheckRule::Required, location)
294    }
295
296    pub fn min(location: ObjectLocation, min: impl Into<Value>, current: impl Into<Value>) -> Self {
297        Self::new(CheckRule::Min, location)
298            .with_system_value(min)
299            .with_input_value(current)
300    }
301
302    pub fn max(location: ObjectLocation, max: impl Into<Value>, current: impl Into<Value>) -> Self {
303        Self::new(CheckRule::Max, location)
304            .with_system_value(max)
305            .with_input_value(current)
306    }
307
308    pub fn min_str(location: ObjectLocation, min_len: u64, current: impl Into<Value>) -> Self {
309        Self::new(CheckRule::MinStringLength, location)
310            .with_system_value(min_len)
311            .with_input_value(current)
312    }
313
314    pub fn max_str(location: ObjectLocation, max_len: u64, current: impl Into<Value>) -> Self {
315        Self::new(CheckRule::MaxStringLength, location)
316            .with_system_value(max_len)
317            .with_input_value(current)
318    }
319
320    pub fn with_input_value(mut self, value: impl Into<Value>) -> Self {
321        self.input_value = Some(value.into());
322        self
323    }
324
325    pub fn with_system_value(mut self, value: impl Into<Value>) -> Self {
326        self.system_value = Some(value.into());
327        self
328    }
329
330    pub fn with_message(mut self, message: impl Into<String>) -> Self {
331        self.message = Some(message.into());
332        self
333    }
334
335    pub fn with_entity_type(mut self, entity_type: impl Into<String>) -> Self {
336        self.entity_type = Some(entity_type.into());
337        self
338    }
339
340    pub fn with_source_instance_path(mut self, path: impl Into<String>) -> Self {
341        self.source_instance_path = Some(path.into());
342        self
343    }
344
345    pub fn to_wire(&self, profile: JsonFieldNamingProfile) -> WireCheckResult {
346        WireCheckResult {
347            rule_id: self.rule.wire_id().to_owned(),
348            entity_type: self.entity_type.clone(),
349            location: self
350                .location
351                .segments
352                .iter()
353                .map(|segment| match segment {
354                    LocationSegment::Member(name) => {
355                        WireLocationSegment::Property { name: name.clone() }
356                    }
357                    LocationSegment::Index(index) => WireLocationSegment::Index { index: *index },
358                })
359                .collect(),
360            instance_path: self.location.instance_path_with(profile),
361            source_instance_path: self.source_instance_path.clone(),
362            input_value: self.input_value.as_ref().map(Value::to_json_value),
363            system_value: self.system_value.as_ref().map(Value::to_json_value),
364            message: self.message.clone(),
365        }
366    }
367}
368
369impl std::fmt::Display for CheckResult {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        match &self.message {
372            Some(message) => write!(f, "{message}"),
373            None => write!(f, "{}: {:?}", self.location, self.rule),
374        }
375    }
376}
377
378pub type CheckResults = Vec<CheckResult>;
379
380pub trait Checker: Send + Sync {
381    fn entity(&self) -> &str;
382
383    fn check_and_fix(
384        &self,
385        context: &UserContext,
386        values: &mut EntityValues,
387        location: &ObjectLocation,
388        results: &mut CheckResults,
389    );
390
391    fn required(
392        &self,
393        values: &EntityValues,
394        field: &str,
395        location: &ObjectLocation,
396        results: &mut CheckResults,
397    ) {
398        if matches!(values.get(field), None | Some(Value::Null)) {
399            results.push(CheckResult::required(location.clone().member(field)));
400        }
401    }
402
403    fn min_string_length(
404        &self,
405        values: &EntityValues,
406        field: &str,
407        min_len: usize,
408        location: &ObjectLocation,
409        results: &mut CheckResults,
410    ) {
411        if let Some(Value::Text(value)) = values.get(field)
412            && value.chars().count() < min_len
413        {
414            results.push(CheckResult::min_str(
415                location.clone().member(field),
416                min_len as u64,
417                value.clone(),
418            ));
419        }
420    }
421
422    fn max_string_length(
423        &self,
424        values: &EntityValues,
425        field: &str,
426        max_len: usize,
427        location: &ObjectLocation,
428        results: &mut CheckResults,
429    ) {
430        if let Some(Value::Text(value)) = values.get(field)
431            && value.chars().count() > max_len
432        {
433            results.push(CheckResult::max_str(
434                location.clone().member(field),
435                max_len as u64,
436                value.clone(),
437            ));
438        }
439    }
440}
441
442pub trait CheckerRegistry: Send + Sync {
443    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>>;
444}
445
446#[derive(Default, Clone)]
447pub struct InMemoryCheckerRegistry {
448    checkers: BTreeMap<String, Arc<dyn Checker>>,
449}
450
451impl InMemoryCheckerRegistry {
452    pub fn new() -> Self {
453        Self::default()
454    }
455
456    pub fn register(&mut self, checker: impl Checker + 'static) {
457        self.checkers
458            .insert(checker.entity().to_owned(), Arc::new(checker));
459    }
460
461    pub fn with_checker(mut self, checker: impl Checker + 'static) -> Self {
462        self.register(checker);
463        self
464    }
465}
466
467impl CheckerRegistry for InMemoryCheckerRegistry {
468    fn checker(&self, entity: &str) -> Option<Arc<dyn Checker>> {
469        self.checkers.get(entity).cloned()
470    }
471}
472
473// ---------------------------------------------------------------------------
474// TypedChecker & TypedEntityChecker
475// ---------------------------------------------------------------------------
476
477/// Typed version of [`Checker`] that works with concrete entity types (`T`)
478/// instead of generic value maps.
479///
480/// Implement this trait for per-entity checker logic structs, then wrap
481/// them in [`TypedEntityChecker`] so they satisfy the [`Checker`] trait
482/// expected by [`InMemoryCheckerRegistry`].
483pub trait TypedChecker<T>: Send + Sync {
484    fn check_and_fix_typed(
485        &self,
486        context: &UserContext,
487        entity: &mut T,
488        status: CheckObjectStatus,
489        location: &ObjectLocation,
490        results: &mut CheckResults,
491    );
492}
493
494/// Adapter that turns a [`TypedChecker<T>`] into a [`Checker`].
495///
496/// On [`Checker::check_and_fix`], it:
497/// 1. Extracts [`CheckObjectStatus`] from the entity values.
498/// 2. Materializes `T` from a compact row.
499/// 3. Delegates to [`TypedChecker::check_and_fix_typed`].
500/// 4. Serializes the (possibly mutated) `T` back into entity values.
501pub struct TypedEntityChecker<T, C> {
502    checker: C,
503    entity_name: String,
504    _marker: std::marker::PhantomData<fn() -> T>,
505}
506
507impl<T, C> TypedEntityChecker<T, C>
508where
509    T: TeaqlEntity,
510{
511    /// Create a new `TypedEntityChecker` wrapping `checker`.
512    pub fn new(checker: C) -> Self {
513        let entity_name = T::entity_descriptor().name.clone();
514        Self {
515            checker,
516            entity_name,
517            _marker: std::marker::PhantomData,
518        }
519    }
520}
521
522impl<T, C> Checker for TypedEntityChecker<T, C>
523where
524    T: Entity + TeaqlEntity + Send + Sync + Clone,
525    C: TypedChecker<T>,
526{
527    fn entity(&self) -> &str {
528        &self.entity_name
529    }
530
531    fn check_and_fix(
532        &self,
533        context: &UserContext,
534        values: &mut EntityValues,
535        location: &ObjectLocation,
536        results: &mut CheckResults,
537    ) {
538        let status = CheckObjectStatus::from_values(values);
539        // Materializing a partial update necessarily fills omitted Rust fields
540        // with their type defaults. Those defaults are only a checker view;
541        // they must never become mutation intent. Keep the original sparse
542        // record and merge back only fields the typed checker actually changed.
543        let mut original_values = std::mem::take(values);
544        let loaded_fields = match original_values.remove("_loaded_fields") {
545            Some(Value::List(fields)) => Some(
546                fields
547                    .into_iter()
548                    .filter_map(|field| match field {
549                        Value::Text(field) => Some(field),
550                        _ => None,
551                    })
552                    .collect::<std::collections::BTreeSet<_>>(),
553            ),
554            _ => None,
555        };
556        let dirty_fields = match original_values.remove("_dirty_fields") {
557            Some(Value::List(fields)) => Some(
558                fields
559                    .into_iter()
560                    .filter_map(|field| match field {
561                        Value::Text(field) => Some(field),
562                        _ => None,
563                    })
564                    .collect::<std::collections::BTreeSet<_>>(),
565            ),
566            _ => None,
567        };
568        let checker_values: teaql_core::MutationValues = original_values.clone().into();
569        let owned_record = original_values.clone().into();
570        match T::from_compact_row(teaql_core::CompactRow::from_map(owned_record)) {
571            Ok(mut entity) => {
572                if let Some(loaded_fields) = loaded_fields {
573                    entity.set_checker_loaded_fields(loaded_fields);
574                }
575                if let Some(dirty_fields) = dirty_fields {
576                    entity.set_checker_dirty_fields(dirty_fields, &checker_values);
577                }
578                let before_check = entity.clone().into_values();
579                self.checker
580                    .check_and_fix_typed(context, &mut entity, status, location, results);
581                let after_check = entity.into_values();
582                let descriptor = T::entity_descriptor();
583                for property in descriptor.properties.iter().filter(|property| {
584                    !property.nullable && !property.is_id && !property.is_version
585                }) {
586                    let was_absent_or_null = original_values
587                        .get(&property.name)
588                        .is_none_or(|value| matches!(value, Value::Null));
589                    if was_absent_or_null
590                        && after_check.get(&property.name).is_none_or(|value| {
591                            matches!(value, Value::Null)
592                                || before_check.get(&property.name) == Some(value)
593                        })
594                    {
595                        results.push(CheckResult::required(
596                            location.clone().member(&property.name),
597                        ));
598                    }
599                }
600                *values = original_values;
601                for (field, after_value) in after_check {
602                    if before_check.get(&field) != Some(&after_value) {
603                        values.insert(field, after_value);
604                    }
605                }
606            }
607            Err(error) => {
608                // A malformed value is not an absent required value. Preserve
609                // the caller's mutation boundary and report the materialization
610                // error so the offending field and actual value remain visible.
611                *values = original_values;
612                results.push(
613                    CheckResult::new(CheckRule::InvalidType, location.clone()).with_message(
614                        format!(
615                            "failed to materialize {} for checker: {error}",
616                            self.entity_name
617                        ),
618                    ),
619                );
620            }
621        }
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn test_object_location_formatting_and_nesting_levels() {
631        // Test root
632        let root = ObjectLocation::root();
633        assert_eq!(root.to_string(), "$");
634        assert!(root.is_root());
635        assert_eq!(root.level(), 0);
636
637        // Test hash_root
638        let hash = ObjectLocation::hash_root("user");
639        assert_eq!(hash.to_string(), "user");
640        assert!(!hash.is_root());
641        assert_eq!(hash.level(), 1);
642
643        // Test array_root
644        let arr = ObjectLocation::array_root(5);
645        assert_eq!(arr.to_string(), "[5]");
646        assert!(!arr.is_root());
647        assert_eq!(arr.level(), 1);
648
649        // Test nesting
650        let nested = ObjectLocation::root()
651            .member("users")
652            .element(2)
653            .member("address")
654            .member("city");
655
656        assert_eq!(nested.to_string(), "users[2].address.city");
657        assert_eq!(nested.level(), 4);
658    }
659
660    #[test]
661    fn object_location_renders_model_native_and_external_paths() {
662        let location = ObjectLocation::hash_root("order_items")
663            .element(2)
664            .member("user_url");
665
666        assert_eq!(location.model_path(), "order_items[2].user_url");
667        assert_eq!(location.native_path(), "order_items[2].user_url");
668        assert_eq!(location.instance_path(), "/orderItems/2/userUrl");
669        assert_eq!(
670            location.instance_path_with(JsonFieldNamingProfile::SnakeCase),
671            "/order_items/2/user_url"
672        );
673        assert_eq!(
674            location.instance_path_with(JsonFieldNamingProfile::PascalCase),
675            "/OrderItems/2/UserUrl"
676        );
677        assert_eq!(location.to_string(), "order_items[2].user_url");
678    }
679
680    #[test]
681    fn checker_wire_projection_preserves_submitted_alias() {
682        let result = CheckResult::required(ObjectLocation::hash_root("user_url"))
683            .with_entity_type("customer_account")
684            .with_source_instance_path("/user_url");
685        let wire = result.to_wire(JsonFieldNamingProfile::CamelCase);
686        assert_eq!(wire.rule_id, "required");
687        assert_eq!(wire.entity_type.as_deref(), Some("customer_account"));
688        assert_eq!(wire.instance_path, "/userUrl");
689        assert_eq!(wire.source_instance_path.as_deref(), Some("/user_url"));
690
691        let json = serde_json::to_value(&wire).expect("wire result must serialize");
692        assert_eq!(json["ruleId"], "required");
693        assert_eq!(json["entityType"], "customer_account");
694        assert_eq!(json["location"][0]["kind"], "property");
695        assert_eq!(json["location"][0]["name"], "user_url");
696        assert_eq!(json["instancePath"], "/userUrl");
697        assert_eq!(json["sourceInstancePath"], "/user_url");
698    }
699
700    #[test]
701    fn object_location_escapes_json_pointer_members() {
702        assert_eq!(ObjectLocation::hash_root("a~/b").instance_path(), "/a~0~1b");
703    }
704
705    #[test]
706    fn test_check_object_status_inference_and_explicit_markers() {
707        let mut values = EntityValues::default();
708
709        // No id -> Create
710        assert_eq!(
711            CheckObjectStatus::from_values(&values),
712            CheckObjectStatus::Create
713        );
714
715        // Has id -> Update
716        values.insert("id".to_string(), Value::I64(1));
717        assert_eq!(
718            CheckObjectStatus::from_values(&values),
719            CheckObjectStatus::Update
720        );
721
722        // Explicit marker Create overrides id
723        mark_entity_status(&mut values, CheckObjectStatus::Create);
724        assert_eq!(
725            CheckObjectStatus::from_values(&values),
726            CheckObjectStatus::Create
727        );
728
729        // Explicit marker Update
730        mark_entity_status(&mut values, CheckObjectStatus::Update);
731        assert_eq!(
732            CheckObjectStatus::from_values(&values),
733            CheckObjectStatus::Update
734        );
735
736        // Clear marker
737        clear_entity_status(&mut values);
738        assert_eq!(
739            CheckObjectStatus::from_values(&values),
740            CheckObjectStatus::Update
741        ); // falls back to id -> Update
742    }
743}