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