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 pub fn model_path(&self) -> String {
165 self.render_path(|name| name.to_owned())
166 }
167
168 pub fn native_path(&self) -> String {
170 self.render_path(|name| name.to_owned())
171 }
172
173 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
473pub 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
494pub 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 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 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 *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 let root = ObjectLocation::root();
617 assert_eq!(root.to_string(), "$");
618 assert!(root.is_root());
619 assert_eq!(root.level(), 0);
620
621 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 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 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 assert_eq!(
695 CheckObjectStatus::from_values(&values),
696 CheckObjectStatus::Create
697 );
698
699 values.insert("id".to_string(), Value::I64(1));
701 assert_eq!(
702 CheckObjectStatus::from_values(&values),
703 CheckObjectStatus::Update
704 );
705
706 mark_entity_status(&mut values, CheckObjectStatus::Create);
708 assert_eq!(
709 CheckObjectStatus::from_values(&values),
710 CheckObjectStatus::Create
711 );
712
713 mark_entity_status(&mut values, CheckObjectStatus::Update);
715 assert_eq!(
716 CheckObjectStatus::from_values(&values),
717 CheckObjectStatus::Update
718 );
719
720 clear_entity_status(&mut values);
722 assert_eq!(
723 CheckObjectStatus::from_values(&values),
724 CheckObjectStatus::Update
725 ); }
727}