umbral_core/orm/file_field.rs
1//! `FileField` / `ImageField` — TEXT-backed handles to a file stored in
2//! the ambient [`Storage`](crate::storage::Storage) backend.
3//!
4//! ## What this is
5//!
6//! A `FileField` is a thin newtype around a `String` that holds the
7//! storage *key* — the opaque identifier a [`Storage`] backend returns
8//! from `store` (e.g. `"ab12-photo.jpg"`). The column is plain `TEXT`;
9//! the value persisted is exactly the key. The framework value-add over
10//! a bare `String` is [`FileField::url`]: it resolves the key to a
11//! public URL through the ambient storage backend, so a template can
12//! render `<img src="{{ post.cover.url }}">` without the model author
13//! threading a `Storage` handle through.
14//!
15//! `ImageField` is a [`FileField`] in everything but its *default
16//! widget*. The storage layer treats them identically; the only
17//! difference is the `#[derive(Model)]` macro tags an `ImageField`
18//! column with `widget = Some("image")` (vs `Some("file")` for a
19//! `FileField`), which a later wave's admin uses to render an image
20//! preview instead of a plain file input. `ImageField` shares all of
21//! `FileField`'s behaviour by wrapping it (`ImageField(FileField)`) and
22//! deref-ing to it; the sqlx + serde impls are generated once by an
23//! internal macro and applied to both, so there is no duplicated
24//! encode/decode logic.
25//!
26//! ## Serialisation
27//!
28//! Both serialise *as the bare key string* (not as a `{ "key": ... }`
29//! object), so REST / JSON round-trips the key transparently — a `cover`
30//! field comes back as `"ab12-photo.jpg"`, the same shape a plain
31//! `String` column would have. Deserialisation reads a string straight
32//! into the key. The resolved URL is a *render-time* concern
33//! ([`FileField::url`]), never persisted or serialised.
34//!
35//! ## Storage resolution is non-fatal
36//!
37//! [`FileField::url`] resolves through [`crate::storage::storage_opt`],
38//! which returns `None` when no backend is registered. In that case
39//! `url()` falls back to the raw key rather than panicking, so a
40//! template render never blows up just because the media plugin wasn't
41//! wired. The boot system check (`field.storage_backend`) is the loud
42//! guard: a model that declares a file/image field but registers no
43//! `Storage` backend fails `App::build()`, so the silent-fallback path
44//! is only ever hit in tests / transitional states.
45
46use serde::{Deserialize, Serialize};
47
48/// A TEXT-backed handle to a file in the ambient
49/// [`Storage`](crate::storage::Storage) backend.
50///
51/// The inner `String` is the storage *key*. Construct one from a key
52/// with [`FileField::from`] (`String` or `&str`), read the key with
53/// [`FileField::key`], and resolve the public URL with
54/// [`FileField::url`]. See the module docs for the serialisation +
55/// storage-resolution contract.
56#[derive(Clone, Debug, PartialEq, Eq, Default)]
57pub struct FileField(String);
58
59impl FileField {
60 /// Borrow the storage key — the value persisted in the column.
61 pub fn key(&self) -> &str {
62 &self.0
63 }
64
65 /// Resolve the public URL for this file through the ambient storage
66 /// backend.
67 ///
68 /// When a backend is registered (the production posture — the boot
69 /// system check enforces it for any model with a file/image field),
70 /// this returns `storage.url(key)`. When none is registered, it
71 /// falls back to the raw key so a template render never panics. See
72 /// the module docs.
73 pub fn url(&self) -> String {
74 crate::storage::storage_opt()
75 .map(|s| s.url(self.key()))
76 .unwrap_or_else(|| self.0.clone())
77 }
78
79 /// `true` when no file is attached (the key is empty). The default
80 /// `FileField` is empty.
81 pub fn is_empty(&self) -> bool {
82 self.0.is_empty()
83 }
84}
85
86impl From<String> for FileField {
87 fn from(key: String) -> Self {
88 FileField(key)
89 }
90}
91
92impl From<&str> for FileField {
93 fn from(key: &str) -> Self {
94 FileField(key.to_string())
95 }
96}
97
98impl AsRef<str> for FileField {
99 fn as_ref(&self) -> &str {
100 &self.0
101 }
102}
103
104impl std::fmt::Display for FileField {
105 /// Renders the storage key (not the resolved URL) — matches the
106 /// serialised form and what a bare `String` column would print. Use
107 /// [`FileField::url`] when you need the public URL.
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.write_str(&self.0)
110 }
111}
112
113/// A [`FileField`] that defaults to the `image` widget.
114///
115/// Behaviourally identical to `FileField` at the storage / serde / sqlx
116/// layer — it derefs to the inner `FileField`, so `cover.key()`,
117/// `cover.url()`, and `cover.is_empty()` all work. The only difference
118/// is the default widget the `#[derive(Model)]` macro assigns
119/// (`"image"` vs `"file"`), which a later wave's admin uses to render a
120/// preview. See the module docs.
121#[derive(Clone, Debug, PartialEq, Eq, Default)]
122pub struct ImageField(FileField);
123
124impl std::ops::Deref for ImageField {
125 type Target = FileField;
126 fn deref(&self) -> &FileField {
127 &self.0
128 }
129}
130
131impl From<String> for ImageField {
132 fn from(key: String) -> Self {
133 ImageField(FileField::from(key))
134 }
135}
136
137impl From<&str> for ImageField {
138 fn from(key: &str) -> Self {
139 ImageField(FileField::from(key))
140 }
141}
142
143impl AsRef<str> for ImageField {
144 fn as_ref(&self) -> &str {
145 self.0.as_ref()
146 }
147}
148
149impl std::fmt::Display for ImageField {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 std::fmt::Display::fmt(&self.0, f)
152 }
153}
154
155// =========================================================================
156// serde + sqlx: both types are TEXT-backed string newtypes.
157//
158// The two share one set of impls via the `impl_string_newtype!` macro
159// below so there's a single source of truth for "serialise as the bare
160// key; encode/decode as TEXT on both backends." `ImageField` wraps
161// `FileField` rather than `String`, but the macro's `$inner` accessor
162// (`.key()` / construction `From<String>`) papers over that — the wire
163// shape is identical.
164// =========================================================================
165
166/// Generate the serde + sqlx impls for a TEXT-backed string newtype.
167///
168/// `$ty` is the wrapper; it must impl `From<String>` (to build from a
169/// decoded key) and expose its key via `.key()` (to encode / serialise).
170/// Applied to both `FileField` and `ImageField` so the encode/decode and
171/// serialise logic lives in exactly one place.
172macro_rules! impl_string_newtype {
173 ($ty:ty) => {
174 impl Serialize for $ty {
175 /// Serialise as the bare key string so REST / JSON round-trip
176 /// the key transparently (same shape as a `String` column).
177 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
178 s.serialize_str(self.key())
179 }
180 }
181
182 impl<'de> Deserialize<'de> for $ty {
183 /// Read a JSON string straight into the key.
184 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
185 let key = String::deserialize(d)?;
186 Ok(<$ty>::from(key))
187 }
188 }
189
190 impl sqlx::Type<sqlx::Sqlite> for $ty {
191 fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
192 <String as sqlx::Type<sqlx::Sqlite>>::type_info()
193 }
194 fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
195 <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
196 }
197 }
198
199 impl sqlx::Type<sqlx::Postgres> for $ty {
200 fn type_info() -> sqlx::postgres::PgTypeInfo {
201 <String as sqlx::Type<sqlx::Postgres>>::type_info()
202 }
203 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
204 <String as sqlx::Type<sqlx::Postgres>>::compatible(ty)
205 }
206 }
207
208 impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for $ty {
209 fn decode(
210 value: sqlx::sqlite::SqliteValueRef<'r>,
211 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
212 let s = <String as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
213 Ok(<$ty>::from(s))
214 }
215 }
216
217 impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $ty {
218 fn decode(
219 value: sqlx::postgres::PgValueRef<'r>,
220 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
221 let s = <String as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
222 Ok(<$ty>::from(s))
223 }
224 }
225
226 impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for $ty {
227 fn encode_by_ref(
228 &self,
229 buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
230 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
231 <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(
232 &self.key().to_string(),
233 buf,
234 )
235 }
236 }
237
238 impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $ty {
239 fn encode_by_ref(
240 &self,
241 buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer<'q>,
242 ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
243 <String as sqlx::Encode<'q, sqlx::Postgres>>::encode_by_ref(
244 &self.key().to_string(),
245 buf,
246 )
247 }
248 }
249 };
250}
251
252impl_string_newtype!(FileField);
253impl_string_newtype!(ImageField);