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