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