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};