1extern crate self as teaql_core;
2
3pub mod business_id;
4pub mod dynamic_fields;
5pub mod dynamic_properties;
6pub mod dynamic_search;
7mod entity;
8mod entity_graph;
9mod expr;
10mod list;
11mod load_state;
12mod meta;
13mod mutation;
14mod naming;
15mod query;
16pub mod request;
17mod request_intent;
18mod request_tree;
19mod safe_expression;
20pub mod serde_utils;
21pub mod time;
22mod trace;
23mod value;
24mod web;
25mod xls;
26
27pub use entity::{
28 Audited, BaseEntity, BaseEntityData, Entity, EntityDescriptorStore, EntityError,
29 EntityJsonTraversal, IdentifiableEntity, TeaqlBoxedRelations, TeaqlEntity, VersionedEntity,
30};
31pub use entity_graph::{EntityGraph, EntityGraphBuilder, EntityGraphNode, EntityGraphOperation};
32pub use expr::{BinaryOp, Expr, ExprFunction};
33pub use list::SmartList;
34pub use load_state::{FieldLayout, LoadedSnapshot};
35pub use meta::{EntityDescriptor, PropertyDescriptor, RelationDescriptor};
36pub use mutation::{
37 BatchInsertCommand, BatchUpdateCommand, DeleteCommand, EntitySnapshot, GeneratedValues,
38 InsertCommand, MutationKind, MutationValues, RecoverCommand, UpdateCommand,
39};
40pub use naming::default_table_name;
41#[doc(hidden)]
42pub use query::RelationShapeCache;
43pub use query::{
44 Aggregate, AggregateFunction, AggregationCacheOptions, CompactRow, CompactRowLayout,
45 ContinuousPageFetchOptions, IdSetPaginationOptions, NamedExpr, ObjectGroupBy, OrderBy,
46 PARTITION_RANK_PROPERTY, RawSqlProjection, Record, RelationAggregate, RelationLoad,
47 SelectQuery, Slice, SortDirection, StreamConfig, compact_row_to_json_value,
48 record_to_json_value,
49};
50pub use request_intent::{MutationIntent, QueryIntent, RequestIntentError, RequestKind};
51pub use request_tree::{QueryOperation, QueryOperationKind, QueryRequestScope};
52pub use safe_expression::{SafeExpression, TeaqlEmpty};
53#[doc(hidden)]
54pub use serde_json;
55pub use trace::{TraceKind, TraceNode};
56pub use value::{DataType, Decimal, Value};
57pub use web::{ACTION_LIST_KEY, STYLE_KEY, WEB_RESPONSE_VERSION, WebAction, WebResponse, WebStyle};
58pub use xls::{XlsBlock, XlsBlockBuildContext, XlsPage, XlsWorkbook};
59
60#[cfg(test)]
61mod tests {
62 use std::collections::BTreeMap;
63
64 use super::*;
65 use chrono::{NaiveDate, TimeZone, Utc};
66 use teaql_macros::TeaqlEntity;
67
68 #[derive(Default)]
69 struct TestStore {
70 descriptors: Vec<EntityDescriptor>,
71 }
72
73 impl EntityDescriptorStore for TestStore {
74 fn register_descriptor(&mut self, descriptor: EntityDescriptor) {
75 self.descriptors.push(descriptor);
76 }
77 }
78
79 #[allow(dead_code)]
80 #[derive(Clone, TeaqlEntity)]
81 #[teaql(entity = "Order", table = "orders")]
82 struct OrderRow {
83 #[teaql(id)]
84 id: u64,
85 #[teaql(version)]
86 version: i64,
87 #[teaql(column = "display_name", max_length = 100)]
88 name: String,
89 }
90
91 #[allow(dead_code)]
92 #[derive(Debug, TeaqlEntity)]
93 #[teaql(entity = "TypedNumber", table = "typed_number")]
94 struct TypedNumberRow {
95 #[teaql(id)]
96 id: u64,
97 signed: i32,
98 unsigned: u32,
99 #[teaql(numeric_precision = 19, numeric_scale = 7)]
100 amount: Decimal,
101 }
102
103 #[test]
104 fn derive_entity_descriptor() {
105 let descriptor = OrderRow::entity_descriptor();
106 assert_eq!(descriptor.name, "Order");
107 assert_eq!(descriptor.table_name, "orders");
108 assert_eq!(
109 descriptor.id_property().map(|p| p.name.as_str()),
110 Some("id")
111 );
112 assert_eq!(
113 descriptor
114 .property_by_name("name")
115 .map(|p| p.column_name.as_str()),
116 Some("display_name")
117 );
118 assert_eq!(
119 descriptor
120 .property_by_name("name")
121 .and_then(|p| p.max_length),
122 Some(100)
123 );
124 }
125
126 #[test]
127 fn derive_maps_checked_integer_and_decimal_fields() {
128 let descriptor = TypedNumberRow::entity_descriptor();
129 assert_eq!(
130 descriptor.property_by_name("amount").map(|p| p.data_type),
131 Some(DataType::Decimal)
132 );
133 assert_eq!(
134 descriptor
135 .property_by_name("amount")
136 .and_then(|p| p.numeric_precision),
137 Some(19)
138 );
139 assert_eq!(
140 descriptor
141 .property_by_name("amount")
142 .and_then(|p| p.numeric_scale),
143 Some(7)
144 );
145
146 let row = TypedNumberRow::from_compact_row(CompactRow::from_map(Record::from([
147 ("id".to_owned(), Value::I64(7)),
148 ("signed".to_owned(), Value::I64(2_147_483_647)),
149 ("unsigned".to_owned(), Value::U64(4_294_967_295)),
150 ("amount".to_owned(), Value::Decimal(Decimal::new(12345, 2))),
151 ])))
152 .unwrap();
153 assert_eq!(row.id, 7);
154 assert_eq!(row.signed, i32::MAX);
155 assert_eq!(row.unsigned, u32::MAX);
156 assert_eq!(row.amount, Decimal::new(12345, 2));
157
158 let signed_overflow =
159 TypedNumberRow::from_compact_row(CompactRow::from_map(Record::from([
160 ("id".to_owned(), Value::U64(1)),
161 ("signed".to_owned(), Value::I64(i64::from(i32::MAX) + 1)),
162 ("unsigned".to_owned(), Value::U64(1)),
163 ("amount".to_owned(), Value::Decimal(Decimal::ONE)),
164 ])));
165 assert!(
166 signed_overflow
167 .unwrap_err()
168 .message
169 .contains("out of i32 range")
170 );
171
172 let unsigned_negative =
173 TypedNumberRow::from_compact_row(CompactRow::from_map(Record::from([
174 ("id".to_owned(), Value::U64(1)),
175 ("signed".to_owned(), Value::I64(1)),
176 ("unsigned".to_owned(), Value::I64(-1)),
177 ("amount".to_owned(), Value::Decimal(Decimal::ONE)),
178 ])));
179 assert!(
180 unsigned_negative
181 .unwrap_err()
182 .message
183 .contains("out of u32 range")
184 );
185 }
186
187 #[test]
188 fn derive_allows_partial_projected_records() {
189 let row = OrderRow::from_compact_row(CompactRow::from_map(Record::from([(
190 "name".to_owned(),
191 Value::Text("projected".to_owned()),
192 )])))
193 .unwrap();
194 assert_eq!(row.id, 0);
195 assert_eq!(row.version, 0);
196 assert_eq!(row.name, "projected");
197
198 let nulls = OrderRow::from_compact_row(CompactRow::from_map(Record::from([
199 ("id".to_owned(), Value::Null),
200 ("version".to_owned(), Value::Null),
201 ("name".to_owned(), Value::Null),
202 ])))
203 .unwrap();
204 assert_eq!(nulls.id, 0);
205 assert_eq!(nulls.version, 0);
206 assert_eq!(nulls.name, "");
207
208 match OrderRow::from_compact_row(CompactRow::from_map(Record::from([(
209 "name".to_owned(),
210 Value::U64(1),
211 )]))) {
212 Ok(_) => panic!("wrong field type should fail"),
213 Err(err) => assert!(err.message.contains("invalid field name")),
214 }
215 }
216
217 #[allow(dead_code)]
218 #[derive(TeaqlEntity)]
219 #[teaql(entity = "Product", table = "product")]
220 struct ProductRow {
221 #[teaql(id)]
222 id: u64,
223 name: String,
224 }
225
226 #[allow(dead_code)]
227 #[derive(TeaqlEntity)]
228 #[teaql(entity = "OrderLine", table = "orderline")]
229 struct OrderLineRow {
230 #[teaql(id)]
231 id: u64,
232 #[teaql(column = "order_id")]
233 order_id: u64,
234 #[teaql(relation(
235 target = "Product",
236 local_key = "product_id",
237 foreign_key = "id",
238 attach = false,
239 delete_missing = false
240 ))]
241 product: Option<ProductRow>,
242 }
243
244 #[allow(dead_code)]
245 #[derive(TeaqlEntity)]
246 #[teaql(entity = "BoxedOrderLine", table = "boxed_orderline")]
247 struct BoxedOrderLineRow {
248 #[teaql(id)]
249 id: u64,
250 #[teaql(relation(target = "Product", local_key = "product_id", foreign_key = "id"))]
251 product: Option<Box<ProductRow>>,
252 }
253
254 #[test]
255 fn derive_relation_descriptor_and_register() {
256 let descriptor = OrderLineRow::entity_descriptor();
257 let relation = descriptor.relation_by_name("product").unwrap();
258 assert_eq!(relation.target_entity, "Product");
259 assert_eq!(relation.local_key, "product_id");
260 assert_eq!(relation.foreign_key, "id");
261 assert!(!relation.attach);
262 assert!(!relation.delete_missing);
263
264 let mut store = TestStore::default();
265 OrderLineRow::register_into(&mut store);
266 assert_eq!(store.descriptors.len(), 1);
267 assert_eq!(store.descriptors[0].name, "OrderLine");
268 }
269
270 #[test]
271 fn derive_decodes_boxed_forward_relation_without_changing_relation_semantics() {
272 let product = Record::from([
273 ("id".to_owned(), Value::U64(7)),
274 ("name".to_owned(), Value::Text("boxed".to_owned())),
275 ]);
276 let row = BoxedOrderLineRow::from_compact_row(CompactRow::from_map(Record::from([
277 ("id".to_owned(), Value::U64(1)),
278 ("product".to_owned(), Value::object(product)),
279 ])))
280 .unwrap();
281 assert_eq!(row.product.as_deref().map(|product| product.id), Some(7));
282
283 let values = row.into_values();
284 let Value::Object(product) = values.get("product").unwrap() else {
285 panic!("boxed relation must retain object serialization");
286 };
287 assert_eq!(product.get("id").and_then(Value::try_u64), Some(7));
288 }
289
290 #[test]
291 fn register_entities_macro_registers_multiple_descriptors() {
292 let mut store = TestStore::default();
293 crate::register_entities!(&mut store, OrderRow, OrderLineRow);
294
295 assert_eq!(store.descriptors.len(), 2);
296 assert_eq!(store.descriptors[0].name, "Order");
297 assert_eq!(store.descriptors[1].name, "OrderLine");
298 }
299
300 #[allow(dead_code)]
301 #[derive(TeaqlEntity)]
302 struct DefaultTableNameRow {
303 #[teaql(id)]
304 id: u64,
305 }
306
307 #[allow(dead_code)]
308 #[derive(TeaqlEntity)]
309 struct TypedValueRow {
310 #[teaql(id)]
311 id: u64,
312 payload: serde_json::Value,
313 birthday: NaiveDate,
314 happened_at: crate::time::Timestamp,
315 }
316
317 #[allow(dead_code)]
318 #[derive(TeaqlEntity)]
319 #[teaql(entity = "OrderAggregate", table = "order_aggregate")]
320 struct OrderAggregateRow {
321 #[teaql(id)]
322 id: u64,
323 #[teaql(dynamic)]
324 dynamic: BTreeMap<String, Value>,
325 }
326
327 #[test]
328 fn default_table_name_matches_java_sql_repository_rule() {
329 assert_eq!(default_table_name("Order"), "order_data");
330 assert_eq!(default_table_name("OrderLine"), "order_line_data");
331 assert_eq!(EntityDescriptor::new("Order").table_name, "order_data");
332 assert_eq!(
333 EntityDescriptor::new("OrderLine").table_name,
334 "order_line_data"
335 );
336 assert_eq!(
337 DefaultTableNameRow::entity_descriptor().table_name,
338 "default_table_name_row_data"
339 );
340 }
341
342 #[test]
343 fn derive_maps_json_date_and_timestamp_types() {
344 let descriptor = TypedValueRow::entity_descriptor();
345 assert_eq!(
346 descriptor.property_by_name("payload").map(|p| p.data_type),
347 Some(DataType::Json)
348 );
349 assert_eq!(
350 descriptor.property_by_name("birthday").map(|p| p.data_type),
351 Some(DataType::Date)
352 );
353 assert_eq!(
354 descriptor
355 .property_by_name("happened_at")
356 .map(|p| p.data_type),
357 Some(DataType::Timestamp)
358 );
359
360 let birthday = NaiveDate::from_ymd_opt(2024, 2, 3).unwrap();
361 let happened_at = Utc.with_ymd_and_hms(2024, 2, 3, 4, 5, 6).unwrap();
362 assert_eq!(
363 Value::from(serde_json::json!({"a": 1})),
364 Value::Json(serde_json::json!({"a": 1}))
365 );
366 assert_eq!(Value::from(birthday), Value::Date(birthday));
367 assert_eq!(
368 Value::from(happened_at),
369 Value::Timestamp(crate::time::Timestamp(happened_at.timestamp_millis()))
370 );
371 }
372
373 #[test]
374 fn query_builders_cover_filters_sort_aggregates_and_relations() {
375 let query = SelectQuery::new("Order")
376 .projects(["id", "name"])
377 .filter(Expr::gte("version", 1_i64))
378 .and_filter(Expr::not_in_list(
379 "name",
380 vec![Value::from("archived"), Value::from("deleted")],
381 ))
382 .and_filter(Expr::in_large(
383 "id",
384 vec![Value::from(1_u64), Value::from(2_u64)],
385 ))
386 .and_filter(Expr::contain("name", "rob"))
387 .and_filter(Expr::sound_like("name", "Robert"))
388 .and_filter(Expr::compare_columns(
389 "updated_at",
390 BinaryOp::Gte,
391 "created_at",
392 ))
393 .or_filter(Expr::is_null("name"))
394 .project_expr("nameSound", Expr::soundex(Expr::column("name")))
395 .order_desc("id")
396 .order_gbk_asc("name")
397 .group_by("name")
398 .count("total")
399 .sum("version", "versionSum")
400 .stddev("version", "versionStddev")
401 .enable_aggregation_cache_for(1_000)
402 .propagate_aggregation_cache(2_000)
403 .having(Expr::gt("total", 1_i64))
404 .relation("lines")
405 .relation_query(
406 "customer",
407 SelectQuery::new("Customer")
408 .project("name")
409 .filter(Expr::eq("status", "active")),
410 )
411 .page(20, 10);
412
413 assert_eq!(query.projection, vec!["id", "name"]);
414 assert_eq!(
415 query.expr_projection,
416 vec![NamedExpr::new(
417 "nameSound",
418 Expr::soundex(Expr::column("name"))
419 )]
420 );
421 assert_eq!(
422 query.order_by,
423 vec![OrderBy::desc("id"), OrderBy::asc_gbk("name")]
424 );
425 assert_eq!(query.group_by, vec!["name"]);
426 assert_eq!(
427 query.aggregates,
428 vec![
429 Aggregate::count("total"),
430 Aggregate::sum("version", "versionSum"),
431 Aggregate::stddev("version", "versionStddev")
432 ]
433 );
434 assert_eq!(
435 query.aggregation_cache,
436 Some(AggregationCacheOptions {
437 enabled: true,
438 cache_expired_millis: 1_000,
439 propagate: true,
440 propagate_cache_expired_millis: 2_000,
441 })
442 );
443 assert_eq!(query.having, Some(Expr::gt("total", 1_i64)));
444 assert_eq!(
445 query.relations,
446 vec![
447 RelationLoad::new("lines"),
448 RelationLoad::with_query(
449 "customer",
450 SelectQuery::new("Customer")
451 .project("name")
452 .filter(Expr::eq("status", "active")),
453 )
454 ]
455 );
456 assert_eq!(
457 query.slice,
458 Some(Slice {
459 limit: Some(10),
460 offset: 20
461 })
462 );
463 assert!(matches!(query.filter, Some(Expr::Or(_))));
464 }
465
466 #[test]
467 fn compare_columns_builds_property_to_property_filter() {
468 assert_eq!(
469 Expr::compare_columns("updated_at", BinaryOp::Gte, "created_at"),
470 Expr::Binary {
471 left: Box::new(Expr::Column("updated_at".to_owned())),
472 op: BinaryOp::Gte,
473 right: Box::new(Expr::Column("created_at".to_owned())),
474 }
475 );
476 }
477
478 #[test]
479 fn sound_like_builds_soundex_equality() {
480 assert_eq!(
481 Expr::sound_like("name", "Robert"),
482 Expr::binary(
483 Expr::soundex(Expr::column("name")),
484 BinaryOp::Eq,
485 Expr::soundex(Expr::value("Robert"))
486 )
487 );
488 }
489
490 #[test]
491 fn java_style_string_match_builders_expand_like_patterns() {
492 for original in ["tea", "%tea_\\%"] {
493 for (expr, op, pattern) in [
494 (
495 Expr::contain("name", original),
496 BinaryOp::Like,
497 format!("%{original}%"),
498 ),
499 (
500 Expr::not_contain("name", original),
501 BinaryOp::NotLike,
502 format!("%{original}%"),
503 ),
504 (
505 Expr::begin_with("name", original),
506 BinaryOp::Like,
507 format!("{original}%"),
508 ),
509 (
510 Expr::not_begin_with("name", original),
511 BinaryOp::NotLike,
512 format!("{original}%"),
513 ),
514 (
515 Expr::end_with("name", original),
516 BinaryOp::Like,
517 format!("%{original}"),
518 ),
519 (
520 Expr::not_end_with("name", original),
521 BinaryOp::NotLike,
522 format!("%{original}"),
523 ),
524 ] {
525 assert_eq!(
526 expr,
527 Expr::binary(
528 Expr::column("name"),
529 op,
530 Expr::LikePattern {
531 pattern,
532 original: original.into()
533 }
534 )
535 );
536 }
537 }
538 let Expr::Binary { right, .. } = Expr::like("name", "%tea%") else {
539 panic!()
540 };
541 assert_eq!(*right, Expr::value("%tea%"));
542 }
543
544 #[test]
545 fn large_in_builders_use_large_binary_ops() {
546 assert_eq!(
547 Expr::in_large("id", vec![Value::from(1_u64)]),
548 Expr::binary(
549 Expr::column("id"),
550 BinaryOp::InLarge,
551 Expr::value(Value::List(vec![Value::from(1_u64)]))
552 )
553 );
554 assert_eq!(
555 Expr::not_in_large("id", vec![Value::from(1_u64)]),
556 Expr::binary(
557 Expr::column("id"),
558 BinaryOp::NotInLarge,
559 Expr::value(Value::List(vec![Value::from(1_u64)]))
560 )
561 );
562 }
563
564 #[test]
565 fn ordinary_in_builders_promote_more_than_twenty_values() {
566 let twenty = (1_u64..=20).map(Value::from).collect::<Vec<_>>();
567 let twenty_one = (1_u64..=21).map(Value::from).collect::<Vec<_>>();
568
569 let Expr::Binary { op, .. } = Expr::in_list("id", twenty.clone()) else {
570 panic!("expected IN expression");
571 };
572 assert_eq!(op, BinaryOp::In);
573
574 let Expr::Binary { op, .. } = Expr::in_list("id", twenty_one.clone()) else {
575 panic!("expected large IN expression");
576 };
577 assert_eq!(op, BinaryOp::InLarge);
578
579 let Expr::Binary { op, .. } = Expr::not_in_list("id", twenty) else {
580 panic!("expected NOT IN expression");
581 };
582 assert_eq!(op, BinaryOp::NotIn);
583
584 let Expr::Binary { op, .. } = Expr::not_in_list("id", twenty_one) else {
585 panic!("expected large NOT IN expression");
586 };
587 assert_eq!(op, BinaryOp::NotInLarge);
588 }
589
590 #[test]
591 fn subquery_builder_projects_requested_field() {
592 let query = SelectQuery::new("OrderLine").filter(Expr::eq("name", "line-1"));
593 let expr = Expr::in_subquery("id", OrderLineRow::entity_descriptor(), query, "order_id");
594
595 let Expr::SubQuery {
596 left,
597 op,
598 entity,
599 query,
600 } = expr
601 else {
602 panic!("expected subquery expression");
603 };
604 assert_eq!(*left, Expr::column("id"));
605 assert_eq!(op, BinaryOp::In);
606 assert_eq!(entity.name, "OrderLine");
607 assert_eq!(query.projection, vec!["order_id"]);
608 }
609
610 #[test]
611 fn smart_list_supports_entity_ids_versions_and_records() {
612 let rows = SmartList::from(vec![
613 OrderRow {
614 id: 1,
615 version: 2,
616 name: String::from("a"),
617 },
618 OrderRow {
619 id: 3,
620 version: 4,
621 name: String::from("b"),
622 },
623 ]);
624
625 assert_eq!(rows.ids(), vec![Value::U64(1), Value::U64(3)]);
626 assert_eq!(rows.versions(), vec![2, 4]);
627
628 let records = rows.into_values();
629 assert_eq!(records.len(), 2);
630 assert_eq!(records.data[0].get("id"), Some(&Value::U64(1)));
631 assert_eq!(records.data[1].get("version"), Some(&Value::I64(4)));
632 }
633
634 #[test]
635 fn smart_list_supports_java_style_collection_helpers() {
636 let mut rows = SmartList::empty()
637 .with_total_count(10)
638 .with_aggregation("count", 2_u64)
639 .with_summary("label", "orders");
640 rows.push(OrderRow {
641 id: 1,
642 version: 2,
643 name: String::from("a"),
644 });
645 rows.extend(vec![OrderRow {
646 id: 3,
647 version: 4,
648 name: String::from("b"),
649 }]);
650
651 assert_eq!(rows.total_count_or_len(), 10);
652 assert_eq!(rows.get(1).map(|row| row.name.as_str()), Some("b"));
653 assert_eq!(rows.last().map(|row| row.id), Some(3));
654 assert_eq!(rows.aggregation("count"), Some(&Value::U64(2)));
655 assert_eq!(
656 rows.summary("label"),
657 Some(&Value::Text(String::from("orders")))
658 );
659 assert_eq!(rows.aggregation_json(), serde_json::json!({"count": 2}));
660 assert_eq!(rows.summary_json(), serde_json::json!({"label": "orders"}));
661
662 let names = rows.to_list(|row| row.name.clone());
663 assert_eq!(names, vec![String::from("a"), String::from("b")]);
664 let ids = rows.to_set(|row| row.id);
665 assert_eq!(ids.into_iter().collect::<Vec<_>>(), vec![1, 3]);
666
667 let by_id = rows.map_by_id();
668 assert_eq!(by_id.get("u:1").map(|row| row.name.as_str()), Some("a"));
669 assert_eq!(by_id.get("u:3").map(|row| row.name.as_str()), Some("b"));
670
671 let identity = rows.identity_map(|row| row.name.clone());
672 assert_eq!(identity.get("a").map(|row| row.id), Some(1));
673 let grouped = rows.group_by(|row| row.version % 2);
674 assert_eq!(grouped.get(&0).map(Vec::len), Some(2));
675
676 rows.merge_by(
677 vec![
678 OrderRow {
679 id: 3,
680 version: 5,
681 name: String::from("b2"),
682 },
683 OrderRow {
684 id: 4,
685 version: 1,
686 name: String::from("c"),
687 },
688 ],
689 |row| row.id,
690 );
691 assert_eq!(rows.len(), 3);
692 assert_eq!(rows.map_by_id().get("u:3").map(|row| row.version), Some(5));
693
694 rows.retain(|row| row.id != 1);
695 assert_eq!(rows.ids(), vec![Value::U64(3), Value::U64(4)]);
696 assert_eq!((&rows).into_iter().count(), 2);
697 assert_eq!(rows[0].name, "b2");
698 }
699
700 #[derive(Clone)]
701 struct SafeExpressionEntity {
702 base: BaseEntityData,
703 name: String,
704 lines: SmartList<OrderRow>,
705 }
706
707 impl TeaqlEntity for SafeExpressionEntity {
708 const ENTITY_NAME: &'static str = "SafeExpressionEntity";
709
710 fn entity_descriptor() -> EntityDescriptor {
711 EntityDescriptor::new("SafeExpressionEntity")
712 }
713 }
714
715 impl Entity for SafeExpressionEntity {
716 fn from_compact_row(_row: CompactRow) -> Result<Self, EntityError> {
717 unimplemented!("test helper does not need record mapping")
718 }
719
720 fn into_values(self) -> MutationValues {
721 MutationValues::new()
722 }
723 }
724
725 impl BaseEntity for SafeExpressionEntity {
726 fn base(&self) -> &BaseEntityData {
727 &self.base
728 }
729
730 fn base_mut(&mut self) -> &mut BaseEntityData {
731 &mut self.base
732 }
733 }
734
735 #[test]
736 fn safe_expression_supports_null_safe_chaining_and_defaults() {
737 let entity = SafeExpressionEntity {
738 base: BaseEntityData::new().with_id(7).with_version(3),
739 name: "demo".to_owned(),
740 lines: SmartList::from(vec![OrderRow {
741 id: 11,
742 version: 1,
743 name: "line".to_owned(),
744 }]),
745 };
746
747 let expr = SafeExpression::value(entity);
748 assert_eq!(expr.clone().entity_id().eval(), Some(7));
749 assert_eq!(expr.clone().entity_version().eval(), Some(3));
750 assert_eq!(
751 expr.clone()
752 .apply(|entity| entity.name)
753 .or_else("x".to_owned()),
754 "demo"
755 );
756 assert_eq!(
757 expr.clone()
758 .apply(|entity| entity.lines)
759 .first()
760 .apply(|line| line.id)
761 .eval(),
762 Some(11)
763 );
764 assert!(
765 expr.clone()
766 .apply(|entity| entity.lines)
767 .get(4)
768 .apply(|line| line.id)
769 .is_null()
770 );
771 assert_eq!(
772 expr.clone().apply(|entity| entity.lines).size().or_else(0),
773 1
774 );
775 }
776
777 #[test]
778 fn safe_expression_exposes_java_style_empty_and_callbacks() {
779 let empty = SafeExpression::value(String::new());
780 assert!(empty.is_empty());
781 assert_eq!(empty.or_else("fallback".to_owned()), String::new());
782
783 let missing = SafeExpression::new((), |_| None::<String>);
784 assert!(missing.is_null());
785 assert_eq!(missing.or_else("fallback".to_owned()), "fallback");
786
787 let mut saw_null = false;
788 missing.when_is_null(|| {
789 saw_null = true;
790 });
791 assert!(saw_null);
792
793 let value = SafeExpression::value("teaql".to_owned());
794 let mut captured = String::new();
795 value.when_not_empty(|text| {
796 captured = text;
797 });
798 assert_eq!(captured, "teaql");
799 }
800
801 #[test]
802 fn web_style_and_action_bind_frontend_metadata() {
803 let mut base = BaseEntityData::new();
804 WebStyle::with_background_color("#ffeecc")
805 .font_color("#111111")
806 .bind_base(&mut base);
807 WebAction::view_web_action().bind_base(&mut base);
808 WebAction::modify_web_action("EDIT", "/orders/1/edit").bind_base(&mut base);
809
810 assert_eq!(
811 base.dynamic(STYLE_KEY)
812 .map(Value::to_json_value)
813 .and_then(|value| value.get("backgroundColor").cloned()),
814 Some(serde_json::json!("#ffeecc"))
815 );
816 assert_eq!(
817 base.dynamic(STYLE_KEY)
818 .map(Value::to_json_value)
819 .and_then(|value| value.get("color").cloned()),
820 Some(serde_json::json!("#111111"))
821 );
822
823 let actions_value = base
824 .dynamic(ACTION_LIST_KEY)
825 .map(Value::to_json_value)
826 .unwrap();
827 let actions = actions_value.as_array().unwrap();
828 assert_eq!(actions.len(), 2);
829 assert_eq!(actions[0]["execute"], serde_json::json!("switchview"));
830 assert_eq!(actions[0]["target"], serde_json::json!("detail"));
831 assert_eq!(actions[1]["name"], serde_json::json!("EDIT"));
832 assert_eq!(
833 actions[1]["requestURL"],
834 serde_json::json!("/orders/1/edit")
835 );
836 }
837
838 #[test]
839 fn web_response_wraps_entity_and_list_payloads() {
840 let entity = OrderRow {
841 id: 7,
842 version: 2,
843 name: "order".to_owned(),
844 };
845 let response = WebResponse::from_entity(&entity);
846 assert_eq!(response.result_code, 0);
847 assert_eq!(response.status.as_deref(), Some("YES"));
848 assert_eq!(response.record_count, 1);
849 assert_eq!(response.version, WEB_RESPONSE_VERSION);
850 assert_eq!(response.data[0]["id"], serde_json::json!(7));
851
852 let list = SmartList::from(vec![entity]).with_total_count(99);
853 let response = WebResponse::from_smart_list(list);
854 assert_eq!(response.record_count, 99);
855 assert_eq!(response.data.len(), 1);
856
857 let failed = WebResponse::fail("bad request").to_json_value();
858 assert_eq!(failed["status"], serde_json::json!("NO"));
859 assert_eq!(failed["message"], serde_json::json!("bad request"));
860 assert_eq!(failed["version"], serde_json::json!("1.001"));
861 }
862
863 #[test]
864 fn web_response_includes_facets() {
865 let entity = OrderRow {
866 id: 7,
867 version: 2,
868 name: "order".to_owned(),
869 };
870 let mut facet_record = Record::new();
871 facet_record.insert("status".to_owned(), Value::Text("PENDING".to_owned()));
872 facet_record.insert("count".to_owned(), Value::I64(5));
873
874 let facet_list = SmartList::from(vec![CompactRow::from_map(facet_record)]);
875
876 let mut list = SmartList::from(vec![entity])
877 .with_total_count(99)
878 .with_facet("status", facet_list);
879
880 assert!(list.facets().contains_key("status"));
882 assert_eq!(list.facet("status").unwrap().len(), 1);
883 assert!(list.facet_mut("status").is_some());
884 assert!(list.facets_mut().contains_key("status"));
885
886 let response = WebResponse::from_smart_list(list);
887 assert_eq!(response.record_count, 99);
888 assert_eq!(response.data.len(), 1);
889
890 let json = response.to_json_value();
891 assert!(json.get("facets").is_some());
892 let facets_map = json["facets"].as_object().unwrap();
893 assert!(facets_map.contains_key("status"));
894 let status_facet = facets_map["status"].as_array().unwrap();
895 assert_eq!(status_facet.len(), 1);
896 assert_eq!(status_facet[0]["status"], serde_json::json!("PENDING"));
897 assert_eq!(status_facet[0]["count"], serde_json::json!(5));
898
899 let mut list2 = SmartList::new(vec![OrderRow {
901 id: 8,
902 version: 1,
903 name: "other".to_owned(),
904 }])
905 .with_facet("status", SmartList::empty());
906
907 let removed = list2.remove_facet("status");
908 assert!(removed.is_some());
909 assert!(list2.facet("status").is_none());
910
911 list2.add_facet("status", SmartList::empty());
912 let taken = list2.take_facets();
913 assert!(taken.contains_key("status"));
914 assert!(list2.facets().is_empty());
915 }
916
917 #[test]
918 fn xls_block_context_matches_java_navigation_model() {
919 let context = XlsBlockBuildContext::new("orders", 2, 3);
920 let header = context
921 .to_block("Order No")
922 .add_property("bold", true)
923 .span(2, 1);
924 let next = context.next().to_block("Amount");
925 let next_line = context.next_line().to_block("SO-1");
926 let new_line = context.new_line().to_block("reset-left");
927
928 assert_eq!(header.page, "orders");
929 assert_eq!(
930 (header.left, header.top, header.right, header.bottom),
931 (2, 3, 3, 3)
932 );
933 assert_eq!(header.width(), 2);
934 assert_eq!(header.height(), 1);
935 assert!(header.contains(3, 3));
936 assert!(!header.contains(4, 3));
937 assert_eq!((next.left, next.top), (3, 3));
938 assert_eq!((next_line.left, next_line.top), (2, 4));
939 assert_eq!((new_line.left, new_line.top), (0, 4));
940 assert_eq!(
941 header.properties.get("bold"),
942 Some(&serde_json::json!(true))
943 );
944 }
945
946 #[test]
947 fn xls_workbook_groups_pages_and_blocks_as_json_payload() {
948 let style = XlsBlock::new("orders", 0, 0, serde_json::Value::Null)
949 .add_property("backgroundColor", "#ffeecc");
950 let title = XlsBlock::new("orders", 0, 0, "Orders")
951 .style(style)
952 .span(3, 1);
953 let page = XlsPage::new("orders").add_block(title);
954 let workbook = XlsWorkbook::new().add_page(page);
955
956 assert!(workbook.page("orders").is_some());
957 assert_eq!(
958 workbook
959 .page("orders")
960 .and_then(|page| page.block_at(1, 0))
961 .map(|block| block.value.clone()),
962 Some(serde_json::json!("Orders"))
963 );
964
965 let json = workbook.to_json_value();
966 assert_eq!(json["pages"][0]["name"], serde_json::json!("orders"));
967 assert_eq!(json["pages"][0]["blocks"][0]["right"], serde_json::json!(2));
968 assert_eq!(
969 json["pages"][0]["blocks"][0]["styleReferBlock"]["properties"]["backgroundColor"],
970 serde_json::json!("#ffeecc")
971 );
972 }
973
974 #[test]
975 fn dynamic_properties_roundtrip_into_json() {
976 let aggregate = OrderAggregateRow::from_compact_row(CompactRow::from_map(Record::from([
977 (String::from("id"), Value::U64(7)),
978 (String::from("lineCount"), Value::I64(3)),
979 (String::from("amount"), Value::F64(18.5)),
980 (
981 String::from("detail"),
982 Value::Object(Record::from([(String::from("status"), Value::from("ok"))])),
983 ),
984 ])))
985 .unwrap();
986
987 assert_eq!(aggregate.dynamic.get("lineCount"), Some(&Value::I64(3)));
988 assert_eq!(aggregate.dynamic.get("amount"), Some(&Value::F64(18.5)));
989 assert_eq!(
990 aggregate.dynamic.get("detail"),
991 Some(&Value::Object(Record::from([(
992 String::from("status"),
993 Value::Text(String::from("ok")),
994 )])))
995 );
996
997 let json = aggregate.into_json();
998 assert_eq!(json["id"], serde_json::json!(7));
999 assert_eq!(json["lineCount"], serde_json::json!(3));
1000 assert_eq!(json["amount"], serde_json::json!(18.5));
1001 assert_eq!(json["detail"], serde_json::json!({"status": "ok"}));
1002 }
1003
1004 #[test]
1005 fn base_entity_data_roundtrips_record_and_dynamic_properties() {
1006 let mut base = BaseEntityData::new()
1007 .with_id(11)
1008 .with_version(3)
1009 .with_dynamic("lineCount", 5)
1010 .with_dynamic("detail", serde_json::json!({"status": "ok"}));
1011 assert_eq!(base.dynamic("lineCount"), Some(&Value::I64(5)));
1012 assert_eq!(base.dynamic_i64("lineCount"), Some(5));
1013 base.put_dynamic("amount", 18.5);
1014 assert_eq!(base.dynamic_f64("amount"), Some(18.5));
1015
1016 let record = base.to_values_map();
1017 assert_eq!(record.get("id"), Some(&Value::U64(11)));
1018 assert_eq!(record.get("version"), Some(&Value::I64(3)));
1019 assert_eq!(record.get("lineCount"), Some(&Value::I64(5)));
1020 assert_eq!(
1021 record.get("detail"),
1022 Some(&Value::Json(serde_json::json!({"status": "ok"})))
1023 );
1024
1025 let restored = BaseEntityData::from_values_map(&record).unwrap();
1026 assert_eq!(restored.id, 11);
1027 assert_eq!(restored.version, 3);
1028 assert_eq!(restored.dynamic("amount"), Some(&Value::F64(18.5)));
1029 assert_eq!(restored.dynamic_f64("amount"), Some(18.5));
1030 }
1031}
1032pub mod eval;