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