Skip to main content

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);