Skip to main content

sqlite_diff_rs/
parser.rs

1//! Parser for `SQLite` changeset/patchset binary format.
2//!
3//! Parses `SQLite` session extension changesets and patchsets from binary into
4//! [`DiffSetBuilder`] instances.
5//!
6//! # Binary Format
7//!
8//! The format consists of one or more table sections:
9//!
10//! ```text
11//! Table Header:
12//! ├── Marker: 'T' (0x54) for changeset, 'P' (0x50) for patchset
13//! ├── Column count (1 byte)
14//! ├── PK flags (1 byte per column: 0x01 = PK, 0x00 = not)
15//! └── Table name (null-terminated UTF-8)
16//!
17//! Change Records (repeated):
18//! ├── Operation code: INSERT=0x12, DELETE=0x09, UPDATE=0x17
19//! ├── Indirect flag (1 byte, usually 0)
20//! └── Values (encoded per operation type)
21//! ```
22//!
23
24use alloc::string::String;
25use alloc::vec;
26use alloc::vec::Vec;
27use core::hash::Hash;
28
29use crate::IndexableValues;
30
31/// Type alias for update operation values.
32type UpdateValues = Vec<(MaybeValue<String, Vec<u8>>, MaybeValue<String, Vec<u8>>)>;
33
34/// Type alias for parsed values result.
35type ParsedValues = (Vec<MaybeValue<String, Vec<u8>>>, usize);
36use crate::builders::{ChangesetFormat, DiffSet, DiffSetBuilder, Operation, PatchsetFormat};
37use crate::encoding::{MaybeValue, Value, decode_value, markers, op_codes};
38use crate::schema::{DynTable, SchemaWithPK};
39
40/// Errors that can occur during parsing.
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42pub enum ParseError {
43    /// Unexpected end of input.
44    #[error("Unexpected end of input at position {0}")]
45    UnexpectedEof(usize),
46
47    /// Invalid table marker (expected 'T' or 'P').
48    #[error("Invalid table marker 0x{0:02x} at position {1}")]
49    InvalidTableMarker(u8, usize),
50
51    /// Invalid operation code.
52    #[error("Invalid operation code 0x{0:02x} at position {1}")]
53    InvalidOpCode(u8, usize),
54
55    /// Invalid UTF-8 in table name.
56    #[error("Invalid UTF-8 in table name at position {0}")]
57    InvalidTableName(usize),
58
59    /// Failed to decode a value.
60    #[error("Failed to decode value at position {0}")]
61    InvalidValue(usize),
62
63    /// Table name not null-terminated.
64    #[error("Table name not null-terminated")]
65    UnterminatedTableName,
66
67    /// Mixed format markers in the same file.
68    #[error("Mixed format markers: expected {expected:?}, found {found:?} at position {position}")]
69    MixedFormats {
70        /// The expected format marker.
71        expected: FormatMarker,
72        /// The found format marker.
73        found: FormatMarker,
74        /// The position where the mismatch occurred.
75        position: usize,
76    },
77}
78
79/// The detected format marker.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum FormatMarker {
82    /// Changeset format ('T' marker).
83    Changeset,
84    /// Patchset format ('P' marker).
85    Patchset,
86}
87
88/// A table schema parsed from binary changeset/patchset data.
89///
90/// This type implements [`DynTable`] and [`SchemaWithPK`], allowing it
91/// to be used with [`DiffSetBuilder`].
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93pub struct TableSchema<S> {
94    /// The table name.
95    name: S,
96    /// Number of columns.
97    column_count: usize,
98    /// Primary key flags - raw bytes from the changeset/patchset.
99    ///
100    /// Each byte represents the 1-based ordinal position in the composite PK,
101    /// or 0 if the column is not part of the primary key.
102    /// For example, `[1, 0, 2]` means column 0 is the first PK column,
103    /// column 1 is not a PK column, and column 2 is the second PK column.
104    pk_flags: Vec<u8>,
105}
106
107impl<S> TableSchema<S> {
108    /// Create a new parsed table schema.
109    #[inline]
110    #[must_use]
111    pub fn new(name: S, column_count: usize, pk_flags: Vec<u8>) -> Self {
112        debug_assert_eq!(pk_flags.len(), column_count);
113        Self {
114            name,
115            column_count,
116            pk_flags,
117        }
118    }
119
120    /// Returns the name of the table.
121    #[inline]
122    #[must_use]
123    pub fn name(&self) -> &S {
124        &self.name
125    }
126
127    /// Returns the raw primary-key flags. Each byte at index `i`
128    /// represents column `i`: `0` means the column is not part of the
129    /// primary key, and a non-zero value `k` means it is the `k`-th
130    /// column in the composite primary key.
131    #[inline]
132    #[must_use]
133    pub fn pk_flags(&self) -> &[u8] {
134        &self.pk_flags
135    }
136
137    /// Get the indices of primary key columns, in PK order.
138    #[must_use]
139    pub(crate) fn pk_indices(&self) -> Vec<usize> {
140        // Collect (col_idx, pk_ordinal) pairs for non-zero entries
141        let mut pk_cols: Vec<(usize, u8)> = self
142            .pk_flags
143            .iter()
144            .enumerate()
145            .filter_map(|(i, &pk_ordinal)| {
146                if pk_ordinal > 0 {
147                    Some((i, pk_ordinal))
148                } else {
149                    None
150                }
151            })
152            .collect();
153        // Sort by pk_ordinal to get correct PK order
154        pk_cols.sort_by_key(|(_, ordinal)| *ordinal);
155        pk_cols.into_iter().map(|(idx, _)| idx).collect()
156    }
157}
158
159impl<S: AsRef<str> + Clone + Eq + core::fmt::Debug> DynTable for TableSchema<S> {
160    #[inline]
161    fn name(&self) -> &str {
162        self.name.as_ref()
163    }
164
165    #[inline]
166    fn number_of_columns(&self) -> usize {
167        self.column_count
168    }
169
170    #[inline]
171    fn write_pk_flags(&self, buf: &mut [u8]) {
172        assert_eq!(buf.len(), self.column_count);
173        buf.copy_from_slice(&self.pk_flags);
174    }
175}
176
177impl<N: AsRef<str> + Clone + core::hash::Hash + Eq + core::fmt::Debug> SchemaWithPK
178    for TableSchema<N>
179{
180    fn number_of_primary_keys(&self) -> usize {
181        self.pk_flags.iter().filter(|&&b| b > 0).count()
182    }
183
184    fn primary_key_index(&self, col_idx: usize) -> Option<usize> {
185        self.pk_flags.get(col_idx).and_then(|&pk_ordinal| {
186            if pk_ordinal > 0 {
187                Some(usize::from(pk_ordinal - 1))
188            } else {
189                None
190            }
191        })
192    }
193
194    fn extract_pk<S, B>(
195        &self,
196        values: &impl IndexableValues<Text = S, Binary = B>,
197    ) -> alloc::vec::Vec<Value<S, B>>
198    where
199        S: Clone,
200        B: Clone,
201    {
202        self.pk_indices()
203            .into_iter()
204            .map(|i| {
205                values
206                    .get(i)
207                    .expect("primary key column index out of bounds, values shorter than schema")
208            })
209            .collect()
210    }
211}
212
213/// A parsed changeset or patchset.
214///
215/// This represents a frozen (immutable) diffset produced by the binary parser.
216/// To modify it, convert it to a [`DiffSetBuilder`] using `Into::into`.
217#[derive(Debug, Clone, Eq)]
218pub enum ParsedDiffSet {
219    /// A parsed changeset.
220    Changeset(DiffSet<ChangesetFormat, TableSchema<String>, String, Vec<u8>>),
221    /// A parsed patchset.
222    Patchset(DiffSet<PatchsetFormat, TableSchema<String>, String, Vec<u8>>),
223}
224
225impl PartialEq for ParsedDiffSet {
226    fn eq(&self, other: &Self) -> bool {
227        let self_empty = match self {
228            ParsedDiffSet::Changeset(d) => d.is_empty(),
229            ParsedDiffSet::Patchset(d) => d.is_empty(),
230        };
231        let other_empty = match other {
232            ParsedDiffSet::Changeset(d) => d.is_empty(),
233            ParsedDiffSet::Patchset(d) => d.is_empty(),
234        };
235
236        if self_empty && other_empty {
237            return true;
238        }
239
240        // Otherwise compare by variant and content
241        match (self, other) {
242            (ParsedDiffSet::Changeset(a), ParsedDiffSet::Changeset(b)) => a == b,
243            (ParsedDiffSet::Patchset(a), ParsedDiffSet::Patchset(b)) => a == b,
244            _ => false,
245        }
246    }
247}
248
249impl TryFrom<&[u8]> for ParsedDiffSet {
250    type Error = ParseError;
251
252    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
253        Self::parse(data)
254    }
255}
256
257impl From<ParsedDiffSet> for Vec<u8> {
258    fn from(diffset: ParsedDiffSet) -> Self {
259        match diffset {
260            ParsedDiffSet::Changeset(d) => d.into(),
261            ParsedDiffSet::Patchset(d) => d.into(),
262        }
263    }
264}
265
266impl ParsedDiffSet {
267    /// Parse binary data into a frozen [`DiffSet`].
268    ///
269    /// The format (changeset vs patchset) is determined by the first table marker.
270    ///
271    /// # Errors
272    ///
273    /// Returns a `ParseError` if the data is malformed or contains invalid values.
274    pub fn parse(data: &[u8]) -> Result<Self, ParseError> {
275        if data.is_empty() {
276            // Empty data defaults to changeset
277            return Ok(ParsedDiffSet::Changeset(DiffSet::default()));
278        }
279
280        // Peek at the first byte to determine format
281        match data[0] {
282            markers::CHANGESET => {
283                let diffset = parse_as_changeset(data)?;
284                Ok(ParsedDiffSet::Changeset(diffset))
285            }
286            markers::PATCHSET => {
287                let diffset = parse_as_patchset(data)?;
288                Ok(ParsedDiffSet::Patchset(diffset))
289            }
290            b => Err(ParseError::InvalidTableMarker(b, 0)),
291        }
292    }
293
294    /// Returns true if this is a changeset.
295    #[must_use]
296    pub fn is_changeset(&self) -> bool {
297        matches!(self, ParsedDiffSet::Changeset(_))
298    }
299
300    /// Returns true if this is a patchset.
301    #[must_use]
302    pub fn is_patchset(&self) -> bool {
303        matches!(self, ParsedDiffSet::Patchset(_))
304    }
305
306    /// Returns the table schemas for all tables with non-empty operations.
307    #[must_use]
308    pub fn table_schemas(&self) -> Vec<&TableSchema<String>> {
309        match self {
310            ParsedDiffSet::Changeset(d) => d
311                .tables
312                .iter()
313                .filter(|(_, ops)| !ops.is_empty())
314                .map(|(schema, _)| schema)
315                .collect(),
316            ParsedDiffSet::Patchset(d) => d
317                .tables
318                .iter()
319                .filter(|(_, ops)| !ops.is_empty())
320                .map(|(schema, _)| schema)
321                .collect(),
322        }
323    }
324}
325
326/// Parse binary data as a changeset.
327///
328/// # Errors
329///
330/// Returns a `ParseError` if the data is malformed or not a valid changeset.
331fn parse_as_changeset(
332    data: &[u8],
333) -> Result<DiffSet<ChangesetFormat, TableSchema<String>, String, Vec<u8>>, ParseError> {
334    let mut builder: DiffSetBuilder<ChangesetFormat, TableSchema<String>, String, Vec<u8>> =
335        DiffSetBuilder::new();
336    let mut pos = 0;
337
338    while pos < data.len() {
339        let (schema, format, header_len) = parse_table_header(&data[pos..], pos)?;
340        if format != FormatMarker::Changeset {
341            return Err(ParseError::MixedFormats {
342                expected: FormatMarker::Changeset,
343                found: format,
344                position: pos,
345            });
346        }
347        pos += header_len;
348
349        while pos < data.len() {
350            let byte = data[pos];
351            if byte == markers::CHANGESET || byte == markers::PATCHSET {
352                break;
353            }
354            let op_len = parse_changeset_operation(&data[pos..], pos, &schema, &mut builder)?;
355            pos += op_len;
356        }
357    }
358
359    Ok(builder.into())
360}
361
362/// Parse binary data as a patchset.
363///
364/// # Errors
365///
366/// Returns a `ParseError` if the data is malformed or not a valid patchset.
367fn parse_as_patchset(
368    data: &[u8],
369) -> Result<DiffSet<PatchsetFormat, TableSchema<String>, String, Vec<u8>>, ParseError> {
370    let mut builder: DiffSetBuilder<PatchsetFormat, TableSchema<String>, String, Vec<u8>> =
371        DiffSetBuilder::new();
372    let mut pos = 0;
373
374    while pos < data.len() {
375        let (schema, format, header_len) = parse_table_header(&data[pos..], pos)?;
376        if format != FormatMarker::Patchset {
377            return Err(ParseError::MixedFormats {
378                expected: FormatMarker::Patchset,
379                found: format,
380                position: pos,
381            });
382        }
383        pos += header_len;
384
385        while pos < data.len() {
386            let byte = data[pos];
387            if byte == markers::CHANGESET || byte == markers::PATCHSET {
388                break;
389            }
390            let op_len = parse_patchset_operation(&data[pos..], pos, &schema, &mut builder)?;
391            pos += op_len;
392        }
393    }
394
395    Ok(builder.into())
396}
397
398/// Parse a table header and return the schema.
399fn parse_table_header(
400    data: &[u8],
401    base_pos: usize,
402) -> Result<(TableSchema<String>, FormatMarker, usize), ParseError> {
403    let mut pos = 0;
404
405    if data.is_empty() {
406        return Err(ParseError::UnexpectedEof(base_pos));
407    }
408    let format = match data[pos] {
409        markers::CHANGESET => FormatMarker::Changeset,
410        markers::PATCHSET => FormatMarker::Patchset,
411        b => return Err(ParseError::InvalidTableMarker(b, base_pos + pos)),
412    };
413    pos += 1;
414
415    if pos >= data.len() {
416        return Err(ParseError::UnexpectedEof(base_pos + pos));
417    }
418    let column_count = data[pos] as usize;
419    pos += 1;
420
421    if pos + column_count > data.len() {
422        return Err(ParseError::UnexpectedEof(base_pos + pos));
423    }
424    let pk_flags: Vec<u8> = data[pos..pos + column_count].to_vec();
425    pos += column_count;
426
427    let name_start = pos;
428    while pos < data.len() && data[pos] != 0 {
429        pos += 1;
430    }
431    if pos >= data.len() {
432        return Err(ParseError::UnterminatedTableName);
433    }
434    let name = String::from_utf8(data[name_start..pos].to_vec())
435        .map_err(|_| ParseError::InvalidTableName(base_pos + name_start))?;
436    pos += 1;
437
438    Ok((TableSchema::new(name, column_count, pk_flags), format, pos))
439}
440
441/// Parse operation header (`op_code` + indirect flag).
442///
443/// Returns `(op_code, indirect, bytes_consumed)`. Any non-zero indirect byte
444/// parses as `true` to match SQLite's permissive treatment of the flag.
445fn parse_operation_header(data: &[u8], base_pos: usize) -> Result<(u8, bool, usize), ParseError> {
446    if data.len() < 2 {
447        return Err(ParseError::UnexpectedEof(base_pos));
448    }
449    Ok((data[0], data[1] != 0, 2))
450}
451
452/// Parse a changeset operation.
453fn parse_changeset_operation(
454    data: &[u8],
455    base_pos: usize,
456    schema: &TableSchema<String>,
457    builder: &mut DiffSetBuilder<ChangesetFormat, TableSchema<String>, String, Vec<u8>>,
458) -> Result<usize, ParseError> {
459    let (op_code, indirect, mut pos) = parse_operation_header(data, base_pos)?;
460
461    match op_code {
462        op_codes::INSERT => {
463            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
464            pos += len;
465            let values: Vec<Value<String, Vec<u8>>> = values
466                .into_iter()
467                .map(|v| v.unwrap_or(Value::Null))
468                .collect();
469            let pk = schema.extract_pk(&values);
470            builder.add_operation(schema, pk, Operation::Insert { values, indirect });
471        }
472        op_codes::DELETE => {
473            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
474            pos += len;
475            let values: Vec<Value<String, Vec<u8>>> = values
476                .into_iter()
477                .map(|v| v.unwrap_or(Value::Null))
478                .collect();
479            let pk = schema.extract_pk(&values);
480            builder.add_operation(
481                schema,
482                pk,
483                Operation::Delete {
484                    data: values,
485                    indirect,
486                },
487            );
488        }
489        op_codes::UPDATE => {
490            let (old_values, old_len) =
491                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
492            pos += old_len;
493            let (new_values, new_len) =
494                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
495            pos += new_len;
496            // Extract PK using old values (convert None to Null)
497            let pk_values: Vec<Value<String, Vec<u8>>> = old_values
498                .iter()
499                .map(|v| v.clone().unwrap_or(Value::Null))
500                .collect();
501            let pk = schema.extract_pk(&pk_values);
502            let values: UpdateValues = old_values.into_iter().zip(new_values).collect();
503            builder.add_operation(schema, pk, Operation::Update { values, indirect });
504        }
505        _ => return Err(ParseError::InvalidOpCode(op_code, base_pos)),
506    }
507
508    Ok(pos)
509}
510
511/// Parse a patchset operation.
512fn parse_patchset_operation(
513    data: &[u8],
514    base_pos: usize,
515    schema: &TableSchema<String>,
516    builder: &mut DiffSetBuilder<PatchsetFormat, TableSchema<String>, String, Vec<u8>>,
517) -> Result<usize, ParseError> {
518    let (op_code, indirect, mut pos) = parse_operation_header(data, base_pos)?;
519
520    match op_code {
521        op_codes::INSERT => {
522            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
523            pos += len;
524            let values: Vec<Value<String, Vec<u8>>> = values
525                .into_iter()
526                .map(|v| v.unwrap_or(Value::Null))
527                .collect();
528            let pk = schema.extract_pk(&values);
529            builder.add_operation(schema, pk, Operation::Insert { values, indirect });
530        }
531        op_codes::DELETE => {
532            // Patchset DELETE: only PK values in column order
533            let pk_count = schema.pk_flags.iter().filter(|&&b| b > 0).count();
534            let (pk_values, len) = parse_values(&data[pos..], base_pos + pos, pk_count)?;
535            pos += len;
536            // Expand PK values to full row, then extract_pk to get ordinal-sorted PK.
537            // This is needed because the binary format stores PKs in column order,
538            // but the builder stores them sorted by pk_ordinal (matching the serializer).
539            let full_values = expand_pk_values(&schema.pk_flags, pk_values, schema.column_count);
540            // Convert MaybeValue to Value for extract_pk (PK values should always be defined)
541            let full_values_concrete: Vec<Value<String, Vec<u8>>> = full_values
542                .into_iter()
543                .map(|v| v.unwrap_or(Value::Null))
544                .collect();
545            let pk = schema.extract_pk(&full_values_concrete);
546            builder.add_operation(schema, pk, Operation::Delete { data: (), indirect });
547        }
548        op_codes::UPDATE => {
549            // Patchset UPDATE wire layout (matching SQLite's session extension):
550            //   old side: PK-only values, in column order, exactly `pk_count` entries
551            //            (no padding / undefined markers for non-PK columns).
552            //   new side: non-PK columns, in column order, exactly
553            //            `column_count - pk_count` entries; each entry is either the
554            //            new column value or `0x00` (undefined) if that non-PK column
555            //            did not change.
556            //
557            // Internally we rehydrate the operation into a full-width
558            // `Vec<((), MaybeValue)>` of length `column_count` so downstream code
559            // (`extract_pk`, `sql_output`, consolidation, reversal) keeps working
560            // uniformly: PK slots hold `Some(pk_value)`, non-PK slots hold either
561            // `Some(new_value)` or `None` for undefined.
562            let pk_count = schema.pk_flags.iter().filter(|&&b| b > 0).count();
563            let non_pk_count = schema.column_count.saturating_sub(pk_count);
564
565            let (old_pk_values, old_len) = parse_values(&data[pos..], base_pos + pos, pk_count)?;
566            pos += old_len;
567            let (new_non_pk_values, new_len) =
568                parse_values(&data[pos..], base_pos + pos, non_pk_count)?;
569            pos += new_len;
570
571            let mut values: Vec<((), MaybeValue<String, Vec<u8>>)> =
572                alloc::vec![((), None); schema.column_count];
573            let mut old_iter = old_pk_values.into_iter();
574            let mut new_iter = new_non_pk_values.into_iter();
575            for (col_idx, &pk_flag) in schema.pk_flags.iter().enumerate() {
576                if pk_flag > 0 {
577                    // PK columns always carry a defined value on the old side; a
578                    // stray undefined marker is normalized to Null to stay lenient
579                    // for fuzz-generated inputs (matches `expand_pk_values` in the
580                    // DELETE path).
581                    let old = old_iter.next().flatten().unwrap_or(Value::Null);
582                    values[col_idx] = ((), Some(old));
583                } else {
584                    values[col_idx] = ((), new_iter.next().flatten());
585                }
586            }
587
588            let pk = schema.extract_pk(&values);
589            builder.add_operation(schema, pk, Operation::Update { values, indirect });
590        }
591        _ => return Err(ParseError::InvalidOpCode(op_code, base_pos)),
592    }
593
594    Ok(pos)
595}
596
597/// Expand PK-only values to full row with None (undefined) for non-PK columns.
598///
599/// The `pk_flags` are raw bytes where non-zero means the column is part of the PK.
600/// PK values are expected in the order they appear in `pk_flags` (not sorted by ordinal).
601fn expand_pk_values(
602    pk_flags: &[u8],
603    pk_values: Vec<MaybeValue<String, Vec<u8>>>,
604    column_count: usize,
605) -> Vec<MaybeValue<String, Vec<u8>>> {
606    let mut full: Vec<MaybeValue<String, Vec<u8>>> = vec![None; column_count];
607    let mut pk_iter = pk_values.into_iter();
608    for (i, &pk_ordinal) in pk_flags.iter().enumerate() {
609        if pk_ordinal > 0
610            && let Some(v) = pk_iter.next()
611        {
612            full[i] = v;
613        }
614    }
615    full
616}
617
618/// Parse a sequence of values.
619fn parse_values(data: &[u8], base_pos: usize, count: usize) -> Result<ParsedValues, ParseError> {
620    let mut values = Vec::with_capacity(count);
621    let mut pos = 0;
622
623    for _ in 0..count {
624        let (value, value_len) =
625            decode_value(&data[pos..]).ok_or(ParseError::InvalidValue(base_pos + pos))?;
626        values.push(value);
627        pos += value_len;
628    }
629
630    Ok((values, pos))
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use alloc::vec;
637
638    #[test]
639    fn test_parse_empty() {
640        let result = ParsedDiffSet::parse(&[]);
641        assert!(result.is_ok());
642        assert!(result.unwrap().is_changeset());
643    }
644
645    #[test]
646    fn test_parse_table_header() {
647        // 'T', 2 columns, pk_flags [1, 0], table name "t\0"
648        let data = [b'T', 2, 1, 0, b't', 0];
649        let (schema, format, len) = parse_table_header(&data, 0).unwrap();
650
651        assert_eq!(format, FormatMarker::Changeset);
652        assert_eq!(schema.column_count, 2);
653        assert_eq!(schema.pk_flags, vec![1, 0]); // Raw bytes: 1 = first PK column, 0 = not PK
654        assert_eq!(schema.name, "t");
655        assert_eq!(len, 6);
656    }
657
658    #[test]
659    fn test_parse_insert_changeset() {
660        // Table header + INSERT with integer 1 and text "a"
661        let mut data = vec![b'T', 2, 1, 0, b't', 0];
662        // INSERT opcode, indirect=0
663        data.push(op_codes::INSERT);
664        data.push(0);
665        // Integer 1 (type 1, 8 bytes)
666        data.push(0x01);
667        data.extend(&1i64.to_be_bytes());
668        // Text "a" (type 3, length 1, "a")
669        data.push(0x03);
670        data.push(1);
671        data.push(b'a');
672
673        let parsed = ParsedDiffSet::parse(&data).unwrap();
674        assert!(parsed.is_changeset());
675    }
676
677    #[test]
678    fn test_parse_delete_changeset() {
679        let mut data = vec![b'T', 2, 1, 0, b't', 0];
680        data.push(op_codes::DELETE);
681        data.push(0);
682        // Integer 1
683        data.push(0x01);
684        data.extend(&1i64.to_be_bytes());
685        // Text "a"
686        data.push(0x03);
687        data.push(1);
688        data.push(b'a');
689
690        let parsed = ParsedDiffSet::parse(&data).unwrap();
691        assert!(parsed.is_changeset());
692    }
693
694    #[test]
695    fn test_parse_delete_patchset() {
696        // Patchset DELETE only has PK values
697        let mut data = vec![b'P', 2, 1, 0, b't', 0];
698        data.push(op_codes::DELETE);
699        data.push(0);
700        // Only PK value (integer 1)
701        data.push(0x01);
702        data.extend(&1i64.to_be_bytes());
703
704        let parsed = ParsedDiffSet::parse(&data).unwrap();
705        assert!(parsed.is_patchset());
706    }
707
708    #[test]
709    fn test_parse_update_changeset() {
710        let mut data = vec![b'T', 2, 1, 0, b't', 0];
711        data.push(op_codes::UPDATE);
712        data.push(0);
713        // Old values: integer 1, text "a"
714        data.push(0x01);
715        data.extend(&1i64.to_be_bytes());
716        data.push(0x03);
717        data.push(1);
718        data.push(b'a');
719        // New values: integer 1, text "b"
720        data.push(0x01);
721        data.extend(&1i64.to_be_bytes());
722        data.push(0x03);
723        data.push(1);
724        data.push(b'b');
725
726        let parsed = ParsedDiffSet::parse(&data).unwrap();
727        assert!(parsed.is_changeset());
728    }
729
730    #[test]
731    fn test_is_changeset() {
732        let data = vec![b'T', 1, 1, b't', 0];
733        let parsed = ParsedDiffSet::parse(&data).unwrap();
734        assert!(parsed.is_changeset());
735        assert!(!parsed.is_patchset());
736    }
737
738    #[test]
739    fn test_is_patchset() {
740        let data = vec![b'P', 1, 1, b't', 0];
741        let parsed = ParsedDiffSet::parse(&data).unwrap();
742        assert!(parsed.is_patchset());
743        assert!(!parsed.is_changeset());
744    }
745
746    #[test]
747    fn test_parsed_table_schema_dyn_table() {
748        let schema: TableSchema<String> = TableSchema::new("users".into(), 3, vec![1, 0, 0]);
749        assert_eq!(schema.name(), "users");
750        assert_eq!(schema.number_of_columns(), 3);
751
752        let mut buf = [0u8; 3];
753        schema.write_pk_flags(&mut buf);
754        assert_eq!(buf, [1, 0, 0]);
755    }
756
757    #[test]
758    fn test_parsed_table_schema_extract_pk() {
759        let schema: TableSchema<String> = TableSchema::new("users".into(), 3, vec![1, 0, 2]);
760        let values: Vec<Value<String, Vec<u8>>> = vec![
761            Value::Integer(1),
762            Value::Text("alice".into()),
763            Value::Integer(100),
764        ];
765        let pk = schema.extract_pk(&values);
766        let expected: Vec<Value<String, Vec<u8>>> = vec![Value::Integer(1), Value::Integer(100)];
767        assert_eq!(pk, expected);
768    }
769
770    // ---- Error path tests ----
771
772    #[test]
773    fn test_parse_invalid_table_marker() {
774        let data = [0xFFu8, 1, 1, b't', 0];
775        let err = ParsedDiffSet::parse(&data).unwrap_err();
776        assert!(
777            matches!(err, ParseError::InvalidTableMarker(0xFF, 0)),
778            "got {err:?}"
779        );
780    }
781
782    #[test]
783    fn test_parse_unexpected_eof_in_table_header() {
784        // 'T' marker but no column count
785        let data = *b"T";
786        let err = ParsedDiffSet::parse(&data).unwrap_err();
787        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
788    }
789
790    #[test]
791    fn test_parse_unexpected_eof_in_pk_flags() {
792        // 'T', column count 3, but only 1 PK flag byte
793        let data = [b'T', 3, 1];
794        let err = ParsedDiffSet::parse(&data).unwrap_err();
795        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
796    }
797
798    #[test]
799    fn test_parse_unterminated_table_name() {
800        // 'T', 1 column, pk_flags [1], then "abc" with no null terminator
801        let data = [b'T', 1, 1, b'a', b'b', b'c'];
802        let err = ParsedDiffSet::parse(&data).unwrap_err();
803        assert!(
804            matches!(err, ParseError::UnterminatedTableName),
805            "got {err:?}"
806        );
807    }
808
809    #[test]
810    fn test_parse_invalid_utf8_in_table_name() {
811        // 'T', 1 column, pk_flags [1], then 0xFF (invalid UTF-8), then null
812        let data = [b'T', 1, 1, 0xFF, 0];
813        let err = ParsedDiffSet::parse(&data).unwrap_err();
814        assert!(
815            matches!(err, ParseError::InvalidTableName(_)),
816            "got {err:?}"
817        );
818    }
819
820    #[test]
821    fn test_parse_mixed_formats_changeset_then_patchset() {
822        // First table 'T' (changeset), then second table 'P' (patchset)
823        let mut data = vec![b'T', 1, 1, b'a', 0];
824        // Now a 'P' table header without preceding operations
825        data.extend_from_slice(&[b'P', 1, 1, b'b', 0]);
826        let err = ParsedDiffSet::parse(&data).unwrap_err();
827        assert!(
828            matches!(
829                err,
830                ParseError::MixedFormats {
831                    expected: FormatMarker::Changeset,
832                    found: FormatMarker::Patchset,
833                    ..
834                }
835            ),
836            "got {err:?}"
837        );
838    }
839
840    #[test]
841    fn test_parse_mixed_formats_patchset_then_changeset() {
842        let mut data = vec![b'P', 1, 1, b'a', 0];
843        data.extend_from_slice(&[b'T', 1, 1, b'b', 0]);
844        let err = ParsedDiffSet::parse(&data).unwrap_err();
845        assert!(
846            matches!(
847                err,
848                ParseError::MixedFormats {
849                    expected: FormatMarker::Patchset,
850                    found: FormatMarker::Changeset,
851                    ..
852                }
853            ),
854            "got {err:?}"
855        );
856    }
857
858    /// Build the operation header bytes followed by a single integer payload.
859    fn make_insert_with_indirect(indirect_byte: u8) -> Vec<u8> {
860        let mut data = vec![b'T', 1, 1, b't', 0];
861        data.push(op_codes::INSERT);
862        data.push(indirect_byte);
863        // Integer 1
864        data.push(0x01);
865        data.extend(&1i64.to_be_bytes());
866        data
867    }
868
869    fn first_op_indirect_changeset(data: &[u8]) -> bool {
870        let parsed = ParsedDiffSet::parse(data).unwrap();
871        let ParsedDiffSet::Changeset(set) = parsed else {
872            panic!("expected Changeset");
873        };
874        set.tables
875            .iter()
876            .find_map(|(_schema, rows)| rows.first().map(|(_, op)| op.indirect()))
877            .expect("expected at least one op")
878    }
879
880    #[test]
881    fn test_parse_changeset_indirect_flag_set() {
882        let data = make_insert_with_indirect(1);
883        assert!(first_op_indirect_changeset(&data));
884    }
885
886    #[test]
887    fn test_parse_changeset_indirect_flag_clear() {
888        let data = make_insert_with_indirect(0);
889        assert!(!first_op_indirect_changeset(&data));
890    }
891
892    #[test]
893    fn test_parse_indirect_nonzero_treated_as_true() {
894        // Any non-zero byte must parse as indirect = true.
895        let data = make_insert_with_indirect(0x42);
896        assert!(first_op_indirect_changeset(&data));
897    }
898
899    #[test]
900    fn test_parsed_diffset_variant_mismatch_partial_eq() {
901        let changeset = ParsedDiffSet::parse(&[b'T', 1, 1, b't', 0]).unwrap();
902        let patchset = ParsedDiffSet::parse(&[b'P', 1, 1, b't', 0]).unwrap();
903        // Both are empty so PartialEq short-circuits to true. Add a real op
904        // to each so the variant-mismatch arm in the `match` is reached.
905        let mut full_changeset = vec![b'T', 1, 1, b't', 0];
906        full_changeset.push(op_codes::INSERT);
907        full_changeset.push(0);
908        full_changeset.push(0x01);
909        full_changeset.extend(&1i64.to_be_bytes());
910        let cs = ParsedDiffSet::parse(&full_changeset).unwrap();
911
912        let mut full_patchset = vec![b'P', 1, 1, b't', 0];
913        full_patchset.push(op_codes::INSERT);
914        full_patchset.push(0);
915        full_patchset.push(0x01);
916        full_patchset.extend(&1i64.to_be_bytes());
917        let ps = ParsedDiffSet::parse(&full_patchset).unwrap();
918
919        assert_ne!(cs, ps);
920        // Empty/empty still equal regardless of variant.
921        assert_eq!(changeset, patchset);
922    }
923
924    #[test]
925    fn test_parse_unexpected_eof_in_operation_header() {
926        // Valid changeset header followed by a single byte (op_code only,
927        // no indirect byte) — parse_operation_header must return UnexpectedEof.
928        let data = [b'T', 1, 1, b't', 0, op_codes::INSERT];
929        let err = ParsedDiffSet::parse(&data).unwrap_err();
930        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
931    }
932
933    #[test]
934    fn test_parse_patchset_indirect_flag_set() {
935        // Patchset INSERT carries full row values, same header layout.
936        let mut data = vec![b'P', 1, 1, b't', 0];
937        data.push(op_codes::INSERT);
938        data.push(1);
939        data.push(0x01);
940        data.extend(&1i64.to_be_bytes());
941
942        let parsed = ParsedDiffSet::parse(&data).unwrap();
943        let ParsedDiffSet::Patchset(set) = parsed else {
944            panic!("expected Patchset");
945        };
946        let indirect = set
947            .tables
948            .iter()
949            .find_map(|(_schema, rows)| rows.first().map(|(_, op)| op.indirect()))
950            .expect("expected at least one op");
951        assert!(indirect);
952    }
953
954    /// Assert a real SQLite patchset UPDATE byte string parses to a single
955    /// UPDATE operation, run caller-supplied checks against the destructured
956    /// state, and confirm the parsed value re-serializes byte-identically.
957    ///
958    /// Centralizing the destructures here keeps the individual scenario tests
959    /// focused on their assertions and folds the defensive `panic!` arms into
960    /// one place. Each test below feeds bytes captured from a real
961    /// `Session::patchset_strm` call, so the checker sees the exact wire
962    /// layout the parser now targets.
963    fn assert_patchset_update_roundtrip(
964        data: &[u8],
965        check: impl FnOnce(
966            &TableSchema<String>,
967            &[Value<String, Vec<u8>>],
968            &[((), MaybeValue<String, Vec<u8>>)],
969            bool,
970        ),
971    ) {
972        let parsed = ParsedDiffSet::parse(data).expect("SQLite patchset UPDATE must parse");
973        let ParsedDiffSet::Patchset(set) = parsed else {
974            panic!("expected Patchset, got {parsed:?}");
975        };
976        let (schema, rows) = set.tables.first().expect("expected one table");
977        assert_eq!(rows.len(), 1, "expected exactly one row");
978        let (pk, op) = rows.first().expect("row map non-empty");
979        let Operation::Update { values, indirect } = op else {
980            panic!("expected Update, got {op:?}");
981        };
982        check(schema, pk.as_slice(), values.as_slice(), *indirect);
983        let serialized: Vec<u8> = set.into();
984        assert_eq!(serialized, data, "roundtrip must match SQLite output");
985    }
986
987    /// Real SQLite session output for a standalone patchset UPDATE against a
988    /// pre-existing row on a single-column PK table:
989    ///
990    /// ```text
991    /// CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER, status TEXT);
992    /// INSERT INTO orders VALUES (5, 100, 'pending'); -- before session.attach()
993    /// UPDATE orders SET status = 'shipped' WHERE id = 5; -- after attach, tracked
994    /// ```
995    ///
996    /// Wire layout:
997    /// - 12 bytes header ('P', 3, [1,0,0], "orders\0")
998    /// - 2 bytes op header (UPDATE, indirect=0)
999    /// - 9 bytes old side: INTEGER 5 (only the PK column)
1000    /// - 10 bytes new side: undefined (amount unchanged) + TEXT 'shipped'
1001    ///
1002    /// Total 33 bytes. Historically the parser expected `column_count` values on
1003    /// each side (padded with undefined) and returned `InvalidValue` mid-buffer.
1004    #[test]
1005    fn test_parse_patchset_update_sqlite_wire_layout_single_pk() {
1006        let data: [u8; 33] = [
1007            0x50, 0x03, 0x01, 0x00, 0x00, b'o', b'r', b'd', b'e', b'r', b's', 0x00, 0x17, 0x00,
1008            0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x03, 0x07, b's', b'h',
1009            b'i', b'p', b'p', b'e', b'd',
1010        ];
1011        assert_patchset_update_roundtrip(&data, |schema, pk, values, indirect| {
1012            assert_eq!(schema.name, "orders");
1013            assert_eq!(schema.column_count, 3);
1014            assert_eq!(schema.pk_flags, vec![1, 0, 0]);
1015            assert_eq!(pk, &[Value::Integer(5)]);
1016            assert!(!indirect);
1017            assert_eq!(values.len(), 3);
1018            assert_eq!(values[0].1, Some(Value::Integer(5))); // PK preserved
1019            assert_eq!(values[1].1, None); // amount unchanged
1020            assert_eq!(values[2].1, Some(Value::Text("shipped".into())));
1021        });
1022    }
1023
1024    /// Real SQLite output for a composite PK, `PRIMARY KEY(a, b)`:
1025    ///
1026    /// ```text
1027    /// CREATE TABLE items (a INTEGER NOT NULL, b INTEGER NOT NULL, val TEXT, PRIMARY KEY(a, b));
1028    /// INSERT INTO items VALUES (1, 2, 'v1'); -- before attach
1029    /// UPDATE items SET val = 'v2' WHERE a = 1 AND b = 2;
1030    /// ```
1031    ///
1032    /// Wire layout: two PK values on the old side (INTEGER 1, INTEGER 2), one
1033    /// non-PK value on the new side (TEXT 'v2'). 35 bytes total.
1034    #[test]
1035    fn test_parse_patchset_update_sqlite_wire_layout_composite_pk() {
1036        let data: [u8; 35] = [
1037            0x50, 0x03, 0x01, 0x02, 0x00, b'i', b't', b'e', b'm', b's', 0x00, 0x17, 0x00, 0x01,
1038            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
1039            0x00, 0x00, 0x02, 0x03, 0x02, b'v', b'2',
1040        ];
1041        assert_patchset_update_roundtrip(&data, |schema, pk, values, _indirect| {
1042            assert_eq!(schema.name, "items");
1043            assert_eq!(schema.pk_flags, vec![1, 2, 0]);
1044            // `extract_pk` returns values sorted by PK ordinal. With PK(a, b) and
1045            // pk_flags [1, 2, 0], ordinal 1 is column `a`, ordinal 2 is column `b`.
1046            assert_eq!(pk, &[Value::Integer(1), Value::Integer(2)]);
1047            assert_eq!(values.len(), 3);
1048            assert_eq!(values[0].1, Some(Value::Integer(1))); // a (PK)
1049            assert_eq!(values[1].1, Some(Value::Integer(2))); // b (PK)
1050            assert_eq!(values[2].1, Some(Value::Text("v2".into())));
1051        });
1052    }
1053
1054    /// Every non-PK column is present on the new side, in column order, either
1055    /// as its new value or as the undefined marker `0x00` when unchanged.
1056    ///
1057    /// ```text
1058    /// UPDATE orders SET amount = 200, status = 'shipped' WHERE id = 5;
1059    /// ```
1060    ///
1061    /// Two non-PK columns changed, so both are defined values (no undefined
1062    /// markers). 41 bytes total.
1063    #[test]
1064    fn test_parse_patchset_update_all_non_pk_changed() {
1065        let data: [u8; 41] = [
1066            0x50, 0x03, 0x01, 0x00, 0x00, b'o', b'r', b'd', b'e', b'r', b's', 0x00, 0x17, 0x00,
1067            0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00,
1068            0x00, 0x00, 0x00, 0xc8, 0x03, 0x07, b's', b'h', b'i', b'p', b'p', b'e', b'd',
1069        ];
1070        assert_patchset_update_roundtrip(&data, |_schema, _pk, values, _indirect| {
1071            assert_eq!(values[0].1, Some(Value::Integer(5)));
1072            assert_eq!(values[1].1, Some(Value::Integer(200)));
1073            assert_eq!(values[2].1, Some(Value::Text("shipped".into())));
1074        });
1075    }
1076}