Skip to main content

teaql_runtime/
lib.rs

1mod checker;
2mod context;
3mod entity_runtime;
4mod error;
5mod event;
6mod graph;
7mod id;
8mod language;
9mod memory;
10mod registry;
11mod repository;
12
13pub use context::{SchemaProvider, SqlLogEntry, SqlLogOperation, SqlLogOptions, UserContext, QueryCommentGuard, TuiLogEntry, TuiLogBuffer};
14pub use entity_runtime::{ChangeSetStack, EntityChangeSet, EntityKey, EntityRoot, RootContext};
15pub use error::{ContextError, RepositoryError, RuntimeError};
16pub use event::{
17    EntityEvent, EntityEventKind, EntityEventSink, EntityPropertyChange, InMemoryEntityEventSink,
18};
19pub use graph::{
20    CommentTrack, GraphMutationBatch, GraphMutationKind, GraphMutationPlan, GraphMutationPlanItem,
21    GraphNode, GraphOperation, ScopedCommentNode, sorted_update_fields,
22};
23pub(crate) use id::local_id_generator;
24pub use id::{InternalIdGenerator, SnowflakeIdGenerator};
25pub use language::{
26    BuiltinTranslator, Language, MessageTranslator, translate_check_result, translate_location,
27};
28pub use memory::{MemoryRepository, MemoryRepositoryError};
29pub use registry::{
30    InMemoryMetadataStore, InMemoryRepositoryBehaviorRegistry, InMemoryRepositoryRegistry,
31    MetadataStore, RepositoryBehavior, RepositoryBehaviorRegistry, RepositoryRegistry,
32    RequestPolicy, RuntimeModule,
33};
34pub use repository::{
35    AggregationCacheBackend, ContextRepository, GraphTransactionBoundary, InMemoryAggregationCache,
36    QueryExecutor, RelationLoadPlan, Repository, ResolvedRepository,
37};
38
39#[cfg(test)]
40mod tests {
41    use std::collections::{BTreeMap, VecDeque};
42    use std::sync::{Arc, Mutex};
43
44    use super::{
45        AggregationCacheBackend, CHECK_OBJECT_STATUS_FIELD, CheckObjectStatus, CheckResult,
46        CheckResults, CheckRule, Checker, EntityEvent, EntityEventKind, EntityEventSink,
47        GraphMutationKind, GraphNode, GraphTransactionBoundary, InMemoryAggregationCache,
48        InMemoryCheckerRegistry, InMemoryMetadataStore, InMemoryRepositoryBehaviorRegistry,
49        InMemoryRepositoryRegistry, InternalIdGenerator, Language, MemoryRepository, MetadataStore,
50        ObjectLocation, QueryExecutor, Repository, RepositoryBehavior, RepositoryError,
51        RequestPolicy, RuntimeError, RuntimeModule, SqlLogOperation, SqlLogOptions, TypedChecker,
52        TypedEntityChecker, UserContext, translate_check_result,
53    };
54    use teaql_core::{
55        Aggregate, AggregateFunction, BinaryOp, DataType, Decimal, DeleteCommand, Entity,
56        EntityDescriptor, EntityError, Expr, InsertCommand, OrderBy, PropertyDescriptor, Record,
57        RecoverCommand, RelationAggregate, SelectQuery, TeaqlEntity, UpdateCommand, Value,
58    };
59    use teaql_macros::TeaqlEntity as DeriveTeaqlEntity;
60    use teaql_sql::{CompiledQuery, DatabaseKind, SqlCompileError, SqlDialect, quote_identifier_if_needed};
61
62    const ORDER_DEFAULT_PROJECTION: &str = "id, version, name";
63
64    #[derive(Debug, Default, Clone, Copy)]
65    struct PostgresDialect;
66
67    impl SqlDialect for PostgresDialect {
68        fn kind(&self) -> DatabaseKind {
69            DatabaseKind::PostgreSql
70        }
71
72        fn quote_ident(&self, ident: &str) -> String {
73            quote_identifier_if_needed(ident, '"')
74        }
75
76        fn placeholder(&self, index: usize) -> String {
77            format!("${index}")
78        }
79
80        fn schema_type_sql(
81            &self,
82            data_type: DataType,
83            _property: &PropertyDescriptor,
84        ) -> Result<&'static str, SqlCompileError> {
85            match data_type {
86                DataType::Bool => Ok("BOOLEAN"),
87                DataType::I64 | DataType::U64 => Ok("BIGINT"),
88                DataType::F64 => Ok("DOUBLE PRECISION"),
89                DataType::Decimal => Ok("NUMERIC"),
90                DataType::Text => Ok("TEXT"),
91                DataType::Json => Ok("JSONB"),
92                DataType::Date => Ok("DATE"),
93                DataType::Timestamp => Ok("TIMESTAMPTZ"),
94            }
95        }
96    }
97
98    fn entity() -> EntityDescriptor {
99        EntityDescriptor::new("Order")
100            .table_name("orders")
101            .property(
102                PropertyDescriptor::new("id", DataType::U64)
103                    .column_name("id")
104                    .id()
105                    .not_null(),
106            )
107            .property(
108                PropertyDescriptor::new("version", DataType::I64)
109                    .column_name("version")
110                    .version()
111                    .not_null(),
112            )
113            .property(PropertyDescriptor::new("name", DataType::Text).column_name("name"))
114            .relation(
115                teaql_core::RelationDescriptor::new("lines", "OrderLine")
116                    .local_key("id")
117                    .foreign_key("order_id")
118                    .many(),
119            )
120    }
121
122    fn line_entity() -> EntityDescriptor {
123        EntityDescriptor::new("OrderLine")
124            .table_name("orderline")
125            .property(
126                PropertyDescriptor::new("id", DataType::U64)
127                    .column_name("id")
128                    .id()
129                    .not_null(),
130            )
131            .property(
132                PropertyDescriptor::new("version", DataType::I64)
133                    .column_name("version")
134                    .version(),
135            )
136            .property(
137                PropertyDescriptor::new("order_id", DataType::U64)
138                    .column_name("order_id")
139                    .not_null(),
140            )
141            .property(PropertyDescriptor::new("name", DataType::Text).column_name("name"))
142            .property(
143                PropertyDescriptor::new("product_id", DataType::U64)
144                    .column_name("product_id")
145                    .not_null(),
146            )
147            .relation(
148                teaql_core::RelationDescriptor::new("product", "Product")
149                    .local_key("product_id")
150                    .foreign_key("id"),
151            )
152    }
153
154    fn product_entity() -> EntityDescriptor {
155        EntityDescriptor::new("Product")
156            .table_name("product")
157            .property(
158                PropertyDescriptor::new("id", DataType::U64)
159                    .column_name("id")
160                    .id()
161                    .not_null(),
162            )
163            .property(PropertyDescriptor::new("name", DataType::Text).column_name("name"))
164    }
165
166    #[derive(Debug, Default)]
167    struct StubExecutor {
168        affected: u64,
169        rows: Vec<Record>,
170    }
171
172    #[derive(Debug, Default)]
173    struct QueueExecutor {
174        affected: u64,
175        rows: Mutex<VecDeque<Vec<Record>>>,
176        queries: Mutex<Vec<String>>,
177    }
178
179    struct OrderBehavior;
180
181    #[allow(dead_code)]
182    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
183    #[teaql(entity = "CatalogProduct", table = "catalog_product")]
184    struct CatalogProductRow {
185        #[teaql(id)]
186        id: u64,
187        name: String,
188    }
189
190    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
191    #[teaql(entity = "OrderAggregate", table = "orders")]
192    struct OrderAggregateDynamic {
193        #[teaql(id)]
194        id: u64,
195        #[teaql(dynamic)]
196        dynamic: BTreeMap<String, Value>,
197    }
198
199    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
200    #[teaql(entity = "Product", table = "product")]
201    struct ProductEntityRow {
202        #[teaql(id)]
203        id: u64,
204        name: String,
205    }
206
207    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
208    #[teaql(entity = "OrderLine", table = "orderline")]
209    struct OrderLineEntityRow {
210        #[teaql(id)]
211        id: u64,
212        #[teaql(column = "order_id")]
213        order_id: u64,
214        name: String,
215        #[teaql(column = "product_id")]
216        product_id: u64,
217        #[teaql(relation(target = "Product", local_key = "product_id", foreign_key = "id"))]
218        product: Option<ProductEntityRow>,
219    }
220
221    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
222    #[teaql(entity = "Order", table = "orders")]
223    struct OrderAggregateRow {
224        #[teaql(id)]
225        id: u64,
226        #[teaql(version)]
227        version: i64,
228        name: String,
229        #[teaql(relation(target = "OrderLine", local_key = "id", foreign_key = "order_id", many))]
230        lines: teaql_core::SmartList<OrderLineEntityRow>,
231    }
232
233    #[derive(Debug, Clone, PartialEq, DeriveTeaqlEntity)]
234    #[teaql(entity = "Order", table = "orders")]
235    struct Order {
236        #[teaql(id)]
237        id: u64,
238        #[teaql(version)]
239        version: i64,
240        name: String,
241    }
242
243    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
244    #[teaql(entity = "Product", table = "product")]
245    struct TypedGraphProduct {
246        #[teaql(id)]
247        id: u64,
248        name: String,
249    }
250
251    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
252    #[teaql(entity = "OrderLine", table = "orderline")]
253    struct TypedGraphLine {
254        #[teaql(id)]
255        id: u64,
256        #[teaql(column = "order_id")]
257        order_id: Option<u64>,
258        name: String,
259        #[teaql(column = "product_id")]
260        product_id: Option<u64>,
261        #[teaql(relation(target = "Product", local_key = "product_id", foreign_key = "id"))]
262        product: Option<TypedGraphProduct>,
263    }
264
265    #[derive(Debug, PartialEq, DeriveTeaqlEntity)]
266    #[teaql(entity = "Order", table = "orders")]
267    struct TypedGraphOrder {
268        #[teaql(id)]
269        id: u64,
270        #[teaql(version)]
271        version: i64,
272        name: String,
273        #[teaql(relation(target = "OrderLine", local_key = "id", foreign_key = "order_id", many))]
274        lines: teaql_core::SmartList<TypedGraphLine>,
275    }
276
277    #[derive(Debug, PartialEq, Eq)]
278    struct OrderEntity {
279        id: u64,
280        version: i64,
281        name: String,
282    }
283
284    impl teaql_core::TeaqlEntity for OrderEntity {
285        fn entity_descriptor() -> EntityDescriptor {
286            entity()
287        }
288    }
289
290    impl Entity for OrderEntity {
291        fn from_record(record: Record) -> Result<Self, EntityError> {
292            let id = match record.get("id") {
293                Some(Value::U64(v)) => *v,
294                Some(Value::I64(v)) if *v >= 0 => *v as u64,
295                other => {
296                    return Err(EntityError::new(
297                        "Order",
298                        format!("invalid id field: {other:?}"),
299                    ));
300                }
301            };
302            let version = match record.get("version") {
303                Some(Value::I64(v)) => *v,
304                other => {
305                    return Err(EntityError::new(
306                        "Order",
307                        format!("invalid version field: {other:?}"),
308                    ));
309                }
310            };
311            let name = match record.get("name") {
312                Some(Value::Text(v)) => v.clone(),
313                other => {
314                    return Err(EntityError::new(
315                        "Order",
316                        format!("invalid name field: {other:?}"),
317                    ));
318                }
319            };
320            Ok(Self { id, version, name })
321        }
322
323        fn into_record(self) -> Record {
324            Record::from([
325                (String::from("id"), Value::U64(self.id)),
326                (String::from("version"), Value::I64(self.version)),
327                (String::from("name"), Value::Text(self.name)),
328            ])
329        }
330    }
331
332    #[derive(Debug)]
333    struct StubError;
334
335    impl std::fmt::Display for StubError {
336        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337            write!(f, "stub error")
338        }
339    }
340
341    impl std::error::Error for StubError {}
342
343    impl QueryExecutor for StubExecutor {
344        type Error = StubError;
345
346        fn fetch_all(&self, _query: &CompiledQuery) -> Result<Vec<Record>, Self::Error> {
347            Ok(self.rows.clone())
348        }
349
350        fn execute(&self, _query: &CompiledQuery) -> Result<u64, Self::Error> {
351            Ok(self.affected)
352        }
353
354        fn begin_transaction(&self) -> Result<GraphTransactionBoundary, Self::Error> {
355            Ok(GraphTransactionBoundary::Started)
356        }
357    }
358
359    impl QueryExecutor for QueueExecutor {
360        type Error = StubError;
361
362        fn fetch_all(&self, query: &CompiledQuery) -> Result<Vec<Record>, Self::Error> {
363            self.queries.lock().unwrap().push(query.sql.clone());
364            Ok(self.rows.lock().unwrap().pop_front().unwrap_or_default())
365        }
366
367        fn execute(&self, _query: &CompiledQuery) -> Result<u64, Self::Error> {
368            Ok(self.affected)
369        }
370    }
371
372    #[test]
373    fn user_context_records_configured_sql_logs() {
374        let mut ctx = UserContext::new()
375            .with_module(crate::module!(Order))
376            .with_sql_log_options(SqlLogOptions::select_only());
377        ctx.insert_resource(PostgresDialect);
378        ctx.insert_resource(StubExecutor {
379            affected: 1,
380            rows: Vec::new(),
381        });
382
383        {
384            let repo = ctx
385                .resolve_repository::<PostgresDialect, StubExecutor>("Order")
386                .unwrap();
387            repo.fetch_all(&SelectQuery::new("Order").filter(Expr::eq("name", "Bob's Shop")))
388                .unwrap();
389            repo.insert(&InsertCommand::new("Order").value("name", "created"))
390                .unwrap();
391        }
392
393        let logs = ctx.sql_logs();
394        assert_eq!(logs.len(), 1);
395        assert_eq!(logs[0].operation, SqlLogOperation::Select);
396        assert_eq!(logs[0].result_count, Some(0));
397        assert_eq!(logs[0].result_type.as_deref(), Some("Order"));
398        assert_eq!(logs[0].result_summary, "MISS");
399        assert!(logs[0].ended_at >= logs[0].started_at);
400        assert!(logs[0].pretty_sql.contains("\nFROM orders"));
401        assert!(
402            logs[0]
403                .pretty_sql
404                .contains("\nWHERE (name = 'Bob''s Shop')")
405        );
406        assert_eq!(
407            logs[0].debug_sql,
408            format!(
409                "SELECT {ORDER_DEFAULT_PROJECTION} FROM orders WHERE (name = 'Bob''s Shop')"
410            )
411        );
412
413        ctx.set_sql_log_options(SqlLogOptions::mutation_only());
414        ctx.clear_sql_logs();
415        ctx.resolve_repository::<PostgresDialect, StubExecutor>("Order")
416            .unwrap()
417            .update(
418                &UpdateCommand::new("Order", 1_u64)
419                    .value("name", "updated")
420                    .expected_version(1),
421            )
422            .unwrap();
423
424        let logs = ctx.sql_logs();
425        assert_eq!(logs.len(), 1);
426        assert_eq!(logs[0].operation, SqlLogOperation::Update);
427        assert_eq!(logs[0].affected_rows, Some(1));
428        assert_eq!(logs[0].result_summary, "1 UPDATED");
429        assert!(logs[0].debug_sql.contains("UPDATE orders SET"));
430        assert!(logs[0].debug_sql.contains("'updated'"));
431    }
432
433    #[test]
434    fn user_context_records_all_sql_logs_by_default() {
435        let mut ctx = UserContext::new().with_module(crate::module!(Order));
436        ctx.insert_resource(PostgresDialect);
437        ctx.insert_resource(StubExecutor {
438            affected: 1,
439            rows: Vec::new(),
440        });
441
442        {
443            let repo = ctx
444                .resolve_repository::<PostgresDialect, StubExecutor>("Order")
445                .unwrap();
446            repo.fetch_all(&SelectQuery::new("Order")).unwrap();
447            repo.insert(&InsertCommand::new("Order").value("name", "created"))
448                .unwrap();
449        }
450
451        let logs = ctx.sql_logs();
452        assert_eq!(logs.len(), 2);
453        assert_eq!(logs[0].operation, SqlLogOperation::Select);
454        assert_eq!(logs[1].operation, SqlLogOperation::Insert);
455
456        ctx.disable_sql_log();
457        ctx.resolve_repository::<PostgresDialect, StubExecutor>("Order")
458            .unwrap()
459            .fetch_all(&SelectQuery::new("Order"))
460            .unwrap();
461        assert!(ctx.sql_logs().is_empty());
462    }
463
464    impl RepositoryBehavior for OrderBehavior {
465        fn before_select(
466            &self,
467            _ctx: &UserContext,
468            query: &mut teaql_core::SelectQuery,
469        ) -> Result<(), RuntimeError> {
470            query.filter = Some(Expr::eq("version", 1_i64));
471            Ok(())
472        }
473
474        fn before_insert(
475            &self,
476            _ctx: &UserContext,
477            command: &mut InsertCommand,
478        ) -> Result<(), RuntimeError> {
479            command
480                .values
481                .entry("version".to_owned())
482                .or_insert(Value::I64(1));
483            Ok(())
484        }
485
486        fn relation_loads(&self, _ctx: &UserContext) -> Vec<String> {
487            vec!["lines".to_owned()]
488        }
489    }
490
491    struct ContextAwareOrderBehavior;
492    struct TenantRequestPolicy;
493    struct OrderChecker;
494    struct TypedOrderChecker;
495    #[derive(Clone)]
496    struct RecordingEventSink {
497        events: Arc<Mutex<Vec<EntityEvent>>>,
498    }
499
500    impl RepositoryBehavior for ContextAwareOrderBehavior {
501        fn before_insert(
502            &self,
503            ctx: &UserContext,
504            command: &mut InsertCommand,
505        ) -> Result<(), RuntimeError> {
506            let tenant = ctx
507                .get_named_resource::<String>("tenant")
508                .cloned()
509                .ok_or_else(|| RuntimeError::Behavior("missing tenant resource".to_owned()))?;
510            let version = *ctx
511                .get_named_resource::<i64>("initial_version")
512                .ok_or_else(|| {
513                    RuntimeError::Behavior("missing initial_version resource".to_owned())
514                })?;
515            let trace_id = match ctx.local("trace_id") {
516                Some(Value::Text(value)) => value.clone(),
517                other => {
518                    return Err(RuntimeError::Behavior(format!(
519                        "missing trace_id local, got {other:?}"
520                    )));
521                }
522            };
523
524            command
525                .values
526                .entry("name".to_owned())
527                .or_insert(Value::Text(format!("{tenant}:{trace_id}")));
528            command
529                .values
530                .entry("version".to_owned())
531                .or_insert(Value::I64(version));
532            Ok(())
533        }
534    }
535
536    impl RequestPolicy for TenantRequestPolicy {
537        fn enforce_select(
538            &self,
539            ctx: &UserContext,
540            query: &mut SelectQuery,
541        ) -> Result<(), RuntimeError> {
542            if query.entity == "Order" {
543                let tenant_id = ctx
544                    .get_named_resource::<u64>("tenant_id")
545                    .copied()
546                    .ok_or_else(|| RuntimeError::Policy("missing tenant_id".to_owned()))?;
547                query.filter = Some(match query.filter.take() {
548                    Some(filter) => filter.and_expr(Expr::eq("id", tenant_id)),
549                    None => Expr::eq("id", tenant_id),
550                });
551            }
552            Ok(())
553        }
554
555        fn enforce_insert(
556            &self,
557            ctx: &UserContext,
558            command: &mut InsertCommand,
559        ) -> Result<(), RuntimeError> {
560            if command.entity == "Order" {
561                let tenant_id = ctx
562                    .get_named_resource::<u64>("tenant_id")
563                    .copied()
564                    .ok_or_else(|| RuntimeError::Policy("missing tenant_id".to_owned()))?;
565                command
566                    .values
567                    .insert("version".to_owned(), Value::I64(tenant_id as i64));
568            }
569            Ok(())
570        }
571    }
572
573    impl Checker for OrderChecker {
574        fn entity(&self) -> &str {
575            "Order"
576        }
577
578        fn check_and_fix(
579            &self,
580            _ctx: &UserContext,
581            record: &mut Record,
582            location: &ObjectLocation,
583            results: &mut CheckResults,
584        ) {
585            let status = CheckObjectStatus::from_record(record);
586            if status.is_create() {
587                self.required(record, "name", location, results);
588                record.entry("version".to_owned()).or_insert(Value::I64(1));
589            }
590            if status.is_update()
591                && record.get("name") == Some(&Value::Text("graph-update".to_owned()))
592            {
593                record.insert(
594                    "name".to_owned(),
595                    Value::Text("graph-update-checked".to_owned()),
596                );
597            }
598            self.min_string_length(record, "name", 3, location, results);
599        }
600    }
601
602    impl TypedChecker<Order> for TypedOrderChecker {
603        fn check_and_fix_typed(
604            &self,
605            _ctx: &UserContext,
606            entity: &mut Order,
607            status: CheckObjectStatus,
608            location: &ObjectLocation,
609            results: &mut CheckResults,
610        ) {
611            if status.is_create() {
612                if entity.name.is_empty() {
613                    results.push(CheckResult::required(location.clone().member("name")));
614                }
615            }
616            if entity.name.chars().count() < 3 {
617                results.push(CheckResult::min_str(
618                    location.clone().member("name"),
619                    3,
620                    entity.name.clone(),
621                ));
622            }
623            if entity.name == "fix" {
624                entity.name = "fixed".to_owned();
625            }
626        }
627    }
628
629    impl EntityEventSink for RecordingEventSink {
630        fn on_event(&self, _ctx: &UserContext, event: &EntityEvent) -> Result<(), RuntimeError> {
631            self.events.lock().unwrap().push(event.clone());
632            Ok(())
633        }
634    }
635
636    struct FixedIdGenerator(u64);
637
638    impl InternalIdGenerator for FixedIdGenerator {
639        fn generate_id(&self, _entity: &str) -> Result<u64, RuntimeError> {
640            Ok(self.0)
641        }
642    }
643
644    struct SequentialIdGenerator {
645        next: Mutex<u64>,
646    }
647
648    impl SequentialIdGenerator {
649        fn new(next: u64) -> Self {
650            Self {
651                next: Mutex::new(next),
652            }
653        }
654    }
655
656    impl InternalIdGenerator for SequentialIdGenerator {
657        fn generate_id(&self, _entity: &str) -> Result<u64, RuntimeError> {
658            let mut next = self
659                .next
660                .lock()
661                .map_err(|err| RuntimeError::IdGeneration(err.to_string()))?;
662            let id = *next;
663            *next += 1;
664            Ok(id)
665        }
666    }
667
668    #[test]
669    fn metadata_store_registers_entities() {
670        let store = InMemoryMetadataStore::new().with_entity(entity());
671        assert!(store.entity("Order").is_some());
672    }
673
674    #[test]
675    fn runtime_module_registers_descriptor_into_context() {
676        let ctx = UserContext::new().with_module(RuntimeModule::new().descriptor(entity()));
677        assert!(ctx.entity("Order").is_some());
678        assert!(ctx.has_repository("Order"));
679    }
680
681    #[test]
682    fn runtime_module_registers_derived_entity_and_behavior() {
683        let ctx = UserContext::new().with_module(
684            RuntimeModule::new().entity_with_behavior::<CatalogProductRow, _>(OrderBehavior),
685        );
686        assert!(ctx.entity("CatalogProduct").is_some());
687        assert!(ctx.has_repository("CatalogProduct"));
688        assert!(ctx.repository_behavior("CatalogProduct").is_some());
689    }
690
691    #[test]
692    fn module_macro_registers_multiple_entities() {
693        let ctx = UserContext::new().with_module(crate::module!(CatalogProductRow));
694        assert!(ctx.entity("CatalogProduct").is_some());
695        assert!(ctx.has_repository("CatalogProduct"));
696    }
697
698    #[test]
699    fn module_macro_registers_entity_behavior_pairs() {
700        let ctx =
701            UserContext::new().with_module(crate::module!(CatalogProductRow => OrderBehavior));
702        assert!(ctx.entity("CatalogProduct").is_some());
703        assert!(ctx.repository_behavior("CatalogProduct").is_some());
704    }
705
706    #[test]
707    fn repository_returns_optimistic_lock_conflict() {
708        let store = InMemoryMetadataStore::new().with_entity(entity());
709        let executor = StubExecutor {
710            affected: 0,
711            rows: Vec::new(),
712        };
713        let repo = Repository::new(&PostgresDialect, &store, &executor);
714
715        let err = repo
716            .update(
717                &UpdateCommand::new("Order", 1_u64)
718                    .expected_version(3)
719                    .value("name", "next"),
720            )
721            .unwrap_err();
722
723        match err {
724            RepositoryError::Runtime(RuntimeError::OptimisticLockConflict { .. }) => {}
725            other => panic!("unexpected error: {other}"),
726        }
727    }
728
729    #[test]
730    fn user_context_indexes_resources_and_locals() {
731        let mut ctx =
732            UserContext::new().with_metadata(InMemoryMetadataStore::new().with_entity(entity()));
733        ctx.insert_resource::<u64>(42);
734        ctx.insert_named_resource("tenant", String::from("acme"));
735        ctx.put_local("trace_id", "req-1");
736
737        assert!(ctx.entity("Order").is_some());
738        assert_eq!(ctx.get_resource::<u64>(), Some(&42));
739        assert_eq!(
740            ctx.get_named_resource::<String>("tenant"),
741            Some(&String::from("acme"))
742        );
743        assert_eq!(
744            ctx.local("trace_id"),
745            Some(&Value::Text("req-1".to_owned()))
746        );
747    }
748
749    #[test]
750    fn user_context_builds_context_repository() {
751        let mut ctx =
752            UserContext::new().with_metadata(InMemoryMetadataStore::new().with_entity(entity()));
753        ctx.insert_resource(PostgresDialect);
754        ctx.insert_resource(StubExecutor {
755            affected: 1,
756            rows: Vec::new(),
757        });
758
759        let repo = ctx.repository::<PostgresDialect, StubExecutor>().unwrap();
760        let affected = repo
761            .update(
762                &UpdateCommand::new("Order", 1_u64)
763                    .expected_version(3)
764                    .value("name", "next"),
765            )
766            .unwrap();
767
768        assert_eq!(affected, 1);
769    }
770
771    #[test]
772    fn user_context_resolves_repository_by_entity_type() {
773        let mut ctx = UserContext::new()
774            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
775            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
776        ctx.insert_resource(PostgresDialect);
777        ctx.insert_resource(StubExecutor {
778            affected: 1,
779            rows: Vec::new(),
780        });
781
782        let repo = ctx
783            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
784            .unwrap();
785        assert_eq!(repo.entity(), "Order");
786        assert_eq!(repo.select().entity, "Order");
787
788        let affected = repo
789            .insert(
790                &repo
791                    .insert_command()
792                    .value("id", 1_u64)
793                    .value("version", 1_i64)
794                    .value("name", "n"),
795            )
796            .unwrap();
797        assert_eq!(affected, 1);
798    }
799
800    #[test]
801    fn resolved_repository_applies_behavior_hooks() {
802        let mut ctx = UserContext::new()
803            .with_metadata(
804                InMemoryMetadataStore::new()
805                    .with_entity(entity())
806                    .with_entity(line_entity())
807                    .with_entity(product_entity()),
808            )
809            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
810            .with_repository_behavior_registry(
811                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
812            );
813        ctx.insert_resource(PostgresDialect);
814        ctx.insert_resource(StubExecutor {
815            affected: 1,
816            rows: Vec::new(),
817        });
818
819        let repo = ctx
820            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
821            .unwrap();
822
823        let compiled = repo.compile(&repo.select()).unwrap();
824        assert!(compiled.sql.contains("WHERE (version = $1)"));
825
826        let insert = repo.insert_command().value("id", 1_u64).value("name", "n");
827        let affected = repo.insert(&insert).unwrap();
828        assert_eq!(affected, 1);
829        assert_eq!(repo.relation_loads(), vec!["lines".to_owned()]);
830    }
831
832    #[test]
833    fn resolved_repository_applies_request_policy_after_behavior_hooks() {
834        let mut ctx = UserContext::new()
835            .with_metadata(
836                InMemoryMetadataStore::new()
837                    .with_entity(entity())
838                    .with_entity(line_entity())
839                    .with_entity(product_entity()),
840            )
841            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
842            .with_repository_behavior_registry(
843                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
844            )
845            .with_request_policy(TenantRequestPolicy);
846        ctx.insert_named_resource("tenant_id", 9_u64);
847        ctx.insert_resource(PostgresDialect);
848        ctx.insert_resource(StubExecutor {
849            affected: 1,
850            rows: Vec::new(),
851        });
852
853        let repo = ctx
854            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
855            .unwrap();
856
857        let compiled = repo.compile(&repo.select()).unwrap();
858        assert!(compiled.sql.contains("version = $1"));
859        assert!(compiled.sql.contains("id = $2"));
860
861        let insert = repo.insert_command().value("id", 1_u64).value("name", "n");
862        let command = repo.prepare_insert_command(&insert).unwrap();
863        assert_eq!(command.values.get("version"), Some(&Value::I64(9)));
864    }
865
866    #[test]
867    fn resolved_repository_prepares_insert_command_with_generated_id() {
868        let mut ctx = UserContext::new()
869            .with_metadata(
870                InMemoryMetadataStore::new()
871                    .with_entity(entity())
872                    .with_entity(line_entity())
873                    .with_entity(product_entity()),
874            )
875            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
876            .with_repository_behavior_registry(
877                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
878            )
879            .with_internal_id_generator(FixedIdGenerator(42));
880        ctx.insert_resource(PostgresDialect);
881        ctx.insert_resource(StubExecutor {
882            affected: 1,
883            rows: Vec::new(),
884        });
885
886        let repo = ctx
887            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
888            .unwrap();
889
890        let prepared = repo
891            .prepare_insert_command(&repo.insert_command().value("id", 0_u64).value("name", "n"))
892            .unwrap();
893
894        assert_eq!(prepared.values.get("id"), Some(&Value::U64(42)));
895        assert_eq!(prepared.values.get("version"), Some(&Value::I64(1)));
896        assert_eq!(
897            prepared.values.get("name"),
898            Some(&Value::Text("n".to_owned()))
899        );
900
901        let prepared_zero_version = repo
902            .prepare_insert_command(
903                &repo
904                    .insert_command()
905                    .value("id", 0_u64)
906                    .value("version", 0_i64)
907                    .value("name", "zero-version"),
908            )
909            .unwrap();
910        assert_eq!(
911            prepared_zero_version.values.get("version"),
912            Some(&Value::I64(1))
913        );
914    }
915
916    #[test]
917    fn resolved_repository_saves_create_graph_and_maintains_relation_keys() {
918        let mut ctx = UserContext::new()
919            .with_metadata(
920                InMemoryMetadataStore::new()
921                    .with_entity(entity())
922                    .with_entity(line_entity())
923                    .with_entity(product_entity()),
924            )
925            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
926            .with_repository_behavior_registry(
927                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
928            )
929            .with_internal_id_generator(SequentialIdGenerator::new(500));
930        ctx.insert_resource(PostgresDialect);
931        ctx.insert_resource(StubExecutor {
932            affected: 1,
933            rows: Vec::new(),
934        });
935
936        let repo = ctx
937            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
938            .unwrap();
939        let graph = GraphNode::new("Order").value("name", "root").relation(
940            "lines",
941            GraphNode::new("OrderLine")
942                .value("name", "line-1")
943                .relation("product", GraphNode::new("Product").value("name", "sku-1")),
944        );
945
946        let saved = repo.save_graph(graph).unwrap();
947
948        assert_eq!(saved.values.get("id"), Some(&Value::U64(500)));
949        assert_eq!(saved.values.get("version"), Some(&Value::I64(1)));
950        let lines = saved.relations.get("lines").unwrap();
951        assert_eq!(lines.len(), 1);
952        assert_eq!(lines[0].values.get("id"), Some(&Value::U64(501)));
953        assert_eq!(lines[0].values.get("version"), Some(&Value::I64(1)));
954        assert_eq!(lines[0].values.get("order_id"), Some(&Value::U64(500)));
955        assert_eq!(lines[0].values.get("product_id"), Some(&Value::U64(502)));
956        let product = lines[0].relations.get("product").unwrap();
957        assert_eq!(product[0].values.get("id"), Some(&Value::U64(502)));
958    }
959
960    #[test]
961    fn resolved_repository_extracts_and_saves_typed_entity_graph() {
962        let mut ctx = UserContext::new()
963            .with_metadata(
964                InMemoryMetadataStore::new()
965                    .with_entity(entity())
966                    .with_entity(line_entity())
967                    .with_entity(product_entity()),
968            )
969            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
970            .with_internal_id_generator(SequentialIdGenerator::new(700));
971        ctx.insert_resource(PostgresDialect);
972        ctx.insert_resource(StubExecutor {
973            affected: 1,
974            rows: Vec::new(),
975        });
976
977        let repo = ctx
978            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
979            .unwrap();
980        let order = TypedGraphOrder {
981            id: 0,
982            version: 0,
983            name: "typed-root".to_owned(),
984            lines: teaql_core::SmartList::from(vec![TypedGraphLine {
985                id: 0,
986                order_id: None,
987                name: "typed-line".to_owned(),
988                product_id: None,
989                product: Some(TypedGraphProduct {
990                    id: 0,
991                    name: "typed-product".to_owned(),
992                }),
993            }]),
994        };
995
996        let extracted = repo.graph_node_from_entity(order).unwrap();
997        assert_eq!(extracted.entity, "Order");
998        assert_eq!(
999            extracted.values.get("name"),
1000            Some(&Value::Text("typed-root".to_owned()))
1001        );
1002        assert_eq!(extracted.values.get("id"), Some(&Value::U64(0)));
1003        assert_eq!(extracted.relations["lines"].len(), 1);
1004        assert_eq!(
1005            extracted.relations["lines"][0].values.get("name"),
1006            Some(&Value::Text("typed-line".to_owned()))
1007        );
1008        assert_eq!(
1009            extracted.relations["lines"][0].relations["product"].len(),
1010            1
1011        );
1012
1013        let saved = repo.save_graph(extracted).unwrap();
1014        assert_eq!(saved.values.get("id"), Some(&Value::U64(700)));
1015        assert_eq!(saved.values.get("version"), Some(&Value::I64(1)));
1016        let lines = saved.relations.get("lines").unwrap();
1017        assert_eq!(lines[0].values.get("id"), Some(&Value::U64(701)));
1018        assert_eq!(lines[0].values.get("version"), Some(&Value::I64(1)));
1019        assert_eq!(lines[0].values.get("order_id"), Some(&Value::U64(700)));
1020        assert_eq!(lines[0].values.get("product_id"), Some(&Value::U64(702)));
1021        assert_eq!(
1022            lines[0].relations["product"][0].values.get("id"),
1023            Some(&Value::U64(702))
1024        );
1025    }
1026
1027    #[test]
1028    fn resolved_repository_saves_typed_entity_graph_directly() {
1029        let mut ctx = UserContext::new()
1030            .with_metadata(
1031                InMemoryMetadataStore::new()
1032                    .with_entity(entity())
1033                    .with_entity(line_entity())
1034                    .with_entity(product_entity()),
1035            )
1036            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1037            .with_internal_id_generator(SequentialIdGenerator::new(800));
1038        ctx.insert_resource(PostgresDialect);
1039        ctx.insert_resource(StubExecutor {
1040            affected: 1,
1041            rows: Vec::new(),
1042        });
1043
1044        let repo = ctx
1045            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1046            .unwrap();
1047        let saved = repo
1048            .save_entity_graph(TypedGraphOrder {
1049                id: 0,
1050                version: 0,
1051                name: "typed-direct".to_owned(),
1052                lines: teaql_core::SmartList::from(vec![TypedGraphLine {
1053                    id: 0,
1054                    order_id: None,
1055                    name: "typed-line".to_owned(),
1056                    product_id: None,
1057                    product: Some(TypedGraphProduct {
1058                        id: 0,
1059                        name: "typed-product".to_owned(),
1060                    }),
1061                }]),
1062            })
1063            .unwrap();
1064
1065        assert_eq!(saved.values.get("id"), Some(&Value::U64(800)));
1066        assert_eq!(saved.values.get("version"), Some(&Value::I64(1)));
1067        assert_eq!(
1068            saved.relations["lines"][0].values.get("order_id"),
1069            Some(&Value::U64(800))
1070        );
1071        assert_eq!(
1072            saved.relations["lines"][0].values.get("product_id"),
1073            Some(&Value::U64(802))
1074        );
1075    }
1076
1077    #[test]
1078    fn custom_user_context_can_drive_insert_preparation() {
1079        let mut ctx = UserContext::new()
1080            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1081            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1082            .with_repository_behavior_registry(
1083                InMemoryRepositoryBehaviorRegistry::new()
1084                    .with_behavior("Order", ContextAwareOrderBehavior),
1085            )
1086            .with_internal_id_generator(FixedIdGenerator(99));
1087        ctx.insert_named_resource("tenant", String::from("acme"));
1088        ctx.insert_named_resource("initial_version", 7_i64);
1089        ctx.put_local("trace_id", "req-9");
1090        ctx.insert_resource(PostgresDialect);
1091        ctx.insert_resource(StubExecutor {
1092            affected: 1,
1093            rows: Vec::new(),
1094        });
1095
1096        let repo = ctx
1097            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1098            .unwrap();
1099        let prepared = repo.prepare_insert_command(&repo.insert_command()).unwrap();
1100
1101        assert_eq!(prepared.values.get("id"), Some(&Value::U64(99)));
1102        assert_eq!(prepared.values.get("version"), Some(&Value::I64(7)));
1103        assert_eq!(
1104            prepared.values.get("name"),
1105            Some(&Value::Text("acme:req-9".to_owned()))
1106        );
1107    }
1108
1109    #[test]
1110    fn checker_registry_validates_and_fixes_insert_commands() {
1111        let mut ctx = UserContext::new()
1112            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1113            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1114            .with_checker_registry(InMemoryCheckerRegistry::new().with_checker(OrderChecker))
1115            .with_internal_id_generator(FixedIdGenerator(77));
1116        ctx.insert_resource(PostgresDialect);
1117        ctx.insert_resource(StubExecutor {
1118            affected: 1,
1119            rows: Vec::new(),
1120        });
1121
1122        let repo = ctx
1123            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1124            .unwrap();
1125        let prepared = repo
1126            .prepare_insert_command(&repo.insert_command().value("name", "valid"))
1127            .unwrap();
1128
1129        assert_eq!(prepared.values.get("id"), Some(&Value::U64(77)));
1130        assert_eq!(prepared.values.get("version"), Some(&Value::I64(1)));
1131        assert!(!prepared.values.contains_key(CHECK_OBJECT_STATUS_FIELD));
1132
1133        let error = repo
1134            .prepare_insert_command(&repo.insert_command().value("name", "no"))
1135            .unwrap_err();
1136        match error {
1137            RuntimeError::Check(results) => {
1138                assert_eq!(results.len(), 1);
1139                assert_eq!(results[0].location.to_string(), "name");
1140            }
1141            other => panic!("unexpected checker error: {other:?}"),
1142        }
1143    }
1144
1145    #[test]
1146    fn typed_checker_validates_and_fixes_derived_entities_without_record_access() {
1147        let mut ctx = UserContext::new()
1148            .with_metadata(InMemoryMetadataStore::new().with_entity(Order::entity_descriptor()))
1149            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1150            .with_checker_registry(
1151                InMemoryCheckerRegistry::new()
1152                    .with_checker(TypedEntityChecker::<Order, _>::new(TypedOrderChecker)),
1153            )
1154            .with_internal_id_generator(FixedIdGenerator(79));
1155        ctx.insert_resource(PostgresDialect);
1156        ctx.insert_resource(StubExecutor {
1157            affected: 1,
1158            rows: Vec::new(),
1159        });
1160
1161        let repo = ctx
1162            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1163            .unwrap();
1164        let prepared = repo
1165            .prepare_insert_command(
1166                &repo
1167                    .insert_command()
1168                    .value("name", "fix")
1169                    .value("version", 1_i64),
1170            )
1171            .unwrap();
1172        assert_eq!(
1173            prepared.values.get("name"),
1174            Some(&Value::Text("fixed".to_owned()))
1175        );
1176        assert_eq!(prepared.values.get("id"), Some(&Value::U64(79)));
1177        assert!(!prepared.values.contains_key(CHECK_OBJECT_STATUS_FIELD));
1178
1179        let error = repo
1180            .prepare_insert_command(&repo.insert_command().value("version", 1_i64))
1181            .unwrap_err();
1182        match error {
1183            RuntimeError::Check(results) => {
1184                assert!(
1185                    results
1186                        .iter()
1187                        .any(|result| result.rule == CheckRule::Required
1188                            && result.location.to_string() == "name")
1189                );
1190            }
1191            other => panic!("unexpected typed checker error: {other:?}"),
1192        }
1193    }
1194
1195    #[test]
1196    fn checker_registry_validates_update_commands_without_required_insert_checks() {
1197        let mut ctx = UserContext::new()
1198            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1199            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1200            .with_checker_registry(InMemoryCheckerRegistry::new().with_checker(OrderChecker));
1201        ctx.insert_resource(PostgresDialect);
1202        ctx.insert_resource(StubExecutor {
1203            affected: 1,
1204            rows: Vec::new(),
1205        });
1206
1207        let repo = ctx
1208            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1209            .unwrap();
1210        repo.update(&repo.update_command(1_u64).value("version", 1_i64))
1211            .unwrap();
1212
1213        let error = repo
1214            .update(&repo.update_command(1_u64).value("name", "no"))
1215            .unwrap_err();
1216        match error {
1217            RepositoryError::Runtime(RuntimeError::Check(results)) => {
1218                assert_eq!(results.len(), 1);
1219                assert_eq!(results[0].location.to_string(), "name");
1220            }
1221            other => panic!("unexpected checker error: {other:?}"),
1222        }
1223    }
1224
1225    #[test]
1226    fn checker_registry_reports_nested_create_locations_and_fixes_records() {
1227        let ctx = UserContext::new()
1228            .with_checker_registry(InMemoryCheckerRegistry::new().with_checker(OrderChecker));
1229
1230        let mut child = Record::from([
1231            (String::from("id"), Value::U64(10)),
1232            (
1233                String::from(CHECK_OBJECT_STATUS_FIELD),
1234                Value::from(CheckObjectStatus::Create),
1235            ),
1236        ]);
1237        let error = ctx
1238            .check_and_fix_record_at(
1239                "Order",
1240                &mut child,
1241                &ObjectLocation::hash_root("lines").element(0),
1242            )
1243            .unwrap_err();
1244
1245        assert_eq!(child.get("version"), Some(&Value::I64(1)));
1246        match error {
1247            RuntimeError::Check(results) => {
1248                assert_eq!(results.len(), 1);
1249                assert_eq!(results[0].rule, CheckRule::Required);
1250                assert_eq!(results[0].location.to_string(), "lines[0].name");
1251            }
1252            other => panic!("unexpected checker error: {other:?}"),
1253        }
1254
1255        child.insert("name".to_owned(), Value::Text("valid child".to_owned()));
1256        ctx.check_and_fix_record_at(
1257            "Order",
1258            &mut child,
1259            &ObjectLocation::hash_root("lines").element(0),
1260        )
1261        .unwrap();
1262    }
1263
1264    #[test]
1265    fn built_in_language_translators_cover_fifteen_languages() {
1266        assert_eq!(Language::ALL.len(), 15);
1267        let result = super::CheckResult::required(ObjectLocation::hash_root("name"));
1268        let messages = Language::ALL
1269            .iter()
1270            .map(|language| translate_check_result(*language, &result))
1271            .collect::<Vec<_>>();
1272
1273        assert!(messages.iter().all(|message| !message.is_empty()));
1274        assert!(messages.iter().any(|message| message.contains("required")));
1275        assert!(messages.iter().any(|message| message.contains("å¿…å¡«")));
1276        assert!(
1277            messages
1278                .iter()
1279                .any(|message| message.contains("obligatoire"))
1280        );
1281        assert_eq!(Language::from_code("zh-CN"), Some(Language::Chinese));
1282        assert_eq!(
1283            Language::from_code("zh-TW"),
1284            Some(Language::TraditionalChinese)
1285        );
1286    }
1287
1288    #[test]
1289    fn user_context_language_switch_translates_checker_errors() {
1290        let mut ctx = UserContext::new()
1291            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1292            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1293            .with_checker_registry(InMemoryCheckerRegistry::new().with_checker(OrderChecker))
1294            .with_internal_id_generator(FixedIdGenerator(77))
1295            .with_language(Language::Chinese);
1296        ctx.insert_resource(PostgresDialect);
1297        ctx.insert_resource(StubExecutor {
1298            affected: 1,
1299            rows: Vec::new(),
1300        });
1301
1302        let repo = ctx
1303            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1304            .unwrap();
1305        let error = repo
1306            .prepare_insert_command(&repo.insert_command())
1307            .unwrap_err();
1308        match error {
1309            RuntimeError::Check(results) => {
1310                assert_eq!(results.len(), 1);
1311                assert!(
1312                    results[0]
1313                        .message
1314                        .as_ref()
1315                        .is_some_and(|message| message.contains("å¿…å¡«"))
1316                );
1317            }
1318            other => panic!("unexpected checker error: {other:?}"),
1319        }
1320
1321        let mut ctx = UserContext::new().with_language(Language::English);
1322        ctx.set_language_code("es").unwrap();
1323        assert_eq!(ctx.language(), Language::Spanish);
1324    }
1325
1326    #[test]
1327    fn checker_registry_merges_graph_update_fixes_by_object_status() {
1328        let mut ctx = UserContext::new()
1329            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1330            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1331            .with_checker_registry(InMemoryCheckerRegistry::new().with_checker(OrderChecker));
1332        ctx.insert_resource(PostgresDialect);
1333        ctx.insert_resource(StubExecutor {
1334            affected: 1,
1335            rows: vec![Record::from([
1336                ("id".to_owned(), Value::U64(1)),
1337                ("version".to_owned(), Value::I64(1)),
1338                ("name".to_owned(), Value::Text("old".to_owned())),
1339            ])],
1340        });
1341
1342        let repo = ctx
1343            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1344            .unwrap();
1345        let saved = repo
1346            .save_graph(
1347                GraphNode::new("Order")
1348                    .value("id", 1_u64)
1349                    .value("version", 1_i64)
1350                    .value("name", "graph-update"),
1351            )
1352            .unwrap();
1353
1354        assert_eq!(
1355            saved.values.get("name"),
1356            Some(&Value::Text("graph-update-checked".to_owned()))
1357        );
1358        assert_eq!(saved.values.get("version"), Some(&Value::I64(2)));
1359        assert!(!saved.values.contains_key(CHECK_OBJECT_STATUS_FIELD));
1360    }
1361
1362    #[test]
1363    fn user_context_event_sink_receives_repository_mutation_events() {
1364        let events = Arc::new(Mutex::new(Vec::new()));
1365        let mut ctx = UserContext::new()
1366            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1367            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1368            .with_internal_id_generator(FixedIdGenerator(88))
1369            .with_event_sink(RecordingEventSink {
1370                events: events.clone(),
1371            });
1372        ctx.insert_resource(PostgresDialect);
1373        ctx.insert_resource(StubExecutor {
1374            affected: 1,
1375            rows: vec![Record::from([
1376                ("id".to_owned(), Value::U64(88)),
1377                ("version".to_owned(), Value::I64(1)),
1378                ("name".to_owned(), Value::Text("old".to_owned())),
1379            ])],
1380        });
1381
1382        let repo = ctx
1383            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1384            .unwrap();
1385        repo.insert(&repo.insert_command().value("name", "created"))
1386            .unwrap();
1387        repo.update(
1388            &repo
1389                .update_command(88_u64)
1390                .expected_version(1)
1391                .value("name", "updated"),
1392        )
1393        .unwrap();
1394        repo.delete(&repo.delete_command(88_u64).expected_version(2))
1395            .unwrap();
1396        repo.recover(&repo.recover_command(88_u64, -3)).unwrap();
1397
1398        let events = events.lock().unwrap();
1399        assert_eq!(events.len(), 4);
1400        assert_eq!(events[0].kind, EntityEventKind::Created);
1401        assert_eq!(events[0].entity, "Order");
1402        assert_eq!(events[0].values.get("id"), Some(&Value::U64(88)));
1403        assert_eq!(events[1].kind, EntityEventKind::Updated);
1404        assert_eq!(events[1].values.get("id"), Some(&Value::U64(88)));
1405        assert_eq!(events[1].values.get("version"), Some(&Value::I64(2)));
1406        assert_eq!(events[1].updated_fields, vec!["name".to_owned()]);
1407        assert_eq!(
1408            events[1]
1409                .old_values
1410                .as_ref()
1411                .and_then(|values| values.get("name")),
1412            Some(&Value::Text("old".to_owned()))
1413        );
1414        assert_eq!(
1415            events[1]
1416                .new_values
1417                .as_ref()
1418                .and_then(|values| values.get("name")),
1419            Some(&Value::Text("updated".to_owned()))
1420        );
1421        assert_eq!(events[1].changes.len(), 1);
1422        assert_eq!(events[1].changes[0].field, "name");
1423        assert_eq!(
1424            events[1].changes[0].old_value,
1425            Some(Value::Text("old".to_owned()))
1426        );
1427        assert_eq!(
1428            events[1].changes[0].new_value,
1429            Some(Value::Text("updated".to_owned()))
1430        );
1431        assert_eq!(events[2].kind, EntityEventKind::Deleted);
1432        assert!(events[2].old_values.is_some());
1433        assert!(events[2].new_values.is_none());
1434        assert_eq!(events[3].kind, EntityEventKind::Recovered);
1435        assert_eq!(
1436            events[3]
1437                .old_values
1438                .as_ref()
1439                .and_then(|values| values.get("version")),
1440            Some(&Value::I64(1))
1441        );
1442        assert_eq!(
1443            events[3]
1444                .new_values
1445                .as_ref()
1446                .and_then(|values| values.get("version")),
1447            Some(&Value::I64(4))
1448        );
1449        assert_eq!(events[3].changes[0].field, "version");
1450    }
1451
1452    #[test]
1453    fn user_context_event_sink_receives_mixed_graph_mutation_events() {
1454        let events = Arc::new(Mutex::new(Vec::new()));
1455        let mut ctx = UserContext::new()
1456            .with_metadata(
1457                InMemoryMetadataStore::new()
1458                    .with_entity(entity())
1459                    .with_entity(line_entity())
1460                    .with_entity(product_entity()),
1461            )
1462            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1463            .with_event_sink(RecordingEventSink {
1464                events: events.clone(),
1465            });
1466        ctx.insert_resource(PostgresDialect);
1467        ctx.insert_resource(StubExecutor {
1468            affected: 1,
1469            rows: vec![Record::from([
1470                ("id".to_owned(), Value::U64(1)),
1471                ("version".to_owned(), Value::I64(1)),
1472                ("name".to_owned(), Value::Text("old".to_owned())),
1473            ])],
1474        });
1475
1476        let repo = ctx
1477            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1478            .unwrap();
1479        repo.save_graph(
1480            GraphNode::new("Order")
1481                .value("id", 1_u64)
1482                .value("version", 1_i64)
1483                .value("name", "updated")
1484                .relation(
1485                    "lines",
1486                    GraphNode::new("OrderLine")
1487                        .value("name", "line")
1488                        .value("product_id", 3_u64),
1489                ),
1490        )
1491        .unwrap();
1492
1493        let events = events.lock().unwrap();
1494        assert_eq!(events.len(), 3);
1495        assert_eq!(events[0].kind, EntityEventKind::Updated);
1496        assert_eq!(events[0].entity, "Order");
1497        assert_eq!(events[1].kind, EntityEventKind::Updated);
1498        assert_eq!(events[1].entity, "OrderLine");
1499        assert_eq!(events[1].values.get("order_id"), Some(&Value::U64(1)));
1500        assert_eq!(events[2].kind, EntityEventKind::Deleted);
1501        assert_eq!(events[2].entity, "OrderLine");
1502    }
1503
1504    #[test]
1505    fn save_graph_builds_plan_grouped_by_entity_and_operation() {
1506        let mut ctx = UserContext::new()
1507            .with_metadata(
1508                InMemoryMetadataStore::new()
1509                    .with_entity(entity())
1510                    .with_entity(line_entity())
1511                    .with_entity(product_entity()),
1512            )
1513            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1514            .with_internal_id_generator(SequentialIdGenerator::new(500));
1515        ctx.insert_resource(PostgresDialect);
1516        ctx.insert_resource(StubExecutor {
1517            affected: 1,
1518            rows: vec![Record::from([
1519                ("id".to_owned(), Value::U64(1)),
1520                ("version".to_owned(), Value::I64(1)),
1521                ("name".to_owned(), Value::Text("old".to_owned())),
1522            ])],
1523        });
1524
1525        let plan = ctx
1526            .plan_for_save_graph::<PostgresDialect, StubExecutor>(
1527                GraphNode::new("Order")
1528                    .value("id", 1_u64)
1529                    .value("version", 1_i64)
1530                    .value("name", "updated")
1531                    .relation(
1532                        "lines",
1533                        GraphNode::new("OrderLine")
1534                            .value("name", "new-line-a")
1535                            .value("product_id", 2_u64),
1536                    )
1537                    .relation(
1538                        "lines",
1539                        GraphNode::new("OrderLine")
1540                            .value("name", "new-line-b")
1541                            .value("product_id", 2_u64),
1542                    )
1543                    .relation(
1544                        "lines",
1545                        GraphNode::new("OrderLine")
1546                            .value("id", 5_u64)
1547                            .value("version", 1_i64)
1548                            .value("name", "same-update-a"),
1549                    )
1550                    .relation(
1551                        "lines",
1552                        GraphNode::new("OrderLine")
1553                            .value("id", 6_u64)
1554                            .value("version", 1_i64)
1555                            .value("name", "same-update-b"),
1556                    )
1557                    .relation(
1558                        "lines",
1559                        GraphNode::new("OrderLine").value("id", 3_u64).remove(),
1560                    )
1561                    .relation(
1562                        "lines",
1563                        GraphNode::new("OrderLine").value("id", 4_u64).reference(),
1564                    ),
1565            )
1566            .unwrap();
1567        let counts = plan.grouped_counts();
1568
1569        assert_eq!(
1570            counts.get(&("Order".to_owned(), GraphMutationKind::Update)),
1571            Some(&1)
1572        );
1573        assert_eq!(
1574            counts.get(&("OrderLine".to_owned(), GraphMutationKind::Create)),
1575            Some(&2)
1576        );
1577        assert_eq!(
1578            counts.get(&("OrderLine".to_owned(), GraphMutationKind::Update)),
1579            Some(&2)
1580        );
1581        assert_eq!(
1582            counts.get(&("OrderLine".to_owned(), GraphMutationKind::Delete)),
1583            Some(&1)
1584        );
1585        assert_eq!(
1586            counts.get(&("OrderLine".to_owned(), GraphMutationKind::Reference)),
1587            Some(&1)
1588        );
1589        let create_batch = plan
1590            .batches
1591            .iter()
1592            .find(|batch| batch.entity == "OrderLine" && batch.kind == GraphMutationKind::Create)
1593            .unwrap();
1594        assert_eq!(create_batch.items.len(), 2);
1595        assert_eq!(
1596            create_batch.items[0].values.get("id"),
1597            Some(&Value::U64(500))
1598        );
1599        assert_eq!(
1600            create_batch.items[1].values.get("id"),
1601            Some(&Value::U64(501))
1602        );
1603        let update_batch = plan
1604            .batches
1605            .iter()
1606            .find(|batch| {
1607                batch.entity == "OrderLine"
1608                    && batch.kind == GraphMutationKind::Update
1609                    && batch.update_fields == vec!["name".to_owned()]
1610            })
1611            .unwrap();
1612        assert_eq!(update_batch.items.len(), 2);
1613    }
1614
1615    #[test]
1616    fn resolved_repository_builds_relation_plans() {
1617        let mut ctx = UserContext::new()
1618            .with_metadata(
1619                InMemoryMetadataStore::new()
1620                    .with_entity(entity())
1621                    .with_entity(line_entity())
1622                    .with_entity(product_entity()),
1623            )
1624            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1625            .with_repository_behavior_registry(
1626                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
1627            );
1628        ctx.insert_resource(PostgresDialect);
1629        ctx.insert_resource(StubExecutor {
1630            affected: 1,
1631            rows: Vec::new(),
1632        });
1633
1634        let repo = ctx
1635            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1636            .unwrap();
1637        let plans = repo.relation_plans().unwrap();
1638
1639        assert_eq!(plans.len(), 1);
1640        assert_eq!(plans[0].relation_name, "lines");
1641        assert_eq!(plans[0].target_entity, "OrderLine");
1642        assert_eq!(plans[0].local_key, "id");
1643        assert_eq!(plans[0].foreign_key, "order_id");
1644        assert!(plans[0].many);
1645    }
1646
1647    #[test]
1648    fn resolved_repository_builds_relation_query_from_parent_rows() {
1649        let mut ctx = UserContext::new()
1650            .with_metadata(
1651                InMemoryMetadataStore::new()
1652                    .with_entity(entity())
1653                    .with_entity(line_entity())
1654                    .with_entity(product_entity()),
1655            )
1656            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1657            .with_repository_behavior_registry(
1658                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
1659            );
1660        ctx.insert_resource(PostgresDialect);
1661        ctx.insert_resource(StubExecutor {
1662            affected: 1,
1663            rows: Vec::new(),
1664        });
1665
1666        let repo = ctx
1667            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1668            .unwrap();
1669        let parent_rows = vec![
1670            Record::from([(String::from("id"), Value::U64(11))]),
1671            Record::from([(String::from("id"), Value::U64(12))]),
1672        ];
1673
1674        let query = repo.relation_query("lines", &parent_rows).unwrap();
1675        let compiled = repo.compile(&query).unwrap();
1676        assert!(compiled.sql.contains("FROM orderline"));
1677        assert!(compiled.sql.contains("order_id IN ($1, $2)"));
1678        assert_eq!(compiled.params, vec![Value::U64(11), Value::U64(12)]);
1679    }
1680
1681    #[test]
1682    fn resolved_repository_enhances_parent_rows_with_relations() {
1683        let mut ctx = UserContext::new()
1684            .with_metadata(
1685                InMemoryMetadataStore::new()
1686                    .with_entity(entity())
1687                    .with_entity(line_entity())
1688                    .with_entity(product_entity()),
1689            )
1690            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"))
1691            .with_repository_behavior_registry(
1692                InMemoryRepositoryBehaviorRegistry::new().with_behavior("Order", OrderBehavior),
1693            );
1694        ctx.insert_resource(PostgresDialect);
1695        ctx.insert_resource(StubExecutor {
1696            affected: 1,
1697            rows: vec![
1698                Record::from([
1699                    (String::from("id"), Value::U64(101)),
1700                    (String::from("order_id"), Value::U64(11)),
1701                    (String::from("name"), Value::Text(String::from("l1"))),
1702                ]),
1703                Record::from([
1704                    (String::from("id"), Value::U64(102)),
1705                    (String::from("order_id"), Value::U64(11)),
1706                    (String::from("name"), Value::Text(String::from("l2"))),
1707                ]),
1708                Record::from([
1709                    (String::from("id"), Value::U64(201)),
1710                    (String::from("order_id"), Value::U64(12)),
1711                    (String::from("name"), Value::Text(String::from("l3"))),
1712                ]),
1713            ],
1714        });
1715
1716        let repo = ctx
1717            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1718            .unwrap();
1719        let mut parents = vec![
1720            Record::from([(String::from("id"), Value::U64(11))]),
1721            Record::from([(String::from("id"), Value::U64(12))]),
1722        ];
1723
1724        repo.enhance_relations(&mut parents).unwrap();
1725
1726        match parents[0].get("lines") {
1727            Some(Value::List(lines)) => assert_eq!(lines.len(), 2),
1728            other => panic!("unexpected lines payload: {other:?}"),
1729        }
1730        match parents[1].get("lines") {
1731            Some(Value::List(lines)) => assert_eq!(lines.len(), 1),
1732            other => panic!("unexpected lines payload: {other:?}"),
1733        }
1734    }
1735
1736    #[test]
1737    fn resolved_repository_fetches_smart_list_of_entities() {
1738        let mut ctx = UserContext::new()
1739            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1740            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
1741        ctx.insert_resource(PostgresDialect);
1742        ctx.insert_resource(StubExecutor {
1743            affected: 1,
1744            rows: vec![Record::from([
1745                (String::from("id"), Value::U64(7)),
1746                (String::from("version"), Value::I64(2)),
1747                (String::from("name"), Value::Text(String::from("typed"))),
1748            ])],
1749        });
1750
1751        let repo = ctx
1752            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
1753            .unwrap();
1754        let rows = repo.fetch_entities::<OrderEntity>(&repo.select()).unwrap();
1755
1756        assert_eq!(rows.len(), 1);
1757        assert_eq!(
1758            rows.first(),
1759            Some(&OrderEntity {
1760                id: 7,
1761                version: 2,
1762                name: String::from("typed"),
1763            })
1764        );
1765    }
1766
1767    #[test]
1768    fn resolved_repository_fetches_smart_list_of_derived_entities() {
1769        let mut ctx = UserContext::new()
1770            .with_metadata(
1771                InMemoryMetadataStore::new().with_entity(CatalogProductRow::entity_descriptor()),
1772            )
1773            .with_repository_registry(
1774                InMemoryRepositoryRegistry::new().with_entity("CatalogProduct"),
1775            );
1776        ctx.insert_resource(PostgresDialect);
1777        ctx.insert_resource(StubExecutor {
1778            affected: 1,
1779            rows: vec![Record::from([
1780                (String::from("id"), Value::U64(9)),
1781                (String::from("name"), Value::Text(String::from("derived"))),
1782            ])],
1783        });
1784
1785        let repo = ctx
1786            .resolve_repository::<PostgresDialect, StubExecutor>("CatalogProduct")
1787            .unwrap();
1788        let rows = repo
1789            .fetch_entities::<CatalogProductRow>(&repo.select())
1790            .unwrap();
1791
1792        assert_eq!(rows.len(), 1);
1793        assert_eq!(
1794            rows.first(),
1795            Some(&CatalogProductRow {
1796                id: 9,
1797                name: String::from("derived"),
1798            })
1799        );
1800    }
1801
1802    #[test]
1803    fn resolved_repository_collects_dynamic_properties_for_aggregate_output() {
1804        let mut ctx = UserContext::new()
1805            .with_metadata(
1806                InMemoryMetadataStore::new()
1807                    .with_entity(OrderAggregateDynamic::entity_descriptor()),
1808            )
1809            .with_repository_registry(
1810                InMemoryRepositoryRegistry::new().with_entity("OrderAggregate"),
1811            );
1812        ctx.insert_resource(PostgresDialect);
1813        ctx.insert_resource(StubExecutor {
1814            affected: 1,
1815            rows: vec![Record::from([
1816                (String::from("id"), Value::U64(1)),
1817                (String::from("lineCount"), Value::I64(3)),
1818                (String::from("amount"), Value::F64(18.5)),
1819            ])],
1820        });
1821
1822        let repo = ctx
1823            .resolve_repository::<PostgresDialect, StubExecutor>("OrderAggregate")
1824            .unwrap();
1825        let rows = repo
1826            .fetch_entities::<OrderAggregateDynamic>(&repo.select())
1827            .unwrap();
1828
1829        assert_eq!(rows.len(), 1);
1830        assert_eq!(rows.data[0].id, 1);
1831        assert_eq!(rows.data[0].dynamic.get("lineCount"), Some(&Value::I64(3)));
1832        assert_eq!(rows.data[0].dynamic.get("amount"), Some(&Value::F64(18.5)));
1833        assert_eq!(
1834            rows.into_vec().into_iter().next().unwrap().into_json(),
1835            serde_json::json!({
1836                "id": 1,
1837                "lineCount": 3,
1838                "amount": 18.5
1839            })
1840        );
1841    }
1842
1843    #[test]
1844    fn resolved_repository_executes_relation_aggregates_into_dynamic_properties() {
1845        let executor = QueueExecutor {
1846            affected: 1,
1847            rows: Mutex::new(VecDeque::from([
1848                vec![
1849                    Record::from([
1850                        (String::from("id"), Value::U64(1)),
1851                        (String::from("version"), Value::I64(1)),
1852                        (String::from("name"), Value::Text(String::from("first"))),
1853                    ]),
1854                    Record::from([
1855                        (String::from("id"), Value::U64(2)),
1856                        (String::from("version"), Value::I64(1)),
1857                        (String::from("name"), Value::Text(String::from("second"))),
1858                    ]),
1859                ],
1860                vec![Record::from([
1861                    (String::from("order_id"), Value::U64(1)),
1862                    (String::from("lineCount"), Value::I64(3)),
1863                ])],
1864            ])),
1865            queries: Mutex::new(Vec::new()),
1866        };
1867        let mut ctx = UserContext::new()
1868            .with_metadata(
1869                InMemoryMetadataStore::new()
1870                    .with_entity(entity())
1871                    .with_entity(line_entity()),
1872            )
1873            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
1874        ctx.insert_resource(PostgresDialect);
1875        ctx.insert_resource(executor);
1876
1877        let repo = ctx
1878            .resolve_repository::<PostgresDialect, QueueExecutor>("Order")
1879            .unwrap();
1880        let rows = repo
1881            .fetch_all_with_relation_aggregates(
1882                &repo
1883                    .select()
1884                    .project("id")
1885                    .project("version")
1886                    .project("name"),
1887                &[RelationAggregate::new(
1888                    "lines",
1889                    "lineCount",
1890                    SelectQuery::new("OrderLine"),
1891                    true,
1892                )],
1893            )
1894            .unwrap();
1895
1896        assert_eq!(rows[0].get("lineCount"), Some(&Value::I64(3)));
1897        assert_eq!(rows[1].get("lineCount"), Some(&Value::U64(0)));
1898
1899        let executor = ctx.get_resource::<QueueExecutor>().unwrap();
1900        let queries = executor.queries.lock().unwrap();
1901        assert_eq!(queries.len(), 2);
1902        assert_eq!(
1903            queries[1],
1904            "SELECT order_id, COUNT(*) AS lineCount FROM orderline WHERE (order_id IN ($1, $2)) GROUP BY order_id"
1905        );
1906    }
1907
1908    #[test]
1909    fn resolved_repository_maps_relation_aggregate_storage_key_to_property_key() {
1910        let mut line = line_entity();
1911        line.properties
1912            .iter_mut()
1913            .find(|property| property.name == "order_id")
1914            .unwrap()
1915            .column_name = "order_ref".to_owned();
1916        let executor = QueueExecutor {
1917            affected: 1,
1918            rows: Mutex::new(VecDeque::from([
1919                vec![Record::from([
1920                    (String::from("id"), Value::U64(1)),
1921                    (String::from("version"), Value::I64(1)),
1922                    (String::from("name"), Value::Text(String::from("first"))),
1923                ])],
1924                vec![Record::from([
1925                    (String::from("order_ref"), Value::I64(1)),
1926                    (String::from("lineCount"), Value::I64(3)),
1927                ])],
1928            ])),
1929            queries: Mutex::new(Vec::new()),
1930        };
1931        let mut ctx = UserContext::new()
1932            .with_metadata(
1933                InMemoryMetadataStore::new()
1934                    .with_entity(entity())
1935                    .with_entity(line),
1936            )
1937            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
1938        ctx.insert_resource(PostgresDialect);
1939        ctx.insert_resource(executor);
1940
1941        let repo = ctx
1942            .resolve_repository::<PostgresDialect, QueueExecutor>("Order")
1943            .unwrap();
1944        let rows = repo
1945            .fetch_all_with_relation_aggregates(
1946                &repo
1947                    .select()
1948                    .project("id")
1949                    .project("version")
1950                    .project("name"),
1951                &[RelationAggregate::new(
1952                    "lines",
1953                    "lineCount",
1954                    SelectQuery::new("OrderLine"),
1955                    true,
1956                )],
1957            )
1958            .unwrap();
1959
1960        assert_eq!(rows[0].get("lineCount"), Some(&Value::I64(3)));
1961        let executor = ctx.get_resource::<QueueExecutor>().unwrap();
1962        assert_eq!(
1963            executor.queries.lock().unwrap()[1],
1964            "SELECT order_ref, COUNT(*) AS lineCount FROM orderline WHERE (order_ref IN ($1)) GROUP BY order_ref"
1965        );
1966    }
1967
1968    #[test]
1969    fn resolved_repository_uses_aggregation_cache_when_resource_is_registered() {
1970        let executor = QueueExecutor {
1971            affected: 1,
1972            rows: Mutex::new(VecDeque::from([vec![Record::from([(
1973                String::from("count"),
1974                Value::I64(2),
1975            )])]])),
1976            queries: Mutex::new(Vec::new()),
1977        };
1978        let mut ctx = UserContext::new()
1979            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
1980            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
1981        ctx.insert_resource(PostgresDialect);
1982        ctx.insert_resource(executor);
1983        ctx.insert_resource(InMemoryAggregationCache::default());
1984
1985        let repo = ctx
1986            .resolve_repository::<PostgresDialect, QueueExecutor>("Order")
1987            .unwrap();
1988        let query = repo
1989            .select()
1990            .count("count")
1991            .enable_aggregation_cache_for(60_000);
1992
1993        let first = repo.fetch_all(&query).unwrap();
1994        let second = repo.fetch_all(&query).unwrap();
1995
1996        assert_eq!(first, second);
1997        let executor = ctx.get_resource::<QueueExecutor>().unwrap();
1998        assert_eq!(executor.queries.lock().unwrap().len(), 1);
1999    }
2000
2001    #[test]
2002    fn aggregation_cache_is_namespaced_and_invalidated_after_write() {
2003        let executor = QueueExecutor {
2004            affected: 1,
2005            rows: Mutex::new(VecDeque::from([
2006                vec![Record::from([(String::from("count"), Value::I64(2))])],
2007                vec![Record::from([(String::from("count"), Value::I64(3))])],
2008            ])),
2009            queries: Mutex::new(Vec::new()),
2010        };
2011        let mut ctx = UserContext::new()
2012            .with_metadata(InMemoryMetadataStore::new().with_entity(entity()))
2013            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
2014        ctx.insert_resource(PostgresDialect);
2015        ctx.insert_resource(executor);
2016        ctx.insert_resource(
2017            Arc::new(InMemoryAggregationCache::with_namespace("tenant-a"))
2018                as Arc<dyn AggregationCacheBackend>,
2019        );
2020
2021        let repo = ctx
2022            .resolve_repository::<PostgresDialect, QueueExecutor>("Order")
2023            .unwrap();
2024        let query = repo
2025            .select()
2026            .count("count")
2027            .enable_aggregation_cache_for(60_000);
2028
2029        let first = repo.fetch_all(&query).unwrap();
2030        let cached = repo.fetch_all(&query).unwrap();
2031        repo.insert(
2032            &InsertCommand::new("Order")
2033                .value("id", 9_u64)
2034                .value("version", 1_i64)
2035                .value("name", "new"),
2036        )
2037        .unwrap();
2038        let refreshed = repo.fetch_all(&query).unwrap();
2039
2040        assert_eq!(first, cached);
2041        assert_ne!(cached, refreshed);
2042        let executor = ctx.get_resource::<QueueExecutor>().unwrap();
2043        assert_eq!(executor.queries.lock().unwrap().len(), 2);
2044    }
2045
2046    #[test]
2047    fn aggregation_cache_propagates_to_relation_aggregates() {
2048        let parent_rows = vec![
2049            Record::from([
2050                (String::from("id"), Value::U64(1)),
2051                (String::from("version"), Value::I64(1)),
2052                (String::from("name"), Value::Text(String::from("first"))),
2053            ]),
2054            Record::from([
2055                (String::from("id"), Value::U64(2)),
2056                (String::from("version"), Value::I64(1)),
2057                (String::from("name"), Value::Text(String::from("second"))),
2058            ]),
2059        ];
2060        let aggregate_rows = vec![Record::from([
2061            (String::from("order_id"), Value::U64(1)),
2062            (String::from("lineCount"), Value::I64(3)),
2063        ])];
2064        let executor = QueueExecutor {
2065            affected: 1,
2066            rows: Mutex::new(VecDeque::from([parent_rows, aggregate_rows])),
2067            queries: Mutex::new(Vec::new()),
2068        };
2069        let mut ctx = UserContext::new()
2070            .with_metadata(
2071                InMemoryMetadataStore::new()
2072                    .with_entity(entity())
2073                    .with_entity(line_entity()),
2074            )
2075            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
2076        ctx.insert_resource(PostgresDialect);
2077        ctx.insert_resource(executor);
2078        ctx.insert_resource(InMemoryAggregationCache::default());
2079
2080        let repo = ctx
2081            .resolve_repository::<PostgresDialect, QueueExecutor>("Order")
2082            .unwrap();
2083        let query = repo
2084            .select()
2085            .project("id")
2086            .project("version")
2087            .project("name")
2088            .enable_aggregation_cache_for(60_000)
2089            .propagate_aggregation_cache(60_000);
2090        let aggregate =
2091            RelationAggregate::new("lines", "lineCount", SelectQuery::new("OrderLine"), true);
2092
2093        let first = repo
2094            .fetch_all_with_relation_aggregates(&query, &[aggregate.clone()])
2095            .unwrap();
2096        let second = repo
2097            .fetch_all_with_relation_aggregates(&query, &[aggregate])
2098            .unwrap();
2099
2100        assert_eq!(first, second);
2101        let executor = ctx.get_resource::<QueueExecutor>().unwrap();
2102        assert_eq!(executor.queries.lock().unwrap().len(), 2);
2103    }
2104
2105    #[test]
2106    fn memory_repository_fetches_smart_list_entities_with_query_features() {
2107        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2108        let repository = MemoryRepository::new(metadata).with_rows(
2109            "Order",
2110            vec![
2111                Record::from([
2112                    (String::from("id"), Value::U64(1)),
2113                    (String::from("version"), Value::I64(1)),
2114                    (String::from("name"), Value::Text(String::from("alpha"))),
2115                ]),
2116                Record::from([
2117                    (String::from("id"), Value::U64(2)),
2118                    (String::from("version"), Value::I64(1)),
2119                    (String::from("name"), Value::Text(String::from("beta"))),
2120                ]),
2121                Record::from([
2122                    (String::from("id"), Value::U64(3)),
2123                    (String::from("version"), Value::I64(1)),
2124                    (String::from("name"), Value::Text(String::from("gamma"))),
2125                ]),
2126            ],
2127        );
2128
2129        let query = teaql_core::SelectQuery::new("Order")
2130            .filter(Expr::Binary {
2131                left: Box::new(Expr::column("id")),
2132                op: teaql_core::BinaryOp::Gte,
2133                right: Box::new(Expr::value(2_u64)),
2134            })
2135            .order_by(OrderBy::desc("id"))
2136            .limit(1);
2137
2138        let orders = repository.fetch_entities::<Order>(&query).unwrap();
2139
2140        assert_eq!(orders.ids(), vec![Value::U64(3)]);
2141        assert_eq!(orders.versions(), vec![1]);
2142        assert_eq!(orders.first().unwrap().name, "gamma");
2143    }
2144
2145    #[test]
2146    fn memory_repository_runs_relation_aggregates() {
2147        let metadata = InMemoryMetadataStore::new()
2148            .with_entity(entity())
2149            .with_entity(line_entity());
2150
2151        let repository = MemoryRepository::new(metadata)
2152            .with_rows(
2153                "Order",
2154                vec![
2155                    Record::from([
2156                        (String::from("id"), Value::U64(1)),
2157                        (String::from("version"), Value::I64(1)),
2158                        (String::from("name"), Value::Text(String::from("first"))),
2159                    ]),
2160                    Record::from([
2161                        (String::from("id"), Value::U64(2)),
2162                        (String::from("version"), Value::I64(1)),
2163                        (String::from("name"), Value::Text(String::from("second"))),
2164                    ]),
2165                ],
2166            )
2167            .with_rows(
2168                "OrderLine",
2169                vec![
2170                    Record::from([
2171                        (String::from("id"), Value::U64(10)),
2172                        (String::from("version"), Value::I64(1)),
2173                        (String::from("order_id"), Value::U64(1)),
2174                        (String::from("name"), Value::Text(String::from("line1"))),
2175                    ]),
2176                    Record::from([
2177                        (String::from("id"), Value::U64(11)),
2178                        (String::from("version"), Value::I64(1)),
2179                        (String::from("order_id"), Value::U64(1)),
2180                        (String::from("name"), Value::Text(String::from("line2"))),
2181                    ]),
2182                    Record::from([
2183                        (String::from("id"), Value::U64(12)),
2184                        (String::from("version"), Value::I64(1)),
2185                        (String::from("order_id"), Value::U64(2)),
2186                        (String::from("name"), Value::Text(String::from("line3"))),
2187                    ]),
2188                ],
2189            );
2190
2191        let query = SelectQuery::new("Order").project("id").project("name");
2192        let aggregate = RelationAggregate::new("lines", "lineCount", SelectQuery::new("OrderLine"), true);
2193
2194        let rows = repository
2195            .fetch_all_with_relation_aggregates(&query, &[aggregate])
2196            .unwrap();
2197
2198        assert_eq!(rows.len(), 2);
2199
2200        let first_order = rows.iter().find(|r| r.get("id") == Some(&Value::U64(1))).unwrap();
2201        assert_eq!(first_order.get("lineCount"), Some(&Value::U64(2)));
2202
2203        let second_order = rows.iter().find(|r| r.get("id") == Some(&Value::U64(2))).unwrap();
2204        assert_eq!(second_order.get("lineCount"), Some(&Value::U64(1)));
2205    }
2206
2207    #[test]
2208    fn memory_repository_runs_aggregates() {
2209        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2210        let repository = MemoryRepository::new(metadata).with_rows(
2211            "Order",
2212            vec![
2213                Record::from([
2214                    (String::from("id"), Value::U64(1)),
2215                    (String::from("version"), Value::I64(1)),
2216                    (String::from("name"), Value::Text(String::from("alpha"))),
2217                ]),
2218                Record::from([
2219                    (String::from("id"), Value::U64(2)),
2220                    (String::from("version"), Value::I64(2)),
2221                    (String::from("name"), Value::Text(String::from("beta"))),
2222                ]),
2223            ],
2224        );
2225
2226        let query = teaql_core::SelectQuery {
2227            entity: String::from("Order"),
2228            projection: Vec::new(),
2229            expr_projection: Vec::new(),
2230            filter: None,
2231            having: None,
2232            order_by: Vec::new(),
2233            slice: None,
2234            aggregates: vec![
2235                Aggregate {
2236                    function: AggregateFunction::Count,
2237                    field: String::from("id"),
2238                    alias: String::from("count"),
2239                },
2240                Aggregate {
2241                    function: AggregateFunction::Sum,
2242                    field: String::from("version"),
2243                    alias: String::from("versionSum"),
2244                },
2245            ],
2246            group_by: Vec::new(),
2247            relations: Vec::new(),
2248            aggregation_cache: None,
2249            comment: None,
2250            raw_sql: None,
2251            raw_sql_search_criteria: Vec::new(),
2252            dynamic_properties: Vec::new(),
2253            raw_projections: Vec::new(),
2254            object_group_bys: Vec::new(),
2255            child_enhancements: Vec::new(),
2256        };
2257
2258        let rows = repository.fetch_all(&query).unwrap();
2259
2260        assert_eq!(rows.len(), 1);
2261        assert_eq!(rows[0].get("count"), Some(&Value::U64(2)));
2262        assert_eq!(rows[0].get("versionSum"), Some(&Value::U64(3)));
2263    }
2264
2265    #[test]
2266    fn memory_repository_runs_grouped_aggregates_and_extended_filters() {
2267        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2268        let repository = MemoryRepository::new(metadata).with_rows(
2269            "Order",
2270            vec![
2271                Record::from([
2272                    (String::from("id"), Value::U64(1)),
2273                    (String::from("version"), Value::I64(1)),
2274                    (String::from("name"), Value::Text(String::from("alpha"))),
2275                ]),
2276                Record::from([
2277                    (String::from("id"), Value::U64(2)),
2278                    (String::from("version"), Value::I64(2)),
2279                    (String::from("name"), Value::Text(String::from("alpha"))),
2280                ]),
2281                Record::from([
2282                    (String::from("id"), Value::U64(3)),
2283                    (String::from("version"), Value::I64(3)),
2284                    (String::from("name"), Value::Text(String::from("tmp-beta"))),
2285                ]),
2286            ],
2287        );
2288
2289        let rows = repository
2290            .fetch_all(
2291                &teaql_core::SelectQuery::new("Order")
2292                    .filter(
2293                        Expr::between("version", 1_i64, 3_i64)
2294                            .and_expr(Expr::not_like("name", "tmp%"))
2295                            .and_expr(Expr::not_in_list("name", vec![Value::from("deleted")])),
2296                    )
2297                    .group_by("name")
2298                    .count("total")
2299                    .sum("version", "versionSum"),
2300            )
2301            .unwrap();
2302
2303        assert_eq!(rows.len(), 1);
2304        assert_eq!(
2305            rows[0].get("name"),
2306            Some(&Value::Text(String::from("alpha")))
2307        );
2308        assert_eq!(rows[0].get("total"), Some(&Value::U64(2)));
2309        assert_eq!(rows[0].get("versionSum"), Some(&Value::U64(3)));
2310    }
2311
2312    #[test]
2313    fn memory_repository_runs_extended_aggregates_and_having() {
2314        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2315        let repository = MemoryRepository::new(metadata).with_rows(
2316            "Order",
2317            vec![
2318                Record::from([
2319                    (String::from("id"), Value::U64(1)),
2320                    (String::from("version"), Value::I64(1)),
2321                    (String::from("name"), Value::Text(String::from("alpha"))),
2322                ]),
2323                Record::from([
2324                    (String::from("id"), Value::U64(2)),
2325                    (String::from("version"), Value::I64(3)),
2326                    (String::from("name"), Value::Text(String::from("alpha"))),
2327                ]),
2328                Record::from([
2329                    (String::from("id"), Value::U64(3)),
2330                    (String::from("version"), Value::I64(7)),
2331                    (String::from("name"), Value::Text(String::from("beta"))),
2332                ]),
2333            ],
2334        );
2335
2336        let rows = repository
2337            .fetch_all(
2338                &teaql_core::SelectQuery::new("Order")
2339                    .group_by("name")
2340                    .count("total")
2341                    .stddev("version", "stddevVersion")
2342                    .var_pop("version", "varPopVersion")
2343                    .bit_or("version", "bitOrVersion")
2344                    .having(Expr::gt("total", 1_i64)),
2345            )
2346            .unwrap();
2347
2348        assert_eq!(rows.len(), 1);
2349        assert_eq!(
2350            rows[0].get("name"),
2351            Some(&Value::Text(String::from("alpha")))
2352        );
2353        assert_eq!(rows[0].get("total"), Some(&Value::U64(2)));
2354        assert_eq!(
2355            rows[0].get("stddevVersion").map(Value::to_json_value),
2356            Some(serde_json::Value::String(
2357                "1.4142135623730951454746218583".to_owned()
2358            ))
2359        );
2360        assert_eq!(
2361            rows[0].get("varPopVersion"),
2362            Some(&Value::Decimal(Decimal::ONE))
2363        );
2364        assert_eq!(rows[0].get("bitOrVersion"), Some(&Value::I64(3)));
2365    }
2366
2367    #[test]
2368    fn memory_repository_runs_sound_like_filter() {
2369        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2370        let repository = MemoryRepository::new(metadata).with_rows(
2371            "Order",
2372            vec![
2373                Record::from([
2374                    (String::from("id"), Value::U64(1)),
2375                    (String::from("version"), Value::I64(1)),
2376                    (String::from("name"), Value::Text(String::from("Robert"))),
2377                ]),
2378                Record::from([
2379                    (String::from("id"), Value::U64(2)),
2380                    (String::from("version"), Value::I64(1)),
2381                    (String::from("name"), Value::Text(String::from("Rupert"))),
2382                ]),
2383                Record::from([
2384                    (String::from("id"), Value::U64(3)),
2385                    (String::from("version"), Value::I64(1)),
2386                    (String::from("name"), Value::Text(String::from("Ashcraft"))),
2387                ]),
2388            ],
2389        );
2390
2391        let rows = repository
2392            .fetch_all(
2393                &teaql_core::SelectQuery::new("Order")
2394                    .filter(Expr::sound_like("name", "Robert"))
2395                    .order_asc("id"),
2396            )
2397            .unwrap();
2398
2399        assert_eq!(rows.len(), 2);
2400        assert_eq!(rows[0].get("name"), Some(&Value::Text("Robert".to_owned())));
2401        assert_eq!(rows[1].get("name"), Some(&Value::Text("Rupert".to_owned())));
2402    }
2403
2404    #[test]
2405    fn memory_repository_runs_java_style_string_match_filters() {
2406        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2407        let repository = MemoryRepository::new(metadata).with_rows(
2408            "Order",
2409            vec![
2410                Record::from([
2411                    (String::from("id"), Value::U64(1)),
2412                    (String::from("version"), Value::I64(1)),
2413                    (String::from("name"), Value::Text(String::from("tea-order"))),
2414                ]),
2415                Record::from([
2416                    (String::from("id"), Value::U64(2)),
2417                    (String::from("version"), Value::I64(1)),
2418                    (
2419                        String::from("name"),
2420                        Value::Text(String::from("coffee-order")),
2421                    ),
2422                ]),
2423                Record::from([
2424                    (String::from("id"), Value::U64(3)),
2425                    (String::from("version"), Value::I64(1)),
2426                    (
2427                        String::from("name"),
2428                        Value::Text(String::from("tea-archived")),
2429                    ),
2430                ]),
2431            ],
2432        );
2433
2434        let rows = repository
2435            .fetch_all(
2436                &teaql_core::SelectQuery::new("Order")
2437                    .filter(
2438                        Expr::contain("name", "tea")
2439                            .and_expr(Expr::begin_with("name", "tea"))
2440                            .and_expr(Expr::end_with("name", "order"))
2441                            .and_expr(Expr::not_contain("name", "coffee"))
2442                            .and_expr(Expr::not_begin_with("name", "archived"))
2443                            .and_expr(Expr::not_end_with("name", "draft")),
2444                    )
2445                    .order_asc("id"),
2446            )
2447            .unwrap();
2448
2449        assert_eq!(rows.len(), 1);
2450        assert_eq!(
2451            rows[0].get("name"),
2452            Some(&Value::Text("tea-order".to_owned()))
2453        );
2454    }
2455
2456    #[test]
2457    fn memory_repository_runs_property_to_property_filters() {
2458        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2459        let repository = MemoryRepository::new(metadata).with_rows(
2460            "Order",
2461            vec![
2462                Record::from([
2463                    (String::from("id"), Value::U64(1)),
2464                    (String::from("version"), Value::I64(2)),
2465                    (String::from("name"), Value::Text(String::from("keep"))),
2466                ]),
2467                Record::from([
2468                    (String::from("id"), Value::U64(2)),
2469                    (String::from("version"), Value::I64(1)),
2470                    (String::from("name"), Value::Text(String::from("skip"))),
2471                ]),
2472            ],
2473        );
2474
2475        let rows = repository
2476            .fetch_all(
2477                &teaql_core::SelectQuery::new("Order")
2478                    .filter(Expr::compare_columns("version", BinaryOp::Gte, "id"))
2479                    .order_asc("id"),
2480            )
2481            .unwrap();
2482
2483        assert_eq!(rows.len(), 1);
2484        assert_eq!(rows[0].get("name"), Some(&Value::Text("keep".to_owned())));
2485    }
2486
2487    #[test]
2488    fn memory_repository_supports_mutations_and_optimistic_locking() {
2489        let metadata = InMemoryMetadataStore::new().with_entity(entity());
2490        let repository = MemoryRepository::new(metadata);
2491
2492        repository
2493            .insert(
2494                &InsertCommand::new("Order")
2495                    .value("id", 10_u64)
2496                    .value("version", 1_i64)
2497                    .value("name", "draft"),
2498            )
2499            .unwrap();
2500        repository
2501            .update(
2502                &UpdateCommand::new("Order", 10_u64)
2503                    .expected_version(1)
2504                    .value("name", "submitted"),
2505            )
2506            .unwrap();
2507
2508        let row = repository
2509            .fetch_all(&teaql_core::SelectQuery::new("Order").filter(Expr::eq("id", 10_u64)))
2510            .unwrap()
2511            .pop()
2512            .unwrap();
2513        assert_eq!(
2514            row.get("name"),
2515            Some(&Value::Text(String::from("submitted")))
2516        );
2517        assert_eq!(row.get("version"), Some(&Value::I64(2)));
2518
2519        let conflict = repository
2520            .update(
2521                &UpdateCommand::new("Order", 10_u64)
2522                    .expected_version(1)
2523                    .value("name", "stale"),
2524            )
2525            .unwrap_err();
2526        assert!(matches!(
2527            conflict,
2528            RepositoryError::Runtime(RuntimeError::OptimisticLockConflict { .. })
2529        ));
2530
2531        repository
2532            .delete(&DeleteCommand::new("Order", 10_u64).expected_version(2))
2533            .unwrap();
2534        let row = repository
2535            .fetch_all(&teaql_core::SelectQuery::new("Order").filter(Expr::eq("id", 10_u64)))
2536            .unwrap()
2537            .pop()
2538            .unwrap();
2539        assert_eq!(row.get("version"), Some(&Value::I64(-3)));
2540
2541        repository
2542            .recover(&RecoverCommand::new("Order", 10_u64, -3))
2543            .unwrap();
2544        let row = repository
2545            .fetch_all(&teaql_core::SelectQuery::new("Order").filter(Expr::eq("id", 10_u64)))
2546            .unwrap()
2547            .pop()
2548            .unwrap();
2549        assert_eq!(row.get("version"), Some(&Value::I64(4)));
2550    }
2551
2552    #[tokio::test]
2553    async fn user_context_reports_missing_schema_provider() {
2554        let err = UserContext::new().ensure_schema().await.unwrap_err();
2555        assert!(
2556            matches!(err, RuntimeError::Schema(message) if message == "missing schema provider")
2557        );
2558    }
2559
2560    #[test]
2561    fn user_context_stores_and_exposes_user_identifier() {
2562        let mut ctx = UserContext::new();
2563        let pid = std::process::id();
2564        let thread_id_str = format!("{:?}", std::thread::current().id());
2565        let numeric_thread_id = thread_id_str
2566            .strip_prefix("ThreadId(")
2567            .and_then(|s| s.strip_suffix(")"))
2568            .unwrap_or(&thread_id_str);
2569        let os_user = std::env::var("USER")
2570            .or_else(|_| std::env::var("USERNAME"))
2571            .unwrap_or_else(|_| "main".to_owned());
2572        let expected_default = format!("{os_user}@pid-{pid}.tid-{numeric_thread_id}");
2573        assert_eq!(ctx.user_identifier(), Some(expected_default.as_str()));
2574
2575        ctx.set_user_identifier("user-123");
2576        assert_eq!(ctx.user_identifier(), Some("user-123"));
2577
2578        let ctx2 = UserContext::new().with_user_identifier("user-456");
2579        assert_eq!(ctx2.user_identifier(), Some("user-456"));
2580
2581        let mut ctx3 = UserContext::new();
2582        ctx3.set_user_identifier_option(Some("user-789".to_owned()));
2583        assert_eq!(ctx3.user_identifier(), Some("user-789"));
2584        ctx3.set_user_identifier_option(None);
2585        assert_eq!(ctx3.user_identifier(), None);
2586
2587        let ctx4 = UserContext::new().with_user_identifier_option(Some("user-abc".to_owned()));
2588        assert_eq!(ctx4.user_identifier(), Some("user-abc"));
2589
2590        // Verify user_identifier is captured in SQL logs and written to app.log
2591        let _ = std::fs::remove_file("app.log");
2592        let ctx5 = UserContext::new().with_user_identifier("user-999");
2593        let query = CompiledQuery {
2594            sql: "SELECT 1".to_owned(),
2595            params: vec![],
2596            comment: None,
2597        };
2598        ctx5.record_sql_log(
2599            SqlLogOperation::Select,
2600            &query,
2601            DatabaseKind::Sqlite,
2602            std::time::SystemTime::now(),
2603            std::time::SystemTime::now(),
2604            std::time::Duration::from_millis(10),
2605            Some(1),
2606            None,
2607            None,
2608            Some("test-comment".to_owned()),
2609        );
2610        let logs = ctx5.sql_logs();
2611        assert_eq!(logs.len(), 1);
2612        assert_eq!(logs[0].user_identifier.as_deref(), Some("user-999"));
2613        assert_eq!(logs[0].comment.as_deref(), Some("test-comment"));
2614
2615        // Verify app.log content
2616        assert!(std::path::Path::new("app.log").exists());
2617        let log_content = std::fs::read_to_string("app.log").unwrap();
2618        assert!(log_content.contains("SELECT 1"));
2619        assert!(log_content.contains("user-999"));
2620        assert!(log_content.contains("test-comment"));
2621        assert!(log_content.contains("DEBUG - SqlLogEntry"));
2622        let _ = std::fs::remove_file("app.log"); // Cleanup
2623
2624        // Test nested comment stack propagation
2625        let ctx6 = UserContext::new();
2626        {
2627            let _guard1 = crate::context::QueryCommentGuard::new(&ctx6, Some("outer-task".to_owned()));
2628            ctx6.record_sql_log(
2629                SqlLogOperation::Select,
2630                &query,
2631                DatabaseKind::Sqlite,
2632                std::time::SystemTime::now(),
2633                std::time::SystemTime::now(),
2634                std::time::Duration::from_millis(10),
2635                Some(1),
2636                None,
2637                None,
2638                None,
2639            );
2640            assert_eq!(ctx6.sql_logs()[0].comment.as_deref(), Some("outer-task"));
2641
2642            {
2643                let _guard2 = crate::context::QueryCommentGuard::new(&ctx6, Some("inner-task".to_owned()));
2644                ctx6.record_sql_log(
2645                    SqlLogOperation::Select,
2646                    &query,
2647                    DatabaseKind::Sqlite,
2648                    std::time::SystemTime::now(),
2649                    std::time::SystemTime::now(),
2650                    std::time::Duration::from_millis(10),
2651                    Some(1),
2652                    None,
2653                    None,
2654                    None,
2655                );
2656                assert_eq!(ctx6.sql_logs()[1].comment.as_deref(), Some("outer-task->inner-task"));
2657
2658                {
2659                    // Non-comment nested query does not alter stack
2660                    let _guard3 = crate::context::QueryCommentGuard::new(&ctx6, None);
2661                    ctx6.record_sql_log(
2662                        SqlLogOperation::Select,
2663                        &query,
2664                        DatabaseKind::Sqlite,
2665                        std::time::SystemTime::now(),
2666                        std::time::SystemTime::now(),
2667                        std::time::Duration::from_millis(10),
2668                        Some(1),
2669                        None,
2670                        None,
2671                        None,
2672                    );
2673                    assert_eq!(ctx6.sql_logs()[2].comment.as_deref(), Some("outer-task->inner-task"));
2674                }
2675            }
2676        }
2677        let _ = std::fs::remove_file("app.log"); // Cleanup
2678    }
2679
2680    #[test]
2681    fn resolved_repository_propagates_comments_through_fetch_prepared_query() {
2682        let mut ctx = UserContext::new()
2683            .with_metadata(
2684                InMemoryMetadataStore::new()
2685                    .with_entity(entity()),
2686            )
2687            .with_repository_registry(InMemoryRepositoryRegistry::new().with_entity("Order"));
2688        ctx.insert_resource(PostgresDialect);
2689        ctx.insert_resource(StubExecutor {
2690            affected: 1,
2691            rows: Vec::new(),
2692        });
2693
2694        let repo = ctx
2695            .resolve_repository::<PostgresDialect, StubExecutor>("Order")
2696            .unwrap();
2697
2698        // Simulate outer facet query comment propagation
2699        {
2700            let _guard1 = crate::context::QueryCommentGuard::new(&ctx, Some("outer-query-comment".to_owned()));
2701            let _guard2 = crate::context::QueryCommentGuard::new(&ctx, Some("facet-name-comment".to_owned()));
2702
2703            let query = repo.select().comment("inner-query-comment");
2704            let _ = repo.fetch_all(&query).unwrap();
2705        }
2706
2707        let logs = ctx.sql_logs();
2708        assert_eq!(logs.len(), 1);
2709        assert_eq!(
2710            logs[0].comment.as_deref(),
2711            Some("outer-query-comment->facet-name-comment->inner-query-comment")
2712        );
2713    }
2714}
2715
2716pub use checker::{
2717    CHECK_OBJECT_STATUS_FIELD, CheckObjectStatus, CheckResult, CheckResults, CheckRule, Checker,
2718    CheckerRegistry, InMemoryCheckerRegistry, LocationSegment, ObjectLocation, TypedChecker,
2719    TypedEntityChecker, clear_record_status, mark_record_status,
2720};