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}