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