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