Skip to main content

teaql_core/
lib.rs

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