Skip to main content

teaql_core/
lib.rs

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