Skip to main content

rouchdb_core/
document.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Result, RouchError};
8use crate::rev_tree::RevTree;
9
10// ---------------------------------------------------------------------------
11// Revision
12// ---------------------------------------------------------------------------
13
14/// A CouchDB revision identifier: `{pos}-{hash}`.
15///
16/// - `pos` is the generation number (starts at 1, increments each edit).
17/// - `hash` is a 32-character hex MD5 digest.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct Revision {
20    pub pos: u64,
21    pub hash: String,
22}
23
24impl Revision {
25    pub fn new(pos: u64, hash: String) -> Self {
26        Self { pos, hash }
27    }
28}
29
30impl fmt::Display for Revision {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "{}-{}", self.pos, self.hash)
33    }
34}
35
36impl FromStr for Revision {
37    type Err = RouchError;
38
39    fn from_str(s: &str) -> Result<Self> {
40        let (pos_str, hash) = s
41            .split_once('-')
42            .ok_or_else(|| RouchError::InvalidRev(s.to_string()))?;
43        let pos: u64 = pos_str
44            .parse()
45            .map_err(|_| RouchError::InvalidRev(s.to_string()))?;
46        if hash.is_empty() {
47            return Err(RouchError::InvalidRev(s.to_string()));
48        }
49        Ok(Revision {
50            pos,
51            hash: hash.to_string(),
52        })
53    }
54}
55
56impl Ord for Revision {
57    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
58        self.pos
59            .cmp(&other.pos)
60            .then_with(|| self.hash.cmp(&other.hash))
61    }
62}
63
64impl PartialOrd for Revision {
65    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
66        Some(self.cmp(other))
67    }
68}
69
70// ---------------------------------------------------------------------------
71// AttachmentMeta
72// ---------------------------------------------------------------------------
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct AttachmentMeta {
76    pub content_type: String,
77    pub digest: String,
78    pub length: u64,
79    #[serde(default)]
80    pub stub: bool,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub data: Option<Vec<u8>>,
83}
84
85// ---------------------------------------------------------------------------
86// Document
87// ---------------------------------------------------------------------------
88
89/// A CouchDB-compatible document.
90#[derive(Debug, Clone)]
91pub struct Document {
92    pub id: String,
93    pub rev: Option<Revision>,
94    pub deleted: bool,
95    pub data: serde_json::Value,
96    pub attachments: HashMap<String, AttachmentMeta>,
97}
98
99impl Document {
100    /// Create a new document from a JSON value.
101    ///
102    /// Extracts `_id`, `_rev`, `_deleted`, and `_attachments` from the value
103    /// and puts the remaining fields in `data`.
104    pub fn from_json(mut value: serde_json::Value) -> Result<Self> {
105        let obj = value
106            .as_object_mut()
107            .ok_or_else(|| RouchError::BadRequest("document must be a JSON object".into()))?;
108
109        let id = obj
110            .remove("_id")
111            .and_then(|v| v.as_str().map(String::from))
112            .unwrap_or_default();
113
114        let rev = obj
115            .remove("_rev")
116            .and_then(|v| v.as_str().map(String::from))
117            .map(|s| s.parse::<Revision>())
118            .transpose()?;
119
120        let deleted = obj
121            .remove("_deleted")
122            .and_then(|v| v.as_bool())
123            .unwrap_or(false);
124
125        let mut attachments: HashMap<String, AttachmentMeta> = HashMap::new();
126        if let Some(att_val) = obj.remove("_attachments")
127            && let Some(att_obj) = att_val.as_object()
128        {
129            for (name, meta) in att_obj {
130                // Strip inline Base64 `data` string before serde parsing
131                // (serde expects Vec<u8> as an array, not a string).
132                let mut meta_for_parse = meta.clone();
133                let inline_b64 = if let Some(obj) = meta_for_parse.as_object_mut() {
134                    match obj.remove("data") {
135                        Some(serde_json::Value::String(s)) => Some(s),
136                        Some(other) => {
137                            obj.insert("data".to_string(), other);
138                            None
139                        }
140                        None => None,
141                    }
142                } else {
143                    None
144                };
145
146                if let Ok(mut att) = serde_json::from_value::<AttachmentMeta>(meta_for_parse) {
147                    // Decode inline Base64 data if present
148                    if att.data.is_none()
149                        && let Some(ref data_str) = inline_b64
150                    {
151                        use base64::Engine;
152                        if let Ok(bytes) =
153                            base64::engine::general_purpose::STANDARD.decode(data_str)
154                        {
155                            att.length = bytes.len() as u64;
156                            att.data = Some(bytes);
157                            att.stub = false;
158                        }
159                    }
160                    attachments.insert(name.clone(), att);
161                }
162            }
163        }
164
165        Ok(Document {
166            id,
167            rev,
168            deleted,
169            data: value,
170            attachments,
171        })
172    }
173
174    /// Convert back to a JSON value with CouchDB underscore fields.
175    pub fn to_json(&self) -> serde_json::Value {
176        let mut obj = match &self.data {
177            serde_json::Value::Object(m) => m.clone(),
178            _ => serde_json::Map::new(),
179        };
180
181        obj.insert("_id".into(), serde_json::Value::String(self.id.clone()));
182
183        if let Some(rev) = &self.rev {
184            obj.insert("_rev".into(), serde_json::Value::String(rev.to_string()));
185        }
186
187        if self.deleted {
188            obj.insert("_deleted".into(), serde_json::Value::Bool(true));
189        }
190
191        if !self.attachments.is_empty() {
192            use base64::Engine;
193            let mut att_map = serde_json::Map::new();
194            for (name, att) in &self.attachments {
195                if let Ok(serde_json::Value::Object(mut m)) = serde_json::to_value(att) {
196                    // Inline attachment bytes must be emitted as a CouchDB
197                    // base64 string, not serde's default numeric byte array.
198                    if let Some(bytes) = &att.data {
199                        m.insert(
200                            "data".into(),
201                            serde_json::Value::String(
202                                base64::engine::general_purpose::STANDARD.encode(bytes),
203                            ),
204                        );
205                        m.insert("stub".into(), serde_json::Value::Bool(false));
206                    }
207                    att_map.insert(name.clone(), serde_json::Value::Object(m));
208                }
209            }
210            obj.insert("_attachments".into(), serde_json::Value::Object(att_map));
211        }
212
213        serde_json::Value::Object(obj)
214    }
215}
216
217// ---------------------------------------------------------------------------
218// DocumentMetadata — stored in the database alongside the rev tree
219// ---------------------------------------------------------------------------
220
221/// Internal metadata stored per document in the adapter.
222#[derive(Debug, Clone)]
223pub struct DocMetadata {
224    pub id: String,
225    pub rev_tree: RevTree,
226    pub seq: u64,
227}
228
229// ---------------------------------------------------------------------------
230// Option / response types shared across the crate
231// ---------------------------------------------------------------------------
232
233#[derive(Debug, Clone, Default)]
234pub struct GetOptions {
235    /// Retrieve a specific revision.
236    pub rev: Option<String>,
237    /// Include conflicting revisions in `_conflicts`.
238    pub conflicts: bool,
239    /// Return all open (leaf) revisions.
240    pub open_revs: Option<OpenRevs>,
241    /// Include full revision history.
242    pub revs: bool,
243    /// Include full revision info with status (available/missing/deleted).
244    pub revs_info: bool,
245    /// If rev is specified and not a leaf, return the latest leaf instead.
246    pub latest: bool,
247    /// Include inline Base64 attachment data.
248    pub attachments: bool,
249}
250
251/// Revision info entry returned when `revs_info` is requested.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct RevInfo {
254    pub rev: String,
255    pub status: String, // "available", "missing", "deleted"
256}
257
258#[derive(Debug, Clone)]
259pub enum OpenRevs {
260    All,
261    Specific(Vec<String>),
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct PutResponse {
266    pub ok: bool,
267    pub id: String,
268    pub rev: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct DocResult {
273    pub ok: bool,
274    pub id: String,
275    pub rev: Option<String>,
276    pub error: Option<String>,
277    pub reason: Option<String>,
278}
279
280#[derive(Debug, Clone, Default)]
281pub struct BulkDocsOptions {
282    /// When false (replication), accept revisions as-is.
283    /// When true (default), generate new revisions and check conflicts.
284    pub new_edits: bool,
285}
286
287impl BulkDocsOptions {
288    pub fn new() -> Self {
289        Self { new_edits: true }
290    }
291
292    pub fn replication() -> Self {
293        Self { new_edits: false }
294    }
295}
296
297#[derive(Debug, Clone, Default)]
298pub struct AllDocsOptions {
299    pub start_key: Option<String>,
300    pub end_key: Option<String>,
301    pub key: Option<String>,
302    pub keys: Option<Vec<String>>,
303    pub include_docs: bool,
304    pub descending: bool,
305    pub skip: u64,
306    pub limit: Option<u64>,
307    pub inclusive_end: bool,
308    /// Include `_conflicts` for each document (requires `include_docs`).
309    pub conflicts: bool,
310    /// Include `update_seq` in the response.
311    pub update_seq: bool,
312}
313
314impl AllDocsOptions {
315    pub fn new() -> Self {
316        Self {
317            inclusive_end: true,
318            ..Default::default()
319        }
320    }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct AllDocsRow {
325    pub id: String,
326    pub key: String,
327    pub value: AllDocsRowValue,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub doc: Option<serde_json::Value>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct AllDocsRowValue {
334    pub rev: String,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub deleted: Option<bool>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct AllDocsResponse {
341    pub total_rows: u64,
342    pub offset: u64,
343    pub rows: Vec<AllDocsRow>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub update_seq: Option<Seq>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct DbInfo {
350    pub db_name: String,
351    pub doc_count: u64,
352    #[serde(default)]
353    pub doc_del_count: u64,
354    pub update_seq: Seq,
355}
356
357// ---------------------------------------------------------------------------
358// Changes types
359// ---------------------------------------------------------------------------
360
361#[derive(Debug, Clone, Default)]
362pub struct ChangesOptions {
363    pub since: Seq,
364    pub limit: Option<u64>,
365    pub descending: bool,
366    pub include_docs: bool,
367    pub live: bool,
368    pub doc_ids: Option<Vec<String>>,
369    pub selector: Option<serde_json::Value>,
370    /// Include conflicting revisions per change event.
371    pub conflicts: bool,
372    /// Changes style: `MainOnly` (default) returns only winning rev,
373    /// `AllDocs` returns all leaf revisions.
374    pub style: ChangesStyle,
375}
376
377/// Controls which revisions appear in each change event.
378#[derive(Debug, Clone, Default, PartialEq, Eq)]
379pub enum ChangesStyle {
380    /// Default: only the winning revision.
381    #[default]
382    MainOnly,
383    /// All leaf revisions (including deleted ones), matching CouchDB.
384    AllDocs,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct ChangeEvent {
389    pub seq: Seq,
390    pub id: String,
391    pub changes: Vec<ChangeRev>,
392    #[serde(default)]
393    pub deleted: bool,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub doc: Option<serde_json::Value>,
396    /// Conflicting revisions (when `conflicts: true` requested).
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub conflicts: Option<Vec<String>>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct ChangeRev {
403    pub rev: String,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ChangesResponse {
408    pub results: Vec<ChangeEvent>,
409    pub last_seq: Seq,
410}
411
412// ---------------------------------------------------------------------------
413// Replication-related types
414// ---------------------------------------------------------------------------
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct BulkGetItem {
418    pub id: String,
419    pub rev: Option<String>,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct BulkGetResponse {
424    pub results: Vec<BulkGetResult>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct BulkGetResult {
429    pub id: String,
430    pub docs: Vec<BulkGetDoc>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct BulkGetDoc {
435    pub ok: Option<serde_json::Value>,
436    pub error: Option<BulkGetError>,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct BulkGetError {
441    pub id: String,
442    pub rev: String,
443    pub error: String,
444    pub reason: String,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct RevsDiffResponse {
449    #[serde(flatten)]
450    pub results: HashMap<String, RevsDiffResult>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct RevsDiffResult {
455    pub missing: Vec<String>,
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub possible_ancestors: Vec<String>,
458}
459
460// ---------------------------------------------------------------------------
461// Sequence type — supports both numeric (local) and opaque string (CouchDB)
462// ---------------------------------------------------------------------------
463
464/// A database sequence identifier.
465///
466/// Local adapters use numeric sequences (0, 1, 2, ...).
467/// CouchDB 3.x uses opaque string sequences that must be passed back as-is.
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469#[serde(untagged)]
470pub enum Seq {
471    Num(u64),
472    Str(String),
473}
474
475impl Seq {
476    /// The zero sequence (start from the beginning).
477    pub fn zero() -> Self {
478        Seq::Num(0)
479    }
480
481    /// Extract the numeric value. For opaque strings, parses the numeric
482    /// prefix (e.g., `"13-abc..."` → `13`). Returns 0 if unparseable.
483    pub fn as_num(&self) -> u64 {
484        match self {
485            Seq::Num(n) => *n,
486            Seq::Str(s) => s
487                .split('-')
488                .next()
489                .and_then(|n| n.parse().ok())
490                .unwrap_or(0),
491        }
492    }
493
494    /// Format for use in HTTP query parameters.
495    pub fn to_query_string(&self) -> String {
496        match self {
497            Seq::Num(n) => n.to_string(),
498            Seq::Str(s) => s.clone(),
499        }
500    }
501}
502
503impl Default for Seq {
504    fn default() -> Self {
505        Seq::Num(0)
506    }
507}
508
509impl From<u64> for Seq {
510    fn from(n: u64) -> Self {
511        Seq::Num(n)
512    }
513}
514
515impl std::fmt::Display for Seq {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        match self {
518            Seq::Num(n) => write!(f, "{}", n),
519            Seq::Str(s) => write!(f, "{}", s),
520        }
521    }
522}
523
524// ---------------------------------------------------------------------------
525// Purge types
526// ---------------------------------------------------------------------------
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct PurgeResponse {
530    pub purge_seq: Option<u64>,
531    pub purged: HashMap<String, Vec<String>>,
532}
533
534// ---------------------------------------------------------------------------
535// Security document
536// ---------------------------------------------------------------------------
537
538#[derive(Debug, Clone, Default, Serialize, Deserialize)]
539pub struct SecurityDocument {
540    #[serde(default)]
541    pub admins: SecurityGroup,
542    #[serde(default)]
543    pub members: SecurityGroup,
544    /// Arbitrary additional fields CouchDB permits in `_security` are preserved
545    /// so they round-trip instead of being silently dropped.
546    #[serde(flatten, default)]
547    pub extra: serde_json::Map<String, serde_json::Value>,
548}
549
550#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551pub struct SecurityGroup {
552    #[serde(default)]
553    pub names: Vec<String>,
554    #[serde(default)]
555    pub roles: Vec<String>,
556}
557
558// ---------------------------------------------------------------------------
559// Attachment options
560// ---------------------------------------------------------------------------
561
562#[derive(Debug, Clone, Default)]
563pub struct GetAttachmentOptions {
564    pub rev: Option<String>,
565}
566
567// ---------------------------------------------------------------------------
568// Tests
569// ---------------------------------------------------------------------------
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn revision_display_and_parse() {
577        let rev = Revision::new(3, "abc123".into());
578        assert_eq!(rev.to_string(), "3-abc123");
579
580        let parsed: Revision = "3-abc123".parse().unwrap();
581        assert_eq!(parsed, rev);
582    }
583
584    #[test]
585    fn revision_ordering() {
586        let r1 = Revision::new(1, "aaa".into());
587        let r2 = Revision::new(2, "aaa".into());
588        let r3 = Revision::new(2, "bbb".into());
589        assert!(r1 < r2);
590        assert!(r2 < r3);
591    }
592
593    #[test]
594    fn invalid_revision() {
595        assert!("nope".parse::<Revision>().is_err());
596        assert!("abc-123".parse::<Revision>().is_err());
597    }
598
599    #[test]
600    fn revision_rejects_empty_hash() {
601        // "{pos}-" with no hash is malformed and must be rejected.
602        assert!("3-".parse::<Revision>().is_err());
603        assert!("1-".parse::<Revision>().is_err());
604    }
605
606    #[test]
607    fn to_json_inline_attachment_is_base64() {
608        let mut attachments = HashMap::new();
609        attachments.insert(
610            "hi.txt".into(),
611            AttachmentMeta {
612                content_type: "text/plain".into(),
613                digest: "md5-abc".into(),
614                length: 3,
615                stub: false,
616                data: Some(b"hi!".to_vec()),
617            },
618        );
619        let doc = Document {
620            id: "doc1".into(),
621            rev: None,
622            deleted: false,
623            data: serde_json::json!({}),
624            attachments,
625        };
626        let json = doc.to_json();
627        // CouchDB requires inline data as a base64 string, not a byte array.
628        assert_eq!(json["_attachments"]["hi.txt"]["data"], "aGkh");
629        assert_eq!(json["_attachments"]["hi.txt"]["stub"], false);
630    }
631
632    #[test]
633    fn document_from_json_roundtrip() {
634        let json = serde_json::json!({
635            "_id": "doc1",
636            "_rev": "1-abc",
637            "name": "Alice",
638            "age": 30
639        });
640
641        let doc = Document::from_json(json).unwrap();
642        assert_eq!(doc.id, "doc1");
643        assert_eq!(doc.rev.as_ref().unwrap().to_string(), "1-abc");
644        assert_eq!(doc.data["name"], "Alice");
645        assert!(!doc.data.as_object().unwrap().contains_key("_id"));
646
647        let back = doc.to_json();
648        assert_eq!(back["_id"], "doc1");
649        assert_eq!(back["_rev"], "1-abc");
650        assert_eq!(back["name"], "Alice");
651    }
652
653    #[test]
654    fn document_from_json_minimal() {
655        let json = serde_json::json!({"hello": "world"});
656        let doc = Document::from_json(json).unwrap();
657        assert!(doc.id.is_empty());
658        assert!(doc.rev.is_none());
659        assert!(!doc.deleted);
660    }
661
662    #[test]
663    fn bulk_docs_options_defaults() {
664        let opts = BulkDocsOptions::new();
665        assert!(opts.new_edits);
666
667        let repl = BulkDocsOptions::replication();
668        assert!(!repl.new_edits);
669    }
670
671    #[test]
672    fn to_json_deleted_document() {
673        let doc = Document {
674            id: "doc1".into(),
675            rev: Some(Revision::new(2, "def".into())),
676            deleted: true,
677            data: serde_json::json!({}),
678            attachments: HashMap::new(),
679        };
680        let json = doc.to_json();
681        assert_eq!(json["_deleted"], true);
682        assert_eq!(json["_id"], "doc1");
683        assert_eq!(json["_rev"], "2-def");
684    }
685
686    #[test]
687    fn to_json_with_attachments() {
688        let mut attachments = HashMap::new();
689        attachments.insert(
690            "file.txt".into(),
691            AttachmentMeta {
692                content_type: "text/plain".into(),
693                digest: "md5-abc".into(),
694                length: 100,
695                stub: true,
696                data: None,
697            },
698        );
699        let doc = Document {
700            id: "doc1".into(),
701            rev: None,
702            deleted: false,
703            data: serde_json::json!({"key": "val"}),
704            attachments,
705        };
706        let json = doc.to_json();
707        assert!(json["_attachments"]["file.txt"].is_object());
708        assert_eq!(
709            json["_attachments"]["file.txt"]["content_type"],
710            "text/plain"
711        );
712    }
713
714    #[test]
715    fn to_json_non_object_data() {
716        let doc = Document {
717            id: "doc1".into(),
718            rev: None,
719            deleted: false,
720            data: serde_json::json!("just a string"),
721            attachments: HashMap::new(),
722        };
723        let json = doc.to_json();
724        assert_eq!(json["_id"], "doc1");
725    }
726
727    #[test]
728    fn document_from_json_with_deleted_and_attachments() {
729        let json = serde_json::json!({
730            "_id": "doc1",
731            "_rev": "1-abc",
732            "_deleted": true,
733            "_attachments": {
734                "photo.jpg": {
735                    "content_type": "image/jpeg",
736                    "digest": "md5-xyz",
737                    "length": 500,
738                    "stub": true
739                }
740            },
741            "name": "test"
742        });
743        let doc = Document::from_json(json).unwrap();
744        assert!(doc.deleted);
745        assert_eq!(doc.attachments.len(), 1);
746        assert_eq!(doc.attachments["photo.jpg"].content_type, "image/jpeg");
747    }
748
749    #[test]
750    fn document_from_json_not_object() {
751        let json = serde_json::json!("just a string");
752        assert!(Document::from_json(json).is_err());
753    }
754
755    #[test]
756    fn seq_str_as_num() {
757        let seq = Seq::Str("42-g1AAAABXeJzLY".into());
758        assert_eq!(seq.as_num(), 42);
759
760        let seq2 = Seq::Str("not-a-number".into());
761        assert_eq!(seq2.as_num(), 0);
762    }
763
764    #[test]
765    fn seq_to_query_string() {
766        assert_eq!(Seq::Num(5).to_query_string(), "5");
767        let opaque = "13-g1AAAABXeJzLY".to_string();
768        assert_eq!(Seq::Str(opaque.clone()).to_query_string(), opaque);
769    }
770
771    #[test]
772    fn seq_display() {
773        assert_eq!(format!("{}", Seq::Num(42)), "42");
774        assert_eq!(format!("{}", Seq::Str("opaque-seq".into())), "opaque-seq");
775    }
776
777    #[test]
778    fn seq_from_u64() {
779        let seq: Seq = 7u64.into();
780        assert_eq!(seq, Seq::Num(7));
781    }
782}