Skip to main content

teaql_runtime/
lib.rs

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