1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
//! M10 issue #85: tombstone semantics for removed fields.
//!
//! Pattern under test (per design.md § Schema evolution and
//! docs/format.md § Schema evolution → Tombstone semantics):
//!
//! - v1 stores `{a, b, c}`.
//! - v2 stores `{a, c, d}` (drops `b`, adds `d`).
//! - The migrate body calls `doc.remove("b")` and `doc.set("d", ...)`
//! before `doc.deserialize()` to construct the v2 value.
//!
//! Confirms:
//!
//! 1. `Dynamic::remove` strips the field; `Dynamic::deserialize`
//! via postcard accepts the resulting Map.
//! 2. The end-to-end `Db::get` of a v1-on-disk record through the
//! v2 type returns the migrated value.
#![forbid(unsafe_code)]
use obj_core::codec::{Dynamic, DynamicSchema};
use obj_core::{Document, Result};
use serde::{Deserialize, Serialize};
mod v1 {
use super::{Deserialize, Document, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Row {
pub a: u32,
pub b: String,
pub c: u32,
}
impl Document for Row {
const COLLECTION: &'static str = "tombstone_rows";
const VERSION: u32 = 1;
}
}
mod v2 {
use super::{Deserialize, Document, Dynamic, DynamicSchema, Result, Serialize};
use obj_core::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Row {
pub a: u32,
pub c: u32,
pub d: String,
}
impl Document for Row {
const COLLECTION: &'static str = "tombstone_rows";
const VERSION: u32 = 2;
fn historical_schemas() -> Vec<(u32, DynamicSchema)> {
vec![(
1,
DynamicSchema::map([
("a", DynamicSchema::U64),
("b", DynamicSchema::String),
("c", DynamicSchema::U64),
]),
)]
}
fn migrate(mut dynamic: Dynamic, from_version: u32) -> Result<Self> {
if from_version != 1 {
return Err(Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
});
}
// Tombstone: drop the field that v2 no longer carries.
// Without the remove(), a future `deserialize()` path
// through a `#[serde(deny_unknown_fields)]` type would
// refuse the payload; with permissive serde the field
// is silently dropped on the way through, but explicit
// removal documents intent.
let removed = dynamic.remove("b")?;
assert!(removed.is_some(), "v1 payload must carry `b`");
// Default the new field.
dynamic.set("d", "<migrated>");
// Pull the surviving fields out by hand. We could
// also call `dynamic.deserialize::<Row>()` if the wire
// shape lined up — for a per-field Map decoded via
// `from_postcard_bytes` it doesn't, so per-field
// extraction is the portable approach (see the
// `dynamic_migration_shape` test in obj-core).
let a = match dynamic.get("a") {
Some(Dynamic::U64(n)) => {
u32::try_from(*n).map_err(|_| Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
})?
}
_ => {
return Err(Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
});
}
};
let c = match dynamic.get("c") {
Some(Dynamic::U64(n)) => {
u32::try_from(*n).map_err(|_| Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
})?
}
_ => {
return Err(Error::SchemaMigrationNotImplemented {
collection: Self::COLLECTION,
from_version,
to_version: Self::VERSION,
});
}
};
let d = dynamic.get_str("d")?.to_owned();
Ok(Row { a, c, d })
}
}
}
#[test]
fn migration_drops_b_adds_d() {
use tempfile::TempDir;
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("tombstone.obj");
// Insert a v1 doc.
let id;
{
let db = obj::Db::open(&path).expect("open");
id = db
.insert(v1::Row {
a: 11,
b: "to-be-dropped".to_owned(),
c: 33,
})
.expect("insert v1");
}
// Reopen via the v2 type. Migration applies, `b` is gone, `d`
// is populated.
{
let db = obj::Db::open(&path).expect("reopen");
let migrated: v2::Row = db.get(id).expect("get").expect("present");
assert_eq!(
migrated,
v2::Row {
a: 11,
c: 33,
d: "<migrated>".to_owned(),
}
);
}
}
#[test]
fn remove_on_non_map_in_migrate_is_dynamic_path_not_map() {
// Direct unit-style check of the Dynamic::remove error path.
// Migrate impls that mishandle a scalar-shaped payload by
// calling remove on it surface a `DynamicPathNotMap` rather
// than a panic.
let mut value = Dynamic::U64(5);
let err = value.remove("anything").expect_err("non-map");
assert!(matches!(err, obj_core::Error::DynamicPathNotMap { .. }));
}