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