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