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}