Skip to main content

teaql_core/
lib.rs

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