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