lightstreamer_rs/subscription/item_update.rs
1//! What the caller receives for every real-time update: [`ItemUpdate`].
2//!
3//! A TLCP subscription is a table. Its **items** are the rows the server was
4//! asked for and its **fields** are the columns of the schema; both are ordered
5//! by the Metadata Adapter and both are numbered from 1 on the wire
6//! [`docs/spec/04-notifications.md` §2.1]. Every `U` notification reports new
7//! values for one item, and [`ItemUpdate`] is that notification after the
8//! second-level value syntax has been decoded against the item's previous
9//! state [`docs/spec/04-notifications.md` §2.3].
10//!
11//! # Null is not empty
12//!
13//! This is the single most important thing to get right about a field value.
14//! TLCP has two distinct markers and they mean different things
15//! [`docs/spec/04-notifications.md` §2.2]:
16//!
17//! | Wire token | Meaning | This crate |
18//! |---|---|---|
19//! | `#` | the field is **null** — it has no value | [`FieldValue::Null`] |
20//! | `$` | the field is the **empty string** | [`FieldValue::Text`] wrapping `""` |
21//! | *(empty token)* | the field is **unchanged** since the last update | the previous [`FieldValue`], and the field is absent from [`ItemUpdate::changed_fields`] |
22//!
23//! A field value is therefore [`FieldValue`], never a bare `&str` and never an
24//! `Option<&str>` that a caller might flatten by accident. `FieldValue::Null`
25//! and `FieldValue::Text("")` compare unequal, print differently, and are
26//! produced by different server intentions. Code that treats a missing quote as
27//! an empty quote is wrong in a way no test of its own will catch, so this
28//! crate refuses to make the two spellable as one.
29//!
30//! # Values are text
31//!
32//! A field value is exactly the UTF-8 string the server sent, after
33//! percent-decoding and after any diff was applied
34//! [`docs/spec/04-notifications.md` §2.2, §2.3]. This crate never parses one
35//! into a number, a date or a boolean: what a value means is the Metadata
36//! Adapter's business and the caller's, not the protocol's.
37//!
38//! # Snapshot or real time
39//!
40//! [`ItemUpdate::kind`] answers it, computed from the specification's own
41//! decision table [`docs/spec/04-notifications.md` §2.4]; the table is
42//! implemented row for row by `classify_update` in
43//! `src/subscription/manager.rs`.
44
45use std::fmt;
46use std::ops::Range;
47use std::sync::Arc;
48
49// ---------------------------------------------------------------------------
50// Field values
51// ---------------------------------------------------------------------------
52
53/// The value of one field of one item.
54///
55/// TLCP distinguishes **null** from the **empty string**, and so does this
56/// type: `#` on the wire produces [`FieldValue::Null`] and `$` produces
57/// [`FieldValue::Text`] wrapping `""` [`docs/spec/04-notifications.md` §2.2].
58/// They are not interchangeable. A quote adapter that has no bid to report
59/// sends `#`; one whose status line is deliberately blank sends `$`.
60///
61/// # Examples
62///
63/// An [`ItemUpdate`] is obtained from a live subscription, so the example is
64/// written as the function you would pass one to — which compiles, and so
65/// keeps saying something true:
66///
67/// ```
68/// use lightstreamer_rs::{FieldValue, ItemUpdate};
69///
70/// fn report_bid(update: &ItemUpdate) {
71/// match update.field_by_name("bid") {
72/// Some(FieldValue::Text(bid)) => println!("bid is {bid}"),
73/// Some(FieldValue::Null) => println!("there is no bid"),
74/// None => println!("this subscription has no `bid` field"),
75/// }
76/// }
77/// ```
78///
79/// The three arms are the three answers, and they are not
80/// interchangeable: `Text("")` is a fourth thing again — a value that happens
81/// to be empty. Use [`is_empty_text`](FieldValue::is_empty_text) to ask.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum FieldValue<'a> {
84 /// The field has no value — the `#` marker
85 /// [`docs/spec/04-notifications.md` §2.2].
86 ///
87 /// Also what a field reads as before any update has ever set it, which is
88 /// only observable between the subscription being activated and its first
89 /// `U`: the first update of a subscription carries a token for every field
90 /// [`docs/spec/04-notifications.md` §2.3].
91 Null,
92
93 /// The field holds this text, which may be the **empty string** — the `$`
94 /// marker sets it to exactly that
95 /// [`docs/spec/04-notifications.md` §2.2].
96 Text(&'a str),
97}
98
99impl<'a> FieldValue<'a> {
100 /// Whether the field is null, i.e. has no value at all.
101 ///
102 /// A field holding the empty string is **not** null.
103 #[must_use]
104 #[inline]
105 pub const fn is_null(self) -> bool {
106 matches!(self, Self::Null)
107 }
108
109 /// Whether the field holds the empty string.
110 ///
111 /// A null field is **not** empty text: it has no text.
112 #[must_use]
113 #[inline]
114 pub fn is_empty_text(self) -> bool {
115 matches!(self, Self::Text(text) if text.is_empty())
116 }
117
118 /// The text, or [`None`] if the field is null.
119 ///
120 /// Collapsing the distinction is a deliberate act here, which is the point:
121 /// `text()` reads as "I am choosing to treat null as absent", whereas an
122 /// accessor that returned `&str` directly would make the same choice
123 /// silently.
124 #[must_use]
125 #[inline]
126 pub const fn text(self) -> Option<&'a str> {
127 match self {
128 Self::Null => None,
129 Self::Text(text) => Some(text),
130 }
131 }
132
133 /// The text, substituting `default` if the field is null.
134 ///
135 /// This is how a field value is rendered for a human: `Display` is
136 /// deliberately **not** implemented, because every rendering of a null has
137 /// to choose a spelling and no choice is safe to make on the caller's
138 /// behalf — the empty string collapses null into `$`, and any placeholder
139 /// is a string a field could legitimately hold. Naming the substitute at
140 /// the call site is a one-word cost and it keeps
141 /// `println!("{}", value.text_or("(null)"))` honest.
142 #[must_use]
143 #[inline]
144 pub fn text_or(self, default: &'a str) -> &'a str {
145 self.text().unwrap_or(default)
146 }
147}
148
149// ---------------------------------------------------------------------------
150// COMMAND-mode command field
151// ---------------------------------------------------------------------------
152
153/// What a `COMMAND`-mode update did to the row named by its key.
154///
155/// In `COMMAND` mode the subscription is a dynamic table: "each real-time
156/// update may add a new row, delete an existing row or change the content of a
157/// specific row" [`docs/spec/04-notifications.md` §3.3]. Which of the three
158/// happened is carried in the field whose position `SUBCMD` reported
159/// [`docs/spec/04-notifications.md` §3.2], and the row it applies to is named
160/// by the value of the key field.
161///
162/// SPEC-AMBIGUITY (command-literals): the specification names the commands
163/// `ADD`, `DELETE` and `UPDATE` but "nowhere states the literal wire values
164/// carried in the command field of a `U` notification, nor their casing"
165/// [`docs/spec/04-notifications.md` §3.3]. This crate matches the three names
166/// **case-insensitively** — the most permissive reading that cannot admit a
167/// fourth command by accident — and preserves anything else as
168/// [`ItemCommand::Unrecognized`] rather than rejecting the update, so a server
169/// that grows a command cannot break a client that does not know it.
170#[derive(Debug, Clone, PartialEq, Eq, Hash)]
171pub enum ItemCommand {
172 /// A row entered the table. Its fields are the ones this update carries.
173 Add,
174
175 /// An existing row changed. The fields this update did not carry keep the
176 /// values they already had.
177 Update,
178
179 /// A row left the table. The caller should drop its own representation of
180 /// the row named by [`ItemUpdate::key`].
181 ///
182 /// Note what this does **not** mean for the state this crate keeps: see
183 /// the `DELETE` discussion on [`ItemUpdate::key`].
184 Delete,
185
186 /// The command field held something else. The value is preserved verbatim.
187 Unrecognized(
188 /// The command exactly as the server sent it, after decoding.
189 String,
190 ),
191}
192
193impl ItemCommand {
194 /// Classifies a decoded command-field value.
195 #[must_use]
196 fn from_wire(value: &str) -> Self {
197 // SPEC-AMBIGUITY (command-literals): casing is unspecified
198 // [`docs/spec/04-notifications.md` §3.3], so the comparison ignores it.
199 if value.eq_ignore_ascii_case("ADD") {
200 Self::Add
201 } else if value.eq_ignore_ascii_case("UPDATE") {
202 Self::Update
203 } else if value.eq_ignore_ascii_case("DELETE") {
204 Self::Delete
205 } else {
206 Self::Unrecognized(value.to_owned())
207 }
208 }
209
210 /// The command as an uppercase name, or the preserved literal for
211 /// [`ItemCommand::Unrecognized`].
212 #[must_use]
213 #[inline]
214 pub fn as_str(&self) -> &str {
215 match self {
216 Self::Add => "ADD",
217 Self::Update => "UPDATE",
218 Self::Delete => "DELETE",
219 Self::Unrecognized(literal) => literal,
220 }
221 }
222}
223
224impl fmt::Display for ItemCommand {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 f.write_str(self.as_str())
227 }
228}
229
230// ---------------------------------------------------------------------------
231// Snapshot classification
232// ---------------------------------------------------------------------------
233
234/// Whether an update carries part of an item's initial state or a live change.
235///
236/// `U` carries both: "the notification is also used to send the item's
237/// snapshot" [`docs/spec/04-notifications.md` §2.1], and telling them apart is
238/// a function of four inputs, tabulated in
239/// [`docs/spec/04-notifications.md` §2.4].
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
241pub enum UpdateKind {
242 /// Part of the item's **initial state**, delivered because the
243 /// subscription asked for a snapshot.
244 ///
245 /// A snapshot may be one `U` (`MERGE`) or many (`DISTINCT`, `COMMAND`),
246 /// and in the latter case its end is announced by an end-of-snapshot event
247 /// [`docs/spec/04-notifications.md` §3.5]. `RAW` has no snapshot at all.
248 Snapshot,
249
250 /// A **live change** to the item, produced after its initial state.
251 RealTime,
252}
253
254impl UpdateKind {
255 /// Whether this is snapshot content.
256 #[must_use]
257 #[inline]
258 pub const fn is_snapshot(self) -> bool {
259 matches!(self, Self::Snapshot)
260 }
261}
262
263// ---------------------------------------------------------------------------
264// Schema
265// ---------------------------------------------------------------------------
266
267/// One item or field name, remembering whether the caller declared it.
268///
269/// The protocol never transmits item or field names: `SUBOK` reports only how
270/// many of each there are, and their order "is determined by the Metadata
271/// Adapter" [`docs/spec/04-notifications.md` §3.1]. Names therefore come from
272/// the caller, who knows them whenever it spelled out an item list and a field
273/// list rather than naming a server-side group and schema. When a name was not
274/// declared, this holds the position rendered in decimal so that
275/// [`ItemUpdate::item_name`] can always return something printable.
276#[derive(Debug, Clone, PartialEq, Eq)]
277struct SchemaName {
278 text: String,
279 declared: bool,
280}
281
282impl SchemaName {
283 /// A name the caller supplied.
284 #[must_use]
285 fn declared(text: impl Into<String>) -> Self {
286 Self {
287 text: text.into(),
288 declared: true,
289 }
290 }
291
292 /// A stand-in for a name the caller did not supply: the 1-based position.
293 #[must_use]
294 fn positional(position: usize) -> Self {
295 Self {
296 text: position.to_string(),
297 declared: false,
298 }
299 }
300
301 /// The printable name.
302 #[must_use]
303 #[inline]
304 fn as_str(&self) -> &str {
305 &self.text
306 }
307
308 /// The name, only if the caller declared it.
309 #[must_use]
310 #[inline]
311 fn declared_str(&self) -> Option<&str> {
312 if self.declared {
313 Some(&self.text)
314 } else {
315 None
316 }
317 }
318}
319
320/// The shape of one activated subscription: how many items and fields it has,
321/// what they are called, and — in `COMMAND` mode — where the key and command
322/// fields sit.
323///
324/// Shared by [`Arc`] across every [`ItemUpdate`] of the subscription, because
325/// it is fixed from the moment `SUBOK`/`SUBCMD` arrives
326/// [`docs/spec/04-notifications.md` §3.1, §3.2] and copying it per update would
327/// be pure waste.
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub(crate) struct SubscriptionSchema {
330 items: Vec<SchemaName>,
331 fields: Vec<SchemaName>,
332 key_field: Option<usize>,
333 command_field: Option<usize>,
334}
335
336impl SubscriptionSchema {
337 /// Builds the schema announced by `SUBOK` or `SUBCMD`.
338 ///
339 /// `declared_items` and `declared_fields` are the caller's own names, taken
340 /// positionally. They may be shorter than the server's counts (or empty, if
341 /// the caller subscribed to a server-side group and schema); every position
342 /// they do not cover falls back to its 1-based index
343 /// [`docs/spec/04-notifications.md` §3.1].
344 ///
345 /// `command_fields` carries the 0-based key and command positions of a
346 /// `COMMAND`-mode subscription [`docs/spec/04-notifications.md` §3.2].
347 #[must_use]
348 pub(crate) fn new(
349 item_count: usize,
350 field_count: usize,
351 declared_items: &[String],
352 declared_fields: &[String],
353 command_fields: Option<(usize, usize)>,
354 ) -> Self {
355 let (key_field, command_field) = match command_fields {
356 Some((key, command)) => (Some(key), Some(command)),
357 None => (None, None),
358 };
359 Self {
360 items: Self::name_positions(item_count, declared_items),
361 fields: Self::name_positions(field_count, declared_fields),
362 key_field,
363 command_field,
364 }
365 }
366
367 /// Builds a schema from a name per position, [`None`] meaning the caller
368 /// declared none for that position.
369 ///
370 /// [`new`](Self::new) can only take declared names as a *prefix*, which is
371 /// what a subscription's item and field lists actually are. The
372 /// `test-util` builder ([`crate::test_util`]) needs to name one item at an
373 /// arbitrary position, so it supplies the names slot by slot instead.
374 #[cfg(feature = "test-util")]
375 #[must_use]
376 pub(crate) fn from_optional_names(
377 items: Vec<Option<String>>,
378 fields: Vec<Option<String>>,
379 command_fields: Option<(usize, usize)>,
380 ) -> Self {
381 /// Maps each slot to a declared name or to its 1-based position.
382 fn named(slots: Vec<Option<String>>) -> Vec<SchemaName> {
383 slots
384 .into_iter()
385 .enumerate()
386 .map(|(index, name)| match name {
387 Some(text) => SchemaName::declared(text),
388 // Cannot overflow: `index` is an index into a `Vec`.
389 None => SchemaName::positional(index + 1),
390 })
391 .collect()
392 }
393
394 let (key_field, command_field) = match command_fields {
395 Some((key, command)) => (Some(key), Some(command)),
396 None => (None, None),
397 };
398 Self {
399 items: named(items),
400 fields: named(fields),
401 key_field,
402 command_field,
403 }
404 }
405
406 /// Zips `count` positions against the names the caller declared.
407 #[must_use]
408 fn name_positions(count: usize, declared: &[String]) -> Vec<SchemaName> {
409 let mut names = Vec::with_capacity(count);
410 for index in 0..count {
411 match declared.get(index) {
412 Some(name) => names.push(SchemaName::declared(name.clone())),
413 // Cannot overflow: `index < count <= usize::MAX`.
414 None => names.push(SchemaName::positional(index + 1)),
415 }
416 }
417 names
418 }
419
420 /// How many items the subscription resolved to.
421 // No non-test caller: the item count reaches the caller through
422 // `SubscriptionEvent::Activated`, and this is the inspection surface the
423 // tests assert against.
424 #[allow(dead_code)]
425 #[must_use]
426 #[inline]
427 pub(crate) fn item_count(&self) -> usize {
428 self.items.len()
429 }
430
431 /// How many fields the schema resolved to — the width every `U` value list
432 /// is decoded against [`docs/spec/04-notifications.md` §3.1].
433 #[must_use]
434 #[inline]
435 pub(crate) fn field_count(&self) -> usize {
436 self.fields.len()
437 }
438
439 /// Whether this subscription carries a key and a command field, i.e. was
440 /// activated by `SUBCMD` [`docs/spec/04-notifications.md` §3.2].
441 #[must_use]
442 #[inline]
443 pub(crate) fn is_command_mode(&self) -> bool {
444 self.key_field.is_some() && self.command_field.is_some()
445 }
446}
447
448// ---------------------------------------------------------------------------
449// ItemUpdate
450// ---------------------------------------------------------------------------
451
452/// One real-time update, decoded: the new state of one item of one
453/// subscription, plus which of its fields this update actually changed.
454///
455/// This is what a caller consumes from the subscription's event stream
456/// (`docs/adr/0003-typed-event-stream-as-delivery-surface.md`) — but not
457/// *directly*: [`Updates`](crate::Updates) yields a
458/// [`SubscriptionEvent`](crate::SubscriptionEvent), of which an update is one
459/// variant among the things that can happen to a subscription. Matching it out
460/// is the loop:
461///
462/// ```
463/// use futures_util::StreamExt;
464/// use lightstreamer_rs::{SubscriptionEvent, Updates};
465///
466/// async fn print_changes(mut updates: Updates) {
467/// while let Some(event) = updates.next().await {
468/// if let SubscriptionEvent::Update(update) = event {
469/// println!("{}: {:?}", update.item_name(), update.changed_fields());
470/// }
471/// }
472/// }
473/// ```
474///
475/// # Every field is readable, not just the changed ones
476///
477/// An update on the wire is a delta: a field the server did not mention is
478/// "unchanged compared to the previous update of the same field"
479/// [`docs/spec/04-notifications.md` §2.2]. This type carries the item's
480/// **complete** state after the delta was applied, so
481/// [`field`](Self::field) and [`field_by_name`](Self::field_by_name) always
482/// answer, whether or not this particular update touched the field. Use
483/// [`changed_fields`](Self::changed_fields) or
484/// [`is_field_changed`](Self::is_field_changed) to ask what moved.
485///
486/// # Positions are 1-based
487///
488/// Item and field positions match the specification's own numbering — items
489/// and fields are numbered from 1 [`docs/spec/04-notifications.md` §2.1,
490/// §3.2] — so a position printed in a log lines up with a position in a
491/// packet capture.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ItemUpdate {
494 schema: Arc<SubscriptionSchema>,
495 /// 0-based index of the item within the subscription.
496 item: usize,
497 kind: UpdateKind,
498 /// The complete state of the item after this update, one entry per field
499 /// of the schema. `None` is null; `Some(text)` is text, possibly empty.
500 values: Vec<Option<String>>,
501 /// 0-based positions of the fields this update carried a token for,
502 /// ascending.
503 changed: Vec<usize>,
504}
505
506impl ItemUpdate {
507 /// Assembles an update from the decoded state of an item.
508 ///
509 /// `item` and `changed` are 0-based; `values` has one entry per field of
510 /// `schema`, `None` meaning null.
511 #[must_use]
512 pub(crate) fn new(
513 schema: Arc<SubscriptionSchema>,
514 item: usize,
515 kind: UpdateKind,
516 values: Vec<Option<String>>,
517 changed: Vec<usize>,
518 ) -> Self {
519 Self {
520 schema,
521 item,
522 kind,
523 values,
524 changed,
525 }
526 }
527
528 // -- The item -----------------------------------------------------------
529
530 /// The item's **1-based** position within the subscription.
531 ///
532 /// This is the `<item>` argument of the `U` notification verbatim; the
533 /// order of items "is determined by the Metadata Adapter"
534 /// [`docs/spec/04-notifications.md` §2.1].
535 #[must_use]
536 #[inline]
537 pub const fn item_index(&self) -> usize {
538 // Cannot overflow: `item` is an index into a `Vec`.
539 self.item + 1
540 }
541
542 /// The item's name.
543 ///
544 /// TLCP never sends item names — `SUBOK` reports only a count
545 /// [`docs/spec/04-notifications.md` §3.1] — so this is the name the caller
546 /// declared for this position when it subscribed. If the caller subscribed
547 /// to a server-side group and did not declare a name for this position,
548 /// this returns the item's 1-based index rendered in decimal, so that it is
549 /// always printable. Use [`declared_item_name`](Self::declared_item_name)
550 /// when the difference matters.
551 #[must_use]
552 #[inline]
553 pub fn item_name(&self) -> &str {
554 self.schema
555 .items
556 .get(self.item)
557 .map_or("", SchemaName::as_str)
558 }
559
560 /// The item's name, or [`None`] if the caller never declared one.
561 ///
562 /// Unlike [`item_name`](Self::item_name) this never invents a stand-in.
563 #[must_use]
564 #[inline]
565 pub fn declared_item_name(&self) -> Option<&str> {
566 self.schema
567 .items
568 .get(self.item)
569 .and_then(SchemaName::declared_str)
570 }
571
572 // -- Snapshot or real time ----------------------------------------------
573
574 /// Whether this update is snapshot content or a live change
575 /// [`docs/spec/04-notifications.md` §2.4].
576 #[must_use]
577 #[inline]
578 pub const fn kind(&self) -> UpdateKind {
579 self.kind
580 }
581
582 /// Shorthand for `self.kind().is_snapshot()`.
583 #[must_use]
584 #[inline]
585 pub const fn is_snapshot(&self) -> bool {
586 self.kind.is_snapshot()
587 }
588
589 // -- Fields --------------------------------------------------------------
590
591 /// How many fields the subscription's schema has
592 /// [`docs/spec/04-notifications.md` §3.1].
593 #[must_use]
594 #[inline]
595 pub fn field_count(&self) -> usize {
596 self.schema.field_count()
597 }
598
599 /// The name of the field at `position` (**1-based**), or [`None`] if the
600 /// schema has no such field.
601 ///
602 /// As with [`item_name`](Self::item_name), the protocol does not transmit
603 /// field names; an undeclared position renders as its 1-based index.
604 #[must_use]
605 #[inline]
606 pub fn field_name(&self, position: usize) -> Option<&str> {
607 self.schema
608 .fields
609 .get(position.checked_sub(1)?)
610 .map(SchemaName::as_str)
611 }
612
613 /// The value of the field at `position` (**1-based**), or [`None`] if the
614 /// schema has no such field.
615 ///
616 /// Note the two levels: [`None`] means *there is no such field*, whereas
617 /// `Some(`[`FieldValue::Null`]`)` means *the field exists and is null*.
618 /// See the module documentation on null versus empty.
619 #[must_use]
620 pub fn field(&self, position: usize) -> Option<FieldValue<'_>> {
621 let index = position.checked_sub(1)?;
622 Some(match self.values.get(index)? {
623 None => FieldValue::Null,
624 Some(text) => FieldValue::Text(text),
625 })
626 }
627
628 /// The value of the field the caller declared as `name`, or [`None`] if no
629 /// declared field has that name.
630 ///
631 /// Only names the caller declared are matched: the decimal stand-ins that
632 /// [`field_name`](Self::field_name) returns for undeclared positions are
633 /// **not** lookup keys, so `field_by_name("3")` cannot accidentally resolve
634 /// to the third field of a subscription whose fields were never named.
635 /// Names are compared exactly, and if the caller declared the same name
636 /// twice the first position wins.
637 #[must_use]
638 pub fn field_by_name(&self, name: &str) -> Option<FieldValue<'_>> {
639 self.field(self.field_position(name)?)
640 }
641
642 /// The **1-based** position of the field the caller declared as `name`, or
643 /// [`None`] if no declared field has that name.
644 #[must_use]
645 pub fn field_position(&self, name: &str) -> Option<usize> {
646 self.schema
647 .fields
648 .iter()
649 .position(|field| field.declared_str() == Some(name))
650 // Cannot overflow: `position` is an index into a `Vec`.
651 .map(|index| index + 1)
652 }
653
654 /// Every field of the schema, in order, changed or not.
655 #[must_use]
656 #[inline]
657 pub fn fields(&self) -> Fields<'_> {
658 Fields {
659 update: self,
660 positions: 0..self.values.len(),
661 }
662 }
663
664 // -- What changed --------------------------------------------------------
665
666 /// The fields this update changed, in ascending position order.
667 ///
668 /// "Changed" means **the update carried an explicit token for the field**,
669 /// which is exactly what the specification's own worked examples mark
670 /// *Changed* [`docs/spec/04-notifications.md` §2.5]. It is not a comparison
671 /// of values: an update that sets a field to the value it already had is
672 /// still a change, and the spec's third example does precisely that to its
673 /// `status` field. Fields left out by an empty token or skipped by a `^N`
674 /// run are absent, since both spellings mean "unchanged"
675 /// [`docs/spec/04-notifications.md` §2.2].
676 #[must_use]
677 #[inline]
678 pub fn changed_fields(&self) -> ChangedFields<'_> {
679 ChangedFields {
680 update: self,
681 positions: self.changed.iter(),
682 }
683 }
684
685 /// How many fields this update changed.
686 #[must_use]
687 #[inline]
688 pub fn changed_count(&self) -> usize {
689 self.changed.len()
690 }
691
692 /// Whether this update carried a token for the field at `position`
693 /// (**1-based**).
694 #[must_use]
695 pub fn is_field_changed(&self, position: usize) -> bool {
696 position
697 .checked_sub(1)
698 .is_some_and(|index| self.changed.contains(&index))
699 }
700
701 /// Whether this update carried a token for the field the caller declared as
702 /// `name`. `false` if there is no such declared field.
703 #[must_use]
704 pub fn is_field_changed_by_name(&self, name: &str) -> bool {
705 self.field_position(name)
706 .is_some_and(|position| self.is_field_changed(position))
707 }
708
709 // -- COMMAND mode --------------------------------------------------------
710
711 /// Whether this update belongs to a `COMMAND`-mode subscription, i.e. one
712 /// activated by `SUBCMD` [`docs/spec/04-notifications.md` §3.2].
713 #[must_use]
714 #[inline]
715 pub fn is_command_mode(&self) -> bool {
716 self.schema.is_command_mode()
717 }
718
719 /// The row this update is about, for a `COMMAND`-mode subscription.
720 ///
721 /// [`None`] if the subscription is not in `COMMAND` mode. Otherwise the
722 /// value of the field `SUBCMD` designated as the key
723 /// [`docs/spec/04-notifications.md` §3.2] — an ordinary field, decoded like
724 /// any other, so it can legitimately be [`FieldValue::Null`] and that is
725 /// reported rather than hidden.
726 ///
727 /// # What a `DELETE` does, and does not, do here
728 ///
729 /// [`ItemCommand::Delete`] tells the caller to drop **its own**
730 /// representation of the row. It does not erase anything inside this crate:
731 /// the field values this crate keeps per item are the baseline that the
732 /// next update's unchanged-field markers are resolved against
733 /// [`docs/spec/04-notifications.md` §2.2], and discarding that baseline
734 /// would make the very next `U` undecodable.
735 ///
736 /// SPEC-AMBIGUITY (command-delta-baseline): the specification "does not
737 /// state ... whether unchanged-field markers in a `U` are resolved against
738 /// the previous update of the same key or of the same item", noting that
739 /// the decoding algorithm "speaks only of 'the previous update of the same
740 /// field', without qualification by key"
741 /// [`docs/spec/04-notifications.md` §3.3]. This crate takes the algorithm
742 /// literally and keeps **one baseline per item**, because that is the only
743 /// reading the spec actually states; a per-key baseline would be an
744 /// invention, and it would have no defined value for a key seen for the
745 /// first time.
746 #[must_use]
747 pub fn key(&self) -> Option<FieldValue<'_>> {
748 let index = self.schema.key_field?;
749 // 1-based for `field`.
750 self.field(index.checked_add(1)?)
751 }
752
753 /// What this update did to the row named by [`key`](Self::key), for a
754 /// `COMMAND`-mode subscription.
755 ///
756 /// [`None`] if the subscription is not in `COMMAND` mode, or if the command
757 /// field is null — a null command names no operation, so this crate reports
758 /// its absence rather than guessing one.
759 ///
760 /// # Check [`is_command_changed`](Self::is_command_changed) before acting
761 ///
762 /// The command is an ordinary field, and an ordinary field that this update
763 /// did not carry keeps the value the previous update gave it
764 /// [`docs/spec/04-notifications.md` §2.2]. So a `U` that leaves the command
765 /// field unchanged makes this method return the **previous** update's
766 /// command, and a caller that deletes a row whenever it sees
767 /// [`ItemCommand::Delete`] would delete on the strength of an operation
768 /// that already happened.
769 ///
770 /// SPEC-AMBIGUITY (command-delta-baseline): the specification does not
771 /// state the client-side `COMMAND` state machine, nor "whether
772 /// unchanged-field markers in a `U` are resolved against the previous
773 /// update **of the same key** or of the same item"
774 /// [`docs/spec/04-notifications.md` §3.3]. This crate does not invent one:
775 /// it reports the field value the decoding algorithm produces, and reports
776 /// separately whether *this* update carried it, so the caller can require
777 /// the stronger evidence before doing something destructive.
778 #[must_use]
779 pub fn command(&self) -> Option<ItemCommand> {
780 let index = self.schema.command_field?;
781 let value = self.field(index.checked_add(1)?)?;
782 value.text().map(ItemCommand::from_wire)
783 }
784
785 /// Whether **this** update carried a token for the command field, as
786 /// opposed to inheriting its value from an earlier update of the same item
787 /// [`docs/spec/04-notifications.md` §2.2, §2.5].
788 ///
789 /// `false` when the subscription is not in `COMMAND` mode.
790 #[must_use]
791 pub fn is_command_changed(&self) -> bool {
792 self.schema
793 .command_field
794 .and_then(|index| index.checked_add(1))
795 .is_some_and(|position| self.is_field_changed(position))
796 }
797
798 /// Whether **this** update carried a token for the key field.
799 ///
800 /// `false` when the subscription is not in `COMMAND` mode. A `false` here
801 /// does not mean the row is unknown: under the per-item baseline this crate
802 /// keeps (see [`key`](Self::key)) it means the key is the one the previous
803 /// update named.
804 #[must_use]
805 pub fn is_key_changed(&self) -> bool {
806 self.schema
807 .key_field
808 .and_then(|index| index.checked_add(1))
809 .is_some_and(|position| self.is_field_changed(position))
810 }
811}
812
813// ---------------------------------------------------------------------------
814// Field views and iterators
815// ---------------------------------------------------------------------------
816
817/// One field of an [`ItemUpdate`]: where it sits, what it is called, what it
818/// holds, and whether this update changed it.
819#[derive(Clone, Copy)]
820pub struct Field<'a> {
821 update: &'a ItemUpdate,
822 /// 0-based.
823 index: usize,
824}
825
826impl<'a> Field<'a> {
827 /// The field's **1-based** position in the schema.
828 #[must_use]
829 #[inline]
830 pub const fn position(&self) -> usize {
831 // Cannot overflow: `index` is an index into a `Vec`.
832 self.index + 1
833 }
834
835 /// The field's name, or its 1-based position in decimal if the caller
836 /// declared no name for it — see [`ItemUpdate::field_name`].
837 #[must_use]
838 #[inline]
839 pub fn name(&self) -> &'a str {
840 self.update
841 .schema
842 .fields
843 .get(self.index)
844 .map_or("", SchemaName::as_str)
845 }
846
847 /// The field's name, or [`None`] if the caller declared none.
848 #[must_use]
849 #[inline]
850 pub fn declared_name(&self) -> Option<&'a str> {
851 self.update
852 .schema
853 .fields
854 .get(self.index)
855 .and_then(SchemaName::declared_str)
856 }
857
858 /// The field's value after this update. Null and empty stay distinct; see
859 /// [`FieldValue`].
860 #[must_use]
861 #[inline]
862 pub fn value(&self) -> FieldValue<'a> {
863 match self.update.values.get(self.index) {
864 Some(Some(text)) => FieldValue::Text(text),
865 Some(None) | None => FieldValue::Null,
866 }
867 }
868
869 /// Whether this update carried a token for the field
870 /// [`docs/spec/04-notifications.md` §2.5].
871 #[must_use]
872 #[inline]
873 pub fn is_changed(&self) -> bool {
874 self.update.changed.contains(&self.index)
875 }
876}
877
878impl fmt::Debug for Field<'_> {
879 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
880 f.debug_struct("Field")
881 .field("position", &self.position())
882 .field("name", &self.name())
883 .field("value", &self.value())
884 .field("changed", &self.is_changed())
885 .finish()
886 }
887}
888
889/// Iterator over every field of an [`ItemUpdate`]; see
890/// [`ItemUpdate::fields`].
891#[derive(Clone)]
892pub struct Fields<'a> {
893 update: &'a ItemUpdate,
894 positions: Range<usize>,
895}
896
897impl<'a> Iterator for Fields<'a> {
898 type Item = Field<'a>;
899
900 fn next(&mut self) -> Option<Self::Item> {
901 let index = self.positions.next()?;
902 Some(Field {
903 update: self.update,
904 index,
905 })
906 }
907
908 fn size_hint(&self) -> (usize, Option<usize>) {
909 self.positions.size_hint()
910 }
911}
912
913impl ExactSizeIterator for Fields<'_> {}
914
915impl fmt::Debug for Fields<'_> {
916 /// Renders as a map from field name to value, which is what makes
917 /// `println!("{:?}", update.fields())` readable.
918 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
919 f.debug_map()
920 .entries(self.clone().map(|field| (field.name(), field.value())))
921 .finish()
922 }
923}
924
925/// Iterator over the fields an [`ItemUpdate`] changed; see
926/// [`ItemUpdate::changed_fields`].
927#[derive(Clone)]
928pub struct ChangedFields<'a> {
929 update: &'a ItemUpdate,
930 positions: std::slice::Iter<'a, usize>,
931}
932
933impl<'a> Iterator for ChangedFields<'a> {
934 type Item = Field<'a>;
935
936 fn next(&mut self) -> Option<Self::Item> {
937 let index = *self.positions.next()?;
938 Some(Field {
939 update: self.update,
940 index,
941 })
942 }
943
944 fn size_hint(&self) -> (usize, Option<usize>) {
945 self.positions.size_hint()
946 }
947}
948
949impl ExactSizeIterator for ChangedFields<'_> {}
950
951impl fmt::Debug for ChangedFields<'_> {
952 /// Renders as a map from field name to value, so the ADR-0003 usage
953 /// `println!("{}: {:?}", update.item_name(), update.changed_fields())`
954 /// prints something a human can read.
955 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956 f.debug_map()
957 .entries(self.clone().map(|field| (field.name(), field.value())))
958 .finish()
959 }
960}
961
962// ---------------------------------------------------------------------------
963// Tests
964// ---------------------------------------------------------------------------
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 fn names(values: &[&str]) -> Vec<String> {
971 values.iter().map(|value| (*value).to_owned()).collect()
972 }
973
974 fn quote_schema() -> Arc<SubscriptionSchema> {
975 Arc::new(SubscriptionSchema::new(
976 2,
977 3,
978 &names(&["item1", "item2"]),
979 &names(&["last_price", "time", "status"]),
980 None,
981 ))
982 }
983
984 fn update_with(values: Vec<Option<String>>, changed: Vec<usize>) -> ItemUpdate {
985 ItemUpdate::new(quote_schema(), 0, UpdateKind::RealTime, values, changed)
986 }
987
988 // -- Null vs empty -------------------------------------------------------
989
990 #[test]
991 fn test_field_value_null_and_empty_text_are_distinct() {
992 // `#` is null and `$` is the empty string
993 // [`docs/spec/04-notifications.md` §2.2].
994 assert_ne!(FieldValue::Null, FieldValue::Text(""));
995 assert!(FieldValue::Null.is_null());
996 assert!(!FieldValue::Null.is_empty_text());
997 assert!(FieldValue::Text("").is_empty_text());
998 assert!(!FieldValue::Text("").is_null());
999 assert_eq!(FieldValue::Null.text(), None);
1000 assert_eq!(FieldValue::Text("").text(), Some(""));
1001 }
1002
1003 #[test]
1004 fn test_field_value_text_or_substitutes_only_for_null() {
1005 assert_eq!(FieldValue::Null.text_or("-"), "-");
1006 assert_eq!(FieldValue::Text("").text_or("-"), "");
1007 }
1008
1009 #[test]
1010 fn test_null_and_empty_reach_the_caller_distinctly() {
1011 let update = update_with(
1012 vec![None, Some(String::new()), Some("live".to_owned())],
1013 vec![0, 1, 2],
1014 );
1015 assert_eq!(update.field(1), Some(FieldValue::Null));
1016 assert_eq!(update.field(2), Some(FieldValue::Text("")));
1017 assert_eq!(update.field_by_name("last_price"), Some(FieldValue::Null));
1018 assert_eq!(update.field_by_name("time"), Some(FieldValue::Text("")));
1019 assert_ne!(update.field(1), update.field(2));
1020 }
1021
1022 #[test]
1023 fn test_every_rendering_of_a_field_value_keeps_null_distinguishable() {
1024 // There is no `Display`: rendering goes through `text_or`, which makes
1025 // the substitute for a null explicit and therefore visible in a log.
1026 assert_eq!(FieldValue::Null.text_or("(null)"), "(null)");
1027 assert_eq!(FieldValue::Text("").text_or("(null)"), "");
1028 assert_eq!(FieldValue::Text("x").text_or("(null)"), "x");
1029 // `Debug` — what the iterators print — never collapses them either.
1030 assert_eq!(format!("{:?}", FieldValue::Null), "Null");
1031 assert_eq!(format!("{:?}", FieldValue::Text("")), r#"Text("")"#);
1032 }
1033
1034 // -- Positions and names -------------------------------------------------
1035
1036 #[test]
1037 fn test_positions_are_one_based() {
1038 let update = update_with(vec![Some("a".to_owned()), None, None], vec![0]);
1039 // Items and fields are numbered from 1
1040 // [`docs/spec/04-notifications.md` §2.1].
1041 assert_eq!(update.item_index(), 1);
1042 assert_eq!(update.field(1), Some(FieldValue::Text("a")));
1043 assert_eq!(update.field(0), None);
1044 assert_eq!(update.field(4), None);
1045 assert_eq!(update.field_name(1), Some("last_price"));
1046 assert_eq!(update.field_name(0), None);
1047 }
1048
1049 #[test]
1050 fn test_names_come_from_the_caller() {
1051 let update = update_with(vec![None, None, None], vec![]);
1052 assert_eq!(update.item_name(), "item1");
1053 assert_eq!(update.declared_item_name(), Some("item1"));
1054 assert_eq!(update.field_position("status"), Some(3));
1055 }
1056
1057 #[test]
1058 fn test_undeclared_names_fall_back_to_the_position() {
1059 // `SUBOK` reports counts only [`docs/spec/04-notifications.md` §3.1].
1060 let schema = Arc::new(SubscriptionSchema::new(1, 2, &[], &[], None));
1061 let update = ItemUpdate::new(
1062 schema,
1063 0,
1064 UpdateKind::RealTime,
1065 vec![Some("a".to_owned()), None],
1066 vec![0],
1067 );
1068 assert_eq!(update.item_name(), "1");
1069 assert_eq!(update.declared_item_name(), None);
1070 assert_eq!(update.field_name(2), Some("2"));
1071 // The stand-in is not a lookup key.
1072 assert_eq!(update.field_by_name("2"), None);
1073 assert_eq!(update.field_position("1"), None);
1074 }
1075
1076 #[test]
1077 fn test_partially_declared_names_are_taken_positionally() {
1078 let schema = Arc::new(SubscriptionSchema::new(
1079 1,
1080 3,
1081 &names(&["only_item"]),
1082 &names(&["first"]),
1083 None,
1084 ));
1085 let update = ItemUpdate::new(schema, 0, UpdateKind::RealTime, vec![None; 3], vec![]);
1086 assert_eq!(update.field_name(1), Some("first"));
1087 assert_eq!(update.field_name(2), Some("2"));
1088 assert_eq!(update.field_position("first"), Some(1));
1089 }
1090
1091 #[test]
1092 fn test_unknown_field_name_is_none_everywhere() {
1093 let update = update_with(vec![Some("a".to_owned()), None, None], vec![0]);
1094 assert_eq!(update.field_by_name("no_such_field"), None);
1095 assert_eq!(update.field_position("no_such_field"), None);
1096 assert!(!update.is_field_changed_by_name("no_such_field"));
1097 }
1098
1099 #[test]
1100 fn test_duplicate_declared_names_resolve_to_the_first() {
1101 let schema = Arc::new(SubscriptionSchema::new(
1102 1,
1103 2,
1104 &names(&["i"]),
1105 &names(&["dup", "dup"]),
1106 None,
1107 ));
1108 let update = ItemUpdate::new(
1109 schema,
1110 0,
1111 UpdateKind::RealTime,
1112 vec![Some("first".to_owned()), Some("second".to_owned())],
1113 vec![0, 1],
1114 );
1115 assert_eq!(update.field_position("dup"), Some(1));
1116 assert_eq!(update.field_by_name("dup"), Some(FieldValue::Text("first")));
1117 }
1118
1119 // -- Changed fields ------------------------------------------------------
1120
1121 #[test]
1122 fn test_changed_fields_reports_only_carried_tokens() {
1123 let update = update_with(
1124 vec![
1125 Some("3.04".to_owned()),
1126 Some("20:00:33".to_owned()),
1127 Some(String::new()),
1128 ],
1129 vec![0, 2],
1130 );
1131 let changed: Vec<(usize, &str)> = update
1132 .changed_fields()
1133 .map(|field| (field.position(), field.name()))
1134 .collect();
1135 assert_eq!(changed, vec![(1, "last_price"), (3, "status")]);
1136 assert_eq!(update.changed_count(), 2);
1137 assert!(update.is_field_changed(1));
1138 assert!(!update.is_field_changed(2));
1139 assert!(!update.is_field_changed(0));
1140 assert!(update.is_field_changed_by_name("status"));
1141 assert!(!update.is_field_changed_by_name("time"));
1142 }
1143
1144 #[test]
1145 fn test_fields_iterates_the_whole_schema_with_changed_flags() {
1146 let update = update_with(
1147 vec![Some("a".to_owned()), None, Some(String::new())],
1148 vec![0, 2],
1149 );
1150 let seen: Vec<(usize, bool)> = update
1151 .fields()
1152 .map(|field| (field.position(), field.is_changed()))
1153 .collect();
1154 assert_eq!(seen, vec![(1, true), (2, false), (3, true)]);
1155 assert_eq!(update.fields().len(), 3);
1156 assert_eq!(update.changed_fields().len(), 2);
1157 }
1158
1159 #[test]
1160 fn test_changed_fields_debug_is_a_readable_map() {
1161 // The ADR-0003 usage prints this with `{:?}`.
1162 let update = update_with(
1163 vec![Some("3.04".to_owned()), None, Some(String::new())],
1164 vec![0, 1],
1165 );
1166 let rendered = format!("{:?}", update.changed_fields());
1167 assert!(rendered.contains("last_price"), "{rendered}");
1168 assert!(rendered.contains("Text(\"3.04\")"), "{rendered}");
1169 assert!(rendered.contains("Null"), "{rendered}");
1170 }
1171
1172 #[test]
1173 fn test_adr_0003_usage_compiles_and_prints() {
1174 // `docs/adr/0003-typed-event-stream-as-delivery-surface.md` pins this
1175 // exact pair of accessors.
1176 let update = update_with(vec![Some("3.04".to_owned()), None, None], vec![0]);
1177 let line = format!("{}: {:?}", update.item_name(), update.changed_fields());
1178 assert!(line.starts_with("item1: "), "{line}");
1179 }
1180
1181 // -- COMMAND mode --------------------------------------------------------
1182
1183 fn command_update(key: &str, command: &str) -> ItemUpdate {
1184 // `SUBCMD,3,1,3,1,2` places the key first and the command second
1185 // [`docs/spec/04-notifications.md` §3.2].
1186 let schema = Arc::new(SubscriptionSchema::new(
1187 1,
1188 3,
1189 &names(&["portfolio"]),
1190 &names(&["key", "command", "qty"]),
1191 Some((0, 1)),
1192 ));
1193 ItemUpdate::new(
1194 schema,
1195 0,
1196 UpdateKind::RealTime,
1197 vec![
1198 Some(key.to_owned()),
1199 Some(command.to_owned()),
1200 Some("10".to_owned()),
1201 ],
1202 vec![0, 1, 2],
1203 )
1204 }
1205
1206 #[test]
1207 fn test_command_mode_exposes_key_and_command() {
1208 let update = command_update("AAPL", "ADD");
1209 assert!(update.is_command_mode());
1210 assert_eq!(update.key(), Some(FieldValue::Text("AAPL")));
1211 assert_eq!(update.command(), Some(ItemCommand::Add));
1212 }
1213
1214 #[test]
1215 fn test_command_literals_are_case_insensitive() {
1216 // SPEC-AMBIGUITY (command-literals): casing is unspecified
1217 // [`docs/spec/04-notifications.md` §3.3].
1218 assert_eq!(command_update("k", "add").command(), Some(ItemCommand::Add));
1219 assert_eq!(
1220 command_update("k", "Update").command(),
1221 Some(ItemCommand::Update)
1222 );
1223 assert_eq!(
1224 command_update("k", "DELETE").command(),
1225 Some(ItemCommand::Delete)
1226 );
1227 }
1228
1229 #[test]
1230 fn test_unrecognized_command_is_preserved_not_rejected() {
1231 let update = command_update("k", "MERGE_ROW");
1232 assert_eq!(
1233 update.command(),
1234 Some(ItemCommand::Unrecognized("MERGE_ROW".to_owned()))
1235 );
1236 assert_eq!(
1237 update.command().as_ref().map(ItemCommand::as_str),
1238 Some("MERGE_ROW")
1239 );
1240 }
1241
1242 #[test]
1243 fn test_command_names_render_uppercase() {
1244 assert_eq!(ItemCommand::Add.to_string(), "ADD");
1245 assert_eq!(ItemCommand::Update.as_str(), "UPDATE");
1246 assert_eq!(ItemCommand::Delete.as_str(), "DELETE");
1247 }
1248
1249 #[test]
1250 fn test_non_command_subscription_has_no_key_or_command() {
1251 let update = update_with(vec![Some("a".to_owned()), None, None], vec![0]);
1252 assert!(!update.is_command_mode());
1253 assert_eq!(update.key(), None);
1254 assert_eq!(update.command(), None);
1255 assert!(!update.is_key_changed());
1256 assert!(!update.is_command_changed());
1257 }
1258
1259 #[test]
1260 fn test_a_retained_command_is_reported_as_not_changed() {
1261 // `SUBCMD,3,1,3,1,2`: key first, command second
1262 // [`docs/spec/04-notifications.md` §3.2]. This update carried only the
1263 // third field, so the key and command values it exposes are the ones an
1264 // earlier update left behind [§2.2].
1265 let schema = Arc::new(SubscriptionSchema::new(
1266 1,
1267 3,
1268 &names(&["portfolio"]),
1269 &names(&["key", "command", "qty"]),
1270 Some((0, 1)),
1271 ));
1272 let update = ItemUpdate::new(
1273 schema,
1274 0,
1275 UpdateKind::RealTime,
1276 vec![
1277 Some("AAPL".to_owned()),
1278 Some("DELETE".to_owned()),
1279 Some("10".to_owned()),
1280 ],
1281 vec![2],
1282 );
1283
1284 // The field values are what the decoding algorithm produced...
1285 assert_eq!(update.key(), Some(FieldValue::Text("AAPL")));
1286 assert_eq!(update.command(), Some(ItemCommand::Delete));
1287 // ...and this is the evidence that says they are not this update's
1288 // doing, which is what a caller must check before deleting a row.
1289 assert!(!update.is_key_changed());
1290 assert!(!update.is_command_changed());
1291 }
1292
1293 #[test]
1294 fn test_a_carried_command_is_reported_as_changed() {
1295 let update = command_update("AAPL", "DELETE");
1296 assert!(update.is_key_changed());
1297 assert!(update.is_command_changed());
1298 }
1299
1300 #[test]
1301 fn test_null_key_is_reported_as_null_not_missing() {
1302 let schema = Arc::new(SubscriptionSchema::new(
1303 1,
1304 2,
1305 &[],
1306 &names(&["key", "command"]),
1307 Some((0, 1)),
1308 ));
1309 let update = ItemUpdate::new(
1310 schema,
1311 0,
1312 UpdateKind::RealTime,
1313 vec![None, None],
1314 vec![0, 1],
1315 );
1316 // The key field is an ordinary field and may be null; that is different
1317 // from the subscription having no key field at all.
1318 assert_eq!(update.key(), Some(FieldValue::Null));
1319 // A null command names no operation.
1320 assert_eq!(update.command(), None);
1321 }
1322
1323 // -- Snapshot classification --------------------------------------------
1324
1325 #[test]
1326 fn test_update_kind_accessors() {
1327 let schema = quote_schema();
1328 let snapshot = ItemUpdate::new(
1329 Arc::clone(&schema),
1330 0,
1331 UpdateKind::Snapshot,
1332 vec![None; 3],
1333 vec![],
1334 );
1335 let live = ItemUpdate::new(schema, 1, UpdateKind::RealTime, vec![None; 3], vec![]);
1336 assert!(snapshot.is_snapshot());
1337 assert_eq!(snapshot.kind(), UpdateKind::Snapshot);
1338 assert!(!live.is_snapshot());
1339 assert_eq!(live.item_index(), 2);
1340 assert_eq!(live.item_name(), "item2");
1341 }
1342}