Skip to main content

teaql_core/
lib.rs

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