Skip to main content

umbral_core/
fixtures.rs

1//! Feature #74 — per-model fixture load / dump.
2//!
3//! The `backup` module ships the whole-database dump format
4//! (envelope with `umbral_dump_version` and every registered
5//! model's rows). That's the right shape for migrations between
6//! environments, but it's heavy for the common test-and-dev case:
7//! "I want to seed five `Post` rows from a file at the top of my
8//! test."
9//!
10//! Fixtures are the simpler shape: a flat JSON array of row
11//! objects, per file, per model. The file is hand-editable,
12//! diff-friendly, and skips the envelope so a fresh dev seed
13//! file can be checked into source control without metadata
14//! noise.
15//!
16//! # Example
17//!
18//! ```ignore
19//! // tests/fixtures/posts.json:
20//! //   [
21//! //     {"id": 1, "title": "Hello", "body": "..."},
22//! //     {"id": 2, "title": "World", "body": "..."}
23//! //   ]
24//!
25//! use umbral::orm::Model;
26//!
27//! #[tokio::test]
28//! async fn seeded_posts_are_listable() {
29//!     boot().await;
30//!     let inserted = Post::objects()
31//!         .load_fixture("tests/fixtures/posts.json")
32//!         .await
33//!         .expect("seed");
34//!     assert_eq!(inserted, 2);
35//!     let visible = Post::objects().fetch().await.unwrap();
36//!     assert_eq!(visible.len(), 2);
37//! }
38//! ```
39//!
40//! Row objects flow through the same `DynQuerySet::insert_json`
41//! path the REST plugin uses, so every framework feature applies
42//! transparently: auto_now / auto_now_add timestamps, slug_from
43//! auto-derive, validator pre-checks, FK existence checks, and
44//! the soft-delete WHERE auto-filter on later reads.
45//!
46//! ## Deferred
47//!
48//! - `cargo run -- seed --fixture <path>` CLI subcommand — needs
49//!   per-model table-name resolution from a string, which the
50//!   typed Manager-based shape doesn't expose. Lands when a
51//!   real consumer surfaces the need (`Plugin::commands()` from
52//!   feature #71 makes the wiring trivial when that day arrives).
53//! - `Factory` macros + the `fake` crate for generating
54//!   realistic data on demand. Concrete data via JSON file is
55//!   the v1.
56//! - Transaction-scoped fixture lifecycle (auto-rollback after
57//!   each test). Today the caller manages the test transaction
58//!   themselves; layering an explicit wrapper is straightforward
59//!   once `umbral-testing` grows a `TestClient`.
60
61use std::path::{Path, PathBuf};
62
63use serde_json::{Map, Value};
64
65use crate::migrate::ModelMeta;
66use crate::orm::{DynError, DynQuerySet, Manager, Model};
67
68/// Failures the fixture pipeline can surface. Splits I/O,
69/// JSON-shape, and write-time issues so callers can branch on the
70/// failure kind without parsing error messages.
71#[derive(Debug)]
72pub enum FixtureError {
73    /// `std::fs::read` or `std::fs::write` failed.
74    Io(std::io::Error),
75    /// `serde_json::from_slice` / `serde_json::to_string` failed.
76    /// Most often this means the fixture file isn't a JSON array
77    /// of objects.
78    Json(serde_json::Error),
79    /// The JSON top level wasn't a JSON array. The fixture file
80    /// shape is intentionally strict — wrap-in-envelope formats
81    /// belong in [`crate::backup`].
82    NotAnArray { path: PathBuf },
83    /// A row's insertion failed. Wraps the underlying
84    /// [`crate::orm::write::WriteError`] so callers can pattern-
85    /// match on validator / FK / unique violations and surface a
86    /// readable error per fixture row.
87    Write {
88        index: usize,
89        source: crate::orm::write::WriteError,
90    },
91    /// A read-back (during `dump_fixture`) failed.
92    Read(DynError),
93}
94
95impl std::fmt::Display for FixtureError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::Io(e) => write!(f, "fixture I/O error: {e}"),
99            Self::Json(e) => write!(f, "fixture JSON error: {e}"),
100            Self::NotAnArray { path } => write!(
101                f,
102                "fixture {} is not a JSON array of row objects",
103                path.display()
104            ),
105            Self::Write { index, source } => {
106                write!(f, "fixture row #{index} insert failed: {source:?}")
107            }
108            Self::Read(e) => write!(f, "fixture read-back failed: {e:?}"),
109        }
110    }
111}
112
113impl std::error::Error for FixtureError {}
114
115impl From<std::io::Error> for FixtureError {
116    fn from(e: std::io::Error) -> Self {
117        Self::Io(e)
118    }
119}
120
121impl From<serde_json::Error> for FixtureError {
122    fn from(e: serde_json::Error) -> Self {
123        Self::Json(e)
124    }
125}
126
127/// Load a JSON-array fixture file into the given model's table.
128///
129/// Returns the number of rows successfully inserted. The first
130/// failure stops the run — subsequent rows aren't attempted. Wrap
131/// the call in [`crate::transaction`] if you want all-or-nothing
132/// semantics across the file.
133pub async fn load_fixture<T, P>(path: P) -> Result<usize, FixtureError>
134where
135    T: Model,
136    P: AsRef<Path>,
137{
138    let path = path.as_ref();
139    let bytes = std::fs::read(path)?;
140    let parsed: Value = serde_json::from_slice(&bytes)?;
141    let rows = match parsed {
142        Value::Array(rows) => rows,
143        _ => {
144            return Err(FixtureError::NotAnArray {
145                path: path.to_path_buf(),
146            });
147        }
148    };
149    let meta = ModelMeta::for_::<T>();
150    for (index, raw) in rows.iter().enumerate() {
151        let obj: Map<String, Value> = match raw {
152            Value::Object(map) => map.clone(),
153            _ => {
154                return Err(FixtureError::NotAnArray {
155                    path: path.to_path_buf(),
156                });
157            }
158        };
159        DynQuerySet::for_meta(&meta)
160            .insert_json(&obj)
161            .await
162            .map_err(|source| FixtureError::Write { index, source })?;
163    }
164    Ok(rows.len())
165}
166
167/// Read every row of the given model out as a flat JSON array
168/// and write it to `path`. Symmetric counterpart to
169/// [`load_fixture`].
170///
171/// The output is `serde_json::to_string_pretty`-formatted so the
172/// file is diff-friendly and hand-editable. Use this to capture
173/// a working dev dataset that can be checked into source control
174/// and replayed in tests.
175pub async fn dump_fixture<T, P>(path: P) -> Result<usize, FixtureError>
176where
177    T: Model,
178    P: AsRef<Path>,
179{
180    let meta = ModelMeta::for_::<T>();
181    let rows = DynQuerySet::for_meta(&meta)
182        .fetch_as_json()
183        .await
184        .map_err(FixtureError::Read)?;
185    let json = serde_json::to_string_pretty(&rows)?;
186    std::fs::write(path.as_ref(), json)?;
187    Ok(rows.len())
188}
189
190/// Manager convenience shims so callers can write
191/// `Post::objects().load_fixture(...)` and `dump_fixture(...)`
192/// without importing the free functions.
193impl<T: Model> Manager<T> {
194    /// See [`load_fixture`].
195    pub async fn load_fixture<P: AsRef<Path>>(&self, path: P) -> Result<usize, FixtureError> {
196        load_fixture::<T, _>(path).await
197    }
198
199    /// See [`dump_fixture`].
200    pub async fn dump_fixture<P: AsRef<Path>>(&self, path: P) -> Result<usize, FixtureError> {
201        dump_fixture::<T, _>(path).await
202    }
203}