Skip to main content

teaql_runtime/
lib.rs

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