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