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            if value.chars().count() < min_len {
413                results.push(CheckResult::min_str(
414                    location.clone().member(field),
415                    min_len as u64,
416                    value.clone(),
417                ));
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            if value.chars().count() > max_len {
432                results.push(CheckResult::max_str(
433                    location.clone().member(field),
434                    max_len as u64,
435                    value.clone(),
436                ));
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 owned_record = original_values.clone().into();
557        match T::from_compact_row(teaql_core::CompactRow::from_map(owned_record)) {
558            Ok(mut entity) => {
559                if let Some(loaded_fields) = loaded_fields {
560                    entity.set_checker_loaded_fields(loaded_fields);
561                }
562                let before_check = entity.clone().into_values();
563                self.checker
564                    .check_and_fix_typed(context, &mut entity, status, location, results);
565                let after_check = entity.into_values();
566                let descriptor = T::entity_descriptor();
567                for property in descriptor.properties.iter().filter(|property| {
568                    !property.nullable && !property.is_id && !property.is_version
569                }) {
570                    let was_absent_or_null = original_values
571                        .get(&property.name)
572                        .is_none_or(|value| matches!(value, Value::Null));
573                    if was_absent_or_null
574                        && after_check.get(&property.name).is_none_or(|value| {
575                            matches!(value, Value::Null)
576                                || before_check.get(&property.name) == Some(value)
577                        })
578                    {
579                        results.push(CheckResult::required(
580                            location.clone().member(&property.name),
581                        ));
582                    }
583                }
584                *values = original_values;
585                for (field, after_value) in after_check {
586                    if before_check.get(&field) != Some(&after_value) {
587                        values.insert(field, after_value);
588                    }
589                }
590            }
591            Err(error) => {
592                // A malformed value is not an absent required value. Preserve
593                // the caller's mutation boundary and report the materialization
594                // error so the offending field and actual value remain visible.
595                *values = original_values;
596                results.push(
597                    CheckResult::new(CheckRule::InvalidType, location.clone()).with_message(
598                        format!(
599                            "failed to materialize {} for checker: {error}",
600                            self.entity_name
601                        ),
602                    ),
603                );
604            }
605        }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_object_location_formatting_and_nesting_levels() {
615        // Test root
616        let root = ObjectLocation::root();
617        assert_eq!(root.to_string(), "$");
618        assert!(root.is_root());
619        assert_eq!(root.level(), 0);
620
621        // Test hash_root
622        let hash = ObjectLocation::hash_root("user");
623        assert_eq!(hash.to_string(), "user");
624        assert!(!hash.is_root());
625        assert_eq!(hash.level(), 1);
626
627        // Test array_root
628        let arr = ObjectLocation::array_root(5);
629        assert_eq!(arr.to_string(), "[5]");
630        assert!(!arr.is_root());
631        assert_eq!(arr.level(), 1);
632
633        // Test nesting
634        let nested = ObjectLocation::root()
635            .member("users")
636            .element(2)
637            .member("address")
638            .member("city");
639
640        assert_eq!(nested.to_string(), "users[2].address.city");
641        assert_eq!(nested.level(), 4);
642    }
643
644    #[test]
645    fn object_location_renders_model_native_and_external_paths() {
646        let location = ObjectLocation::hash_root("order_items")
647            .element(2)
648            .member("user_url");
649
650        assert_eq!(location.model_path(), "order_items[2].user_url");
651        assert_eq!(location.native_path(), "order_items[2].user_url");
652        assert_eq!(location.instance_path(), "/orderItems/2/userUrl");
653        assert_eq!(
654            location.instance_path_with(JsonFieldNamingProfile::SnakeCase),
655            "/order_items/2/user_url"
656        );
657        assert_eq!(
658            location.instance_path_with(JsonFieldNamingProfile::PascalCase),
659            "/OrderItems/2/UserUrl"
660        );
661        assert_eq!(location.to_string(), "order_items[2].user_url");
662    }
663
664    #[test]
665    fn checker_wire_projection_preserves_submitted_alias() {
666        let result = CheckResult::required(ObjectLocation::hash_root("user_url"))
667            .with_entity_type("customer_account")
668            .with_source_instance_path("/user_url");
669        let wire = result.to_wire(JsonFieldNamingProfile::CamelCase);
670        assert_eq!(wire.rule_id, "required");
671        assert_eq!(wire.entity_type.as_deref(), Some("customer_account"));
672        assert_eq!(wire.instance_path, "/userUrl");
673        assert_eq!(wire.source_instance_path.as_deref(), Some("/user_url"));
674
675        let json = serde_json::to_value(&wire).expect("wire result must serialize");
676        assert_eq!(json["ruleId"], "required");
677        assert_eq!(json["entityType"], "customer_account");
678        assert_eq!(json["location"][0]["kind"], "property");
679        assert_eq!(json["location"][0]["name"], "user_url");
680        assert_eq!(json["instancePath"], "/userUrl");
681        assert_eq!(json["sourceInstancePath"], "/user_url");
682    }
683
684    #[test]
685    fn object_location_escapes_json_pointer_members() {
686        assert_eq!(ObjectLocation::hash_root("a~/b").instance_path(), "/a~0~1b");
687    }
688
689    #[test]
690    fn test_check_object_status_inference_and_explicit_markers() {
691        let mut values = EntityValues::default();
692
693        // No id -> Create
694        assert_eq!(
695            CheckObjectStatus::from_values(&values),
696            CheckObjectStatus::Create
697        );
698
699        // Has id -> Update
700        values.insert("id".to_string(), Value::I64(1));
701        assert_eq!(
702            CheckObjectStatus::from_values(&values),
703            CheckObjectStatus::Update
704        );
705
706        // Explicit marker Create overrides id
707        mark_entity_status(&mut values, CheckObjectStatus::Create);
708        assert_eq!(
709            CheckObjectStatus::from_values(&values),
710            CheckObjectStatus::Create
711        );
712
713        // Explicit marker Update
714        mark_entity_status(&mut values, CheckObjectStatus::Update);
715        assert_eq!(
716            CheckObjectStatus::from_values(&values),
717            CheckObjectStatus::Update
718        );
719
720        // Clear marker
721        clear_entity_status(&mut values);
722        assert_eq!(
723            CheckObjectStatus::from_values(&values),
724            CheckObjectStatus::Update
725        ); // falls back to id -> Update
726    }
727}