Skip to main content

teaql_core/
lib.rs

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::{TraceKind, 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        // Verify getter/mutator APIs on SmartList
812        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        // Test removing and taking facets
831        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;