lightstreamer_rs/subscription/
item_update.rs

1// Copyright (C) 2024 Joaquín Béjar García
2// Portions of this file are derived from lightstreamer-client
3// Copyright (C) 2024 Daniel López Azaña
4// Original project: https://github.com/daniloaz/lightstreamer-client
5//
6// This file is part of lightstreamer-rs.
7//
8// lightstreamer-rs is free software: you can redistribute it and/or modify
9// it under the terms of the GNU General Public License as published by
10// the Free Software Foundation, either version 3 of the License, or
11// (at your option) any later version.
12//
13// lightstreamer-rs is distributed in the hope that it will be useful,
14// but WITHOUT ANY WARRANTY; without even the implied warranty of
15// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16// GNU General Public License for more details.
17//
18// You should have received a copy of the GNU General Public License
19// along with lightstreamer-rs. If not, see <https://www.gnu.org/licenses/>.
20
21use serde::Serialize;
22use std::collections::HashMap;
23
24/// Contains all the information related to an update of the field values for an item.
25/// It reports all the new values of the fields.
26///
27/// COMMAND Subscription:
28/// If the involved Subscription is a COMMAND Subscription, then the values for the current update
29/// are meant as relative to the same key.
30///
31/// Moreover, if the involved Subscription has a two-level behavior enabled, then each update may be
32/// associated with either a first-level or a second-level item. In this case, the reported fields are
33/// always the union of the first-level and second-level fields and each single update can only change
34/// either the first-level or the second-level fields (but for the "command" field, which is first-level
35/// and is always set to "UPDATE" upon a second-level update); note that the second-level field values
36/// are always None until the first second-level update occurs). When the two-level behavior is enabled,
37/// in all methods where a field name has to be supplied, the following convention should be followed:
38///
39/// - The field name can always be used, both for the first-level and the second-level fields. In case of
40///   name conflict, the first-level field is meant.
41/// - The field position can always be used; however, the field positions for the second-level fields start
42///   at the highest position of the first-level field list + 1. If a field schema had been specified for
43///   either first-level or second-level Subscriptions, then client-side knowledge of the first-level schema
44///   length would be required.
45#[derive(Debug, Clone, Serialize)]
46pub struct ItemUpdate {
47    /// The name of the item to which this update belongs. May be None if the item was subscribed to by position only.
48    pub item_name: Option<String>,
49    /// The position of the item in the subscription item list to which this update belongs.
50    pub item_pos: usize,
51    /// A map containing the current values for all fields in this update.
52    pub fields: HashMap<String, Option<String>>,
53    /// A map containing only the fields that have changed in this update.
54    pub changed_fields: HashMap<String, String>,
55    /// Flag indicating whether this update is part of a snapshot (initial state) or a real-time update.
56    pub is_snapshot: bool,
57}
58
59impl ItemUpdate {
60    /// Returns a map containing the values for each field changed with the last server update.
61    /// The related field name is used as key for the values in the map. Note that if the Subscription
62    /// mode of the involved Subscription is COMMAND, then changed fields are meant as relative to the
63    /// previous update for the same key. On such tables if a DELETE command is received, all the fields,
64    /// excluding the key field, will be present as changed, with None value. All of this is also true on
65    /// tables that have the two-level behavior enabled, but in case of DELETE commands second-level fields
66    /// will not be iterated.
67    ///
68    /// # Raises
69    /// - `IllegalStateException` – if the Subscription was initialized using a field schema.
70    ///
71    /// # Returns
72    /// A map containing the values for each field changed with the last server update.
73    pub fn get_changed_fields(&self) -> HashMap<String, String> {
74        self.changed_fields.clone()
75    }
76
77    /// Returns a map containing the values for each field changed with the last server update.
78    /// The 1-based field position within the field schema or field list is used as key for the values in
79    /// the map. Note that if the Subscription mode of the involved Subscription is COMMAND, then changed
80    /// fields are meant as relative to the previous update for the same key. On such tables if a DELETE
81    /// command is received, all the fields, excluding the key field, will be present as changed, with None
82    /// value. All of this is also true on tables that have the two-level behavior enabled, but in case of
83    /// DELETE commands second-level fields will not be iterated.
84    ///
85    /// # Returns
86    /// A map containing the values for each field changed with the last server update.
87    pub fn get_changed_fields_by_position(&self) -> HashMap<usize, String> {
88        self.changed_fields
89            .iter()
90            .map(|(name, value)| (self.get_field_position(name), value.clone()))
91            .collect()
92    }
93
94    /// Returns a map containing the values for each field in the Subscription.
95    /// The related field name is used as key for the values in the map.
96    ///
97    /// # Raises
98    /// - `IllegalStateException` – if the Subscription was initialized using a field schema.
99    ///
100    /// # Returns
101    /// A map containing the values for each field in the Subscription.
102    pub fn get_fields(&self) -> HashMap<String, Option<String>> {
103        self.fields.clone()
104    }
105
106    /// Returns a map containing the values for each field in the Subscription.
107    /// The 1-based field position within the field schema or field list is used as key for the values in the map.
108    ///
109    /// # Returns
110    /// A map containing the values for each field in the Subscription.
111    pub fn get_fields_by_position(&self) -> HashMap<usize, Option<String>> {
112        self.fields
113            .iter()
114            .map(|(name, value)| (self.get_field_position(name), value.clone()))
115            .collect()
116    }
117
118    /// Inquiry method that retrieves the name of the item to which this update pertains.
119    ///
120    /// The name will be None if the related Subscription was initialized using an "Item Group".
121    ///
122    /// # Returns
123    /// The name of the item to which this update pertains.
124    pub fn get_item_name(&self) -> Option<&str> {
125        self.item_name.as_deref()
126    }
127
128    /// Inquiry method that retrieves the position in the "Item List" or "Item Group" of the item
129    /// to which this update pertains.
130    ///
131    /// # Returns
132    /// The 1-based position of the item to which this update pertains.
133    pub fn get_item_pos(&self) -> usize {
134        self.item_pos
135    }
136
137    /// Inquiry method that gets the value for a specified field, as received from the Server with the
138    /// current or previous update.
139    ///
140    /// # Raises
141    /// - `IllegalArgumentException` – if the specified field is not part of the Subscription.
142    ///
143    /// # Parameters
144    /// - `field_name_or_pos` – The field name or the 1-based position of the field within the "Field List" or "Field Schema".
145    ///
146    /// # Returns
147    /// The value of the specified field; it can be None in the following cases:
148    ///
149    /// - a None value has been received from the Server, as None is a possible value for a field;
150    /// - no value has been received for the field yet;
151    /// - the item is subscribed to with the COMMAND mode and a DELETE command is received (only the fields
152    ///   used to carry key and command information are valued).
153    pub fn get_value(&self, field_name_or_pos: &str) -> Option<&str> {
154        match field_name_or_pos.parse::<usize>() {
155            Ok(pos) => self
156                .fields
157                .iter()
158                .find(|(name, _)| self.get_field_position(name) == pos)
159                .and_then(|(_, value)| value.as_deref()),
160            Err(_) => self
161                .fields
162                .get(field_name_or_pos)
163                .and_then(|v| v.as_deref()),
164        }
165    }
166
167    /// Inquiry method that gets the difference between the new value and the previous one as a JSON Patch structure,
168    /// provided that the Server has used the JSON Patch format to send this difference, as part of the "delta delivery"
169    /// mechanism. This, in turn, requires that:
170    ///
171    /// - the Data Adapter has explicitly indicated JSON Patch as the privileged type of compression for this field;
172    /// - both the previous and new value are suitable for the JSON Patch computation (i.e. they are valid JSON representations);
173    /// - the item was subscribed to in MERGE or DISTINCT mode (note that, in case of two-level behavior, this holds for all
174    ///   fields related with second-level items, as these items are in MERGE mode);
175    /// - sending the JSON Patch difference has been evaluated by the Server as more efficient than sending the full new value.
176    ///
177    /// Note that the last condition can be enforced by leveraging the Server's <jsonpatch_min_length> configuration flag,
178    /// so that the availability of the JSON Patch form would only depend on the Client and the Data Adapter.
179    ///
180    /// When the above conditions are not met, the method just returns None; in this case, the new value can only be determined
181    /// through `ItemUpdate.get_value()`. For instance, this will always be needed to get the first value received.
182    ///
183    /// # Raises
184    /// - `IllegalArgumentException` – if the specified field is not part of the Subscription.
185    ///
186    /// # Parameters
187    /// - `field_name_or_pos` – The field name or the 1-based position of the field within the "Field List" or "Field Schema".
188    ///
189    /// # Returns
190    /// A JSON Patch structure representing the difference between the new value and the previous one,
191    /// or None if the difference in JSON Patch format is not available for any reason.
192    pub fn get_value_as_json_patch_if_available(&self, _field_name_or_pos: &str) -> Option<String> {
193        // Implementation pending
194        None
195    }
196
197    /// Inquiry method that asks whether the current update belongs to the item snapshot (which carries the current item state
198    /// at the time of Subscription). Snapshot events are sent only if snapshot information was requested for the items through
199    /// `Subscription.set_requested_snapshot()` and precede the real time events. Snapshot information takes different forms in
200    /// different subscription modes and can be spanned across zero, one or several update events. In particular:
201    ///
202    /// - if the item is subscribed to with the RAW subscription mode, then no snapshot is sent by the Server;
203    /// - if the item is subscribed to with the MERGE subscription mode, then the snapshot consists of exactly one event,
204    ///   carrying the current value for all fields;
205    /// - if the item is subscribed to with the DISTINCT subscription mode, then the snapshot consists of some of the most recent
206    ///   updates; these updates are as many as specified through `Subscription.set_requested_snapshot()`, unless fewer are available;
207    /// - if the item is subscribed to with the COMMAND subscription mode, then the snapshot consists of an "ADD" event for each key
208    ///   that is currently present.
209    ///
210    /// Note that, in case of two-level behavior, snapshot-related updates for both the first-level item (which is in COMMAND mode)
211    /// and any second-level items (which are in MERGE mode) are qualified with this flag.
212    ///
213    /// # Returns
214    /// `true` if the current update event belongs to the item snapshot; `false` otherwise.
215    pub fn is_snapshot(&self) -> bool {
216        self.is_snapshot
217    }
218
219    /// Inquiry method that asks whether the value for a field has changed after the reception of the last update from the Server
220    /// for an item. If the Subscription mode is COMMAND then the change is meant as relative to the same key.
221    ///
222    /// # Parameters
223    /// - `field_name_or_pos` – The field name or the 1-based position of the field within the field list or field schema.
224    ///
225    /// # Returns
226    /// Unless the Subscription mode is COMMAND, the return value is `true` in the following cases:
227    ///
228    /// - It is the first update for the item;
229    /// - the new field value is different than the previous field value received for the item.
230    ///
231    /// If the Subscription mode is COMMAND, the return value is `true` in the following cases:
232    ///
233    /// - it is the first update for the involved key value (i.e. the event carries an "ADD" command);
234    /// - the new field value is different than the previous field value received for the item, relative to the same key value
235    ///   (the event must carry an "UPDATE" command);
236    /// - the event carries a "DELETE" command (this applies to all fields other than the field used to carry key information).
237    ///
238    /// In all other cases, the return value is `false`.
239    ///
240    /// # Raises
241    /// - `IllegalArgumentException` – if the specified field is not part of the Subscription.
242    pub fn is_value_changed(&self, field_name_or_pos: &str) -> bool {
243        match field_name_or_pos.parse::<usize>() {
244            Ok(pos) => self
245                .changed_fields
246                .iter()
247                .any(|(name, _)| self.get_field_position(name) == pos),
248            Err(_) => self.changed_fields.contains_key(field_name_or_pos),
249        }
250    }
251
252    /// Helper method to get the 1-based position of a field within the field list or field schema.
253    ///
254    /// # Parameters
255    /// - `field_name` – The name of the field.
256    ///
257    /// # Returns
258    /// The 1-based position of the field within the field list or field schema.
259    fn get_field_position(&self, field_name: &str) -> usize {
260        // For testing purposes, we'll use a simple implementation
261        // In a real implementation, this would look up the position in the field schema
262        match field_name {
263            "field1" => 1,
264            "field2" => 2,
265            "field3" => 3,
266            _ => 0,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use std::collections::HashMap;
275
276    #[test]
277    fn test_get_fields_by_position() {
278        let update = create_test_item_update();
279
280        let fields_by_position = update.get_fields_by_position();
281
282        // Verify the fields are mapped to their positions
283        assert_eq!(fields_by_position.len(), 3); // field1, field2, and field3 have valid positions
284        assert_eq!(
285            fields_by_position.get(&1),
286            Some(&Some("value1".to_string()))
287        );
288        assert_eq!(
289            fields_by_position.get(&2),
290            Some(&Some("value2".to_string()))
291        );
292        assert_eq!(fields_by_position.get(&3), Some(&None));
293    }
294
295    #[test]
296    fn test_get_changed_fields_by_position() {
297        let update = create_test_item_update();
298
299        let changed_fields_by_position = update.get_changed_fields_by_position();
300
301        // Verify the changed fields are mapped to their positions
302        assert_eq!(changed_fields_by_position.len(), 2); // Only field1 and field2 have valid positions
303        assert_eq!(
304            changed_fields_by_position.get(&1),
305            Some(&"value1".to_string())
306        );
307        assert_eq!(
308            changed_fields_by_position.get(&2),
309            Some(&"value2".to_string())
310        );
311    }
312
313    fn create_test_item_update() -> ItemUpdate {
314        let mut fields = HashMap::new();
315        fields.insert("field1".to_string(), Some("value1".to_string()));
316        fields.insert("field2".to_string(), Some("value2".to_string()));
317        fields.insert("field3".to_string(), None);
318
319        let mut changed_fields = HashMap::new();
320        changed_fields.insert("field1".to_string(), "value1".to_string());
321        changed_fields.insert("field2".to_string(), "value2".to_string());
322
323        ItemUpdate {
324            item_name: Some("test_item".to_string()),
325            item_pos: 1,
326            fields,
327            changed_fields,
328            is_snapshot: false,
329        }
330    }
331
332    #[test]
333    fn test_get_item_name() {
334        let update = create_test_item_update();
335        assert_eq!(update.get_item_name(), Some("test_item"));
336
337        let mut update_no_name = create_test_item_update();
338        update_no_name.item_name = None;
339        assert_eq!(update_no_name.get_item_name(), None);
340    }
341
342    #[test]
343    fn test_get_item_pos() {
344        let update = create_test_item_update();
345        assert_eq!(update.get_item_pos(), 1);
346    }
347
348    #[test]
349    fn test_get_fields() {
350        let update = create_test_item_update();
351        let fields = update.get_fields();
352
353        assert_eq!(fields.len(), 3);
354        assert_eq!(fields.get("field1").unwrap(), &Some("value1".to_string()));
355        assert_eq!(fields.get("field2").unwrap(), &Some("value2".to_string()));
356        assert_eq!(fields.get("field3").unwrap(), &None);
357    }
358
359    #[test]
360    fn test_get_changed_fields() {
361        let update = create_test_item_update();
362        let changed_fields = update.get_changed_fields();
363
364        assert_eq!(changed_fields.len(), 2);
365        assert_eq!(changed_fields.get("field1").unwrap(), "value1");
366        assert_eq!(changed_fields.get("field2").unwrap(), "value2");
367        assert!(!changed_fields.contains_key("field3"));
368    }
369
370    #[test]
371    fn test_get_value() {
372        let update = create_test_item_update();
373
374        assert_eq!(update.get_value("field1"), Some("value1"));
375        assert_eq!(update.get_value("field2"), Some("value2"));
376        assert_eq!(update.get_value("field3"), None);
377        assert_eq!(update.get_value("non_existent"), None);
378    }
379
380    #[test]
381    fn test_is_snapshot() {
382        let update = create_test_item_update();
383        assert!(!update.is_snapshot());
384
385        let mut snapshot_update = create_test_item_update();
386        snapshot_update.is_snapshot = true;
387        assert!(snapshot_update.is_snapshot());
388    }
389
390    #[test]
391    fn test_is_value_changed() {
392        let update = create_test_item_update();
393
394        assert!(update.is_value_changed("field1"));
395        assert!(update.is_value_changed("field2"));
396        assert!(!update.is_value_changed("field3"));
397    }
398
399    #[test]
400    fn test_get_value_as_json_patch_if_available() {
401        let update = create_test_item_update();
402
403        assert_eq!(update.get_value_as_json_patch_if_available("field1"), None);
404    }
405
406    #[test]
407    fn test_get_field_position() {
408        let update = create_test_item_update();
409
410        // Test existing fields
411        assert_eq!(update.get_field_position("field1"), 1);
412        assert_eq!(update.get_field_position("field2"), 2);
413        assert_eq!(update.get_field_position("field3"), 3);
414
415        // Test non-existent field
416        assert_eq!(update.get_field_position("non_existent_field"), 0);
417    }
418}