Skip to main content

teaql_core/
lib.rs

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