version_migrate/lib.rs
1//! # version-migrate
2//!
3//! A library for explicit, type-safe schema versioning and migration.
4//!
5//! ## Features
6//!
7//! - **Type-safe migrations**: Define migrations between versions using traits
8//! - **Validation**: Automatic validation of migration paths (circular path detection, version ordering)
9//! - **Multi-format support**: Load from JSON, TOML, YAML, or any serde-compatible format
10//! - **Vec support**: Migrate collections of versioned entities
11//! - **Hierarchical structures**: Support for nested versioned entities
12//! - **Async migrations**: Optional async support for I/O-heavy migrations
13//!
14//! ## Basic Example
15//!
16//! ```ignore
17//! use version_migrate::{Versioned, MigratesTo, IntoDomain, Migrator};
18//! use serde::{Serialize, Deserialize};
19//!
20//! // Version 1.0.0
21//! #[derive(Serialize, Deserialize, Versioned)]
22//! #[versioned(version = "1.0.0")]
23//! struct TaskV1_0_0 {
24//! id: String,
25//! title: String,
26//! }
27//!
28//! // Version 1.1.0
29//! #[derive(Serialize, Deserialize, Versioned)]
30//! #[versioned(version = "1.1.0")]
31//! struct TaskV1_1_0 {
32//! id: String,
33//! title: String,
34//! description: Option<String>,
35//! }
36//!
37//! // Domain model
38//! struct TaskEntity {
39//! id: String,
40//! title: String,
41//! description: Option<String>,
42//! }
43//!
44//! impl MigratesTo<TaskV1_1_0> for TaskV1_0_0 {
45//! fn migrate(self) -> TaskV1_1_0 {
46//! TaskV1_1_0 {
47//! id: self.id,
48//! title: self.title,
49//! description: None,
50//! }
51//! }
52//! }
53//!
54//! impl IntoDomain<TaskEntity> for TaskV1_1_0 {
55//! fn into_domain(self) -> TaskEntity {
56//! TaskEntity {
57//! id: self.id,
58//! title: self.title,
59//! description: self.description,
60//! }
61//! }
62//! }
63//! ```
64//!
65//! ## Working with Collections (Vec)
66//!
67//! ```ignore
68//! // Save multiple versioned entities
69//! let tasks = vec![
70//! TaskV1_0_0 { id: "1".into(), title: "Task 1".into() },
71//! TaskV1_0_0 { id: "2".into(), title: "Task 2".into() },
72//! ];
73//! let json = migrator.save_vec(tasks)?;
74//!
75//! // Load and migrate multiple entities
76//! let domains: Vec<TaskEntity> = migrator.load_vec("task", &json)?;
77//! ```
78//!
79//! ## Hierarchical Structures
80//!
81//! For complex configurations with nested versioned entities:
82//!
83//! ```ignore
84//! #[derive(Serialize, Deserialize, Versioned)]
85//! #[versioned(version = "1.0.0")]
86//! struct ConfigV1 {
87//! setting: SettingV1,
88//! items: Vec<ItemV1>,
89//! }
90//!
91//! #[derive(Serialize, Deserialize, Versioned)]
92//! #[versioned(version = "2.0.0")]
93//! struct ConfigV2 {
94//! setting: SettingV2,
95//! items: Vec<ItemV2>,
96//! }
97//!
98//! impl MigratesTo<ConfigV2> for ConfigV1 {
99//! fn migrate(self) -> ConfigV2 {
100//! ConfigV2 {
101//! // Migrate nested entities
102//! setting: self.setting.migrate(),
103//! items: self.items.into_iter()
104//! .map(|item| item.migrate())
105//! .collect(),
106//! }
107//! }
108//! }
109//! ```
110//!
111//! ## Design Philosophy
112//!
113//! This library follows the **explicit versioning** approach:
114//!
115//! - Each version has its own type (V1, V2, V3, etc.)
116//! - Migration logic is explicit and testable
117//! - Version changes are tracked in code
118//! - Root-level versioning ensures consistency
119//!
120//! This differs from ProtoBuf's "append-only" approach but allows for:
121//! - Schema refactoring and cleanup
122//! - Type-safe migration paths
123//! - Clear version history in code
124
125use serde::{Deserialize, Serialize};
126
127pub mod errors;
128mod migrator;
129
130// Re-export the derive macro
131pub use version_migrate_macro::Versioned;
132
133// Re-export error types
134pub use errors::MigrationError;
135
136// Re-export migrator types
137pub use migrator::{MigrationPath, Migrator};
138
139// Re-export async-trait for user convenience
140pub use async_trait::async_trait;
141
142/// A trait for versioned data schemas.
143///
144/// This trait marks a type as representing a specific version of a data schema.
145/// It should be derived using `#[derive(Versioned)]` along with the `#[versioned(version = "x.y.z")]` attribute.
146///
147/// # Custom Keys
148///
149/// You can customize the serialization keys:
150///
151/// ```ignore
152/// #[derive(Versioned)]
153/// #[versioned(
154/// version = "1.0.0",
155/// version_key = "schema_version",
156/// data_key = "payload"
157/// )]
158/// struct Task { ... }
159/// // Serializes to: {"schema_version":"1.0.0","payload":{...}}
160/// ```
161pub trait Versioned {
162 /// The semantic version of this schema.
163 const VERSION: &'static str;
164
165 /// The key name for the version field in serialized data.
166 /// Defaults to "version".
167 const VERSION_KEY: &'static str = "version";
168
169 /// The key name for the data field in serialized data.
170 /// Defaults to "data".
171 const DATA_KEY: &'static str = "data";
172}
173
174/// Defines explicit migration logic from one version to another.
175///
176/// Implementing this trait establishes a migration path from `Self` (the source version)
177/// to `T` (the target version).
178pub trait MigratesTo<T: Versioned>: Versioned {
179 /// Migrates from the current version to the target version.
180 fn migrate(self) -> T;
181}
182
183/// Converts a versioned DTO into the application's domain model.
184///
185/// This trait should be implemented on the latest version of a DTO to convert
186/// it into the clean, version-agnostic domain model.
187pub trait IntoDomain<D>: Versioned {
188 /// Converts this versioned data into the domain model.
189 fn into_domain(self) -> D;
190}
191
192/// Async version of `MigratesTo` for migrations requiring I/O operations.
193///
194/// Use this trait when migrations need to perform asynchronous operations
195/// such as database queries or API calls.
196#[async_trait::async_trait]
197pub trait AsyncMigratesTo<T: Versioned>: Versioned + Send {
198 /// Asynchronously migrates from the current version to the target version.
199 ///
200 /// # Errors
201 ///
202 /// Returns `MigrationError` if the migration fails.
203 async fn migrate(self) -> Result<T, MigrationError>;
204}
205
206/// Async version of `IntoDomain` for domain conversions requiring I/O operations.
207///
208/// Use this trait when converting to the domain model requires asynchronous
209/// operations such as fetching additional data from external sources.
210#[async_trait::async_trait]
211pub trait AsyncIntoDomain<D>: Versioned + Send {
212 /// Asynchronously converts this versioned data into the domain model.
213 ///
214 /// # Errors
215 ///
216 /// Returns `MigrationError` if the conversion fails.
217 async fn into_domain(self) -> Result<D, MigrationError>;
218}
219
220/// A wrapper for serialized data that includes explicit version information.
221///
222/// This struct is used for persistence to ensure that the version of the data
223/// is always stored alongside the data itself.
224#[derive(Serialize, Deserialize, Debug, Clone)]
225pub struct VersionedWrapper<T> {
226 /// The semantic version of the data.
227 pub version: String,
228 /// The actual data.
229 pub data: T,
230}
231
232impl<T> VersionedWrapper<T> {
233 /// Creates a new versioned wrapper with the specified version and data.
234 pub fn new(version: String, data: T) -> Self {
235 Self { version, data }
236 }
237}
238
239impl<T: Versioned> VersionedWrapper<T> {
240 /// Creates a wrapper from a versioned value, automatically extracting its version.
241 pub fn from_versioned(data: T) -> Self {
242 Self {
243 version: T::VERSION.to_string(),
244 data,
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
254 struct TestData {
255 value: String,
256 }
257
258 impl Versioned for TestData {
259 const VERSION: &'static str = "1.0.0";
260 }
261
262 #[test]
263 fn test_versioned_wrapper_from_versioned() {
264 let data = TestData {
265 value: "test".to_string(),
266 };
267 let wrapper = VersionedWrapper::from_versioned(data);
268
269 assert_eq!(wrapper.version, "1.0.0");
270 assert_eq!(wrapper.data.value, "test");
271 }
272
273 #[test]
274 fn test_versioned_wrapper_new() {
275 let data = TestData {
276 value: "manual".to_string(),
277 };
278 let wrapper = VersionedWrapper::new("2.0.0".to_string(), data);
279
280 assert_eq!(wrapper.version, "2.0.0");
281 assert_eq!(wrapper.data.value, "manual");
282 }
283
284 #[test]
285 fn test_versioned_wrapper_serialization() {
286 let data = TestData {
287 value: "serialize_test".to_string(),
288 };
289 let wrapper = VersionedWrapper::from_versioned(data);
290
291 // Serialize
292 let json = serde_json::to_string(&wrapper).expect("Serialization failed");
293
294 // Deserialize
295 let deserialized: VersionedWrapper<TestData> =
296 serde_json::from_str(&json).expect("Deserialization failed");
297
298 assert_eq!(deserialized.version, "1.0.0");
299 assert_eq!(deserialized.data.value, "serialize_test");
300 }
301
302 #[test]
303 fn test_versioned_wrapper_with_complex_data() {
304 #[derive(Serialize, Deserialize, Debug, PartialEq)]
305 struct ComplexData {
306 id: u64,
307 name: String,
308 tags: Vec<String>,
309 metadata: Option<String>,
310 }
311
312 impl Versioned for ComplexData {
313 const VERSION: &'static str = "3.2.1";
314 }
315
316 let data = ComplexData {
317 id: 42,
318 name: "complex".to_string(),
319 tags: vec!["tag1".to_string(), "tag2".to_string()],
320 metadata: Some("meta".to_string()),
321 };
322
323 let wrapper = VersionedWrapper::from_versioned(data);
324 assert_eq!(wrapper.version, "3.2.1");
325 assert_eq!(wrapper.data.id, 42);
326 assert_eq!(wrapper.data.tags.len(), 2);
327 }
328
329 #[test]
330 fn test_versioned_wrapper_clone() {
331 let data = TestData {
332 value: "clone_test".to_string(),
333 };
334 let wrapper = VersionedWrapper::from_versioned(data);
335 let cloned = wrapper.clone();
336
337 assert_eq!(cloned.version, wrapper.version);
338 assert_eq!(cloned.data.value, wrapper.data.value);
339 }
340
341 #[test]
342 fn test_versioned_wrapper_debug() {
343 let data = TestData {
344 value: "debug".to_string(),
345 };
346 let wrapper = VersionedWrapper::from_versioned(data);
347 let debug_str = format!("{:?}", wrapper);
348
349 assert!(debug_str.contains("1.0.0"));
350 assert!(debug_str.contains("debug"));
351 }
352}