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