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