Skip to main content

umbral_core/orm/
multichoice.rs

1//! MultiChoice: closed-set multi-valued field support.
2//!
3//! Where a [`ChoiceField`](crate::orm::ChoiceField) field carries a single
4//! enum variant, a `MultiChoice<E>` field carries an ordered list of
5//! distinct variants of the same enum. Storage is a single TEXT column
6//! holding a comma-separated list of the variants' DB values
7//! (e.g. `"design,frontend"`); decoding is total against `E`'s
8//! `from_str_ok`, so a stray value in the column fails fast at the sqlx
9//! decode boundary.
10//!
11//! ```ignore
12//! use umbral::prelude::*;
13//!
14//! #[derive(Debug, Clone, Copy, PartialEq, Eq, Choices)]
15//! #[choices(rename_all = "lowercase")]
16//! pub enum Tag { Design, Frontend, Backend, DevOps }
17//!
18//! #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow, Model)]
19//! pub struct Article {
20//!     pub id: i64,
21//!     pub title: String,
22//!     #[umbral(default = "design,frontend")]
23//!     pub tags: MultiChoice<Tag>,
24//! }
25//! ```
26//!
27//! The admin renders the field as a checkbox-chip group (one chip per
28//! variant) backed by a hidden CSV input. The Postgres / SQLite
29//! migration emits a plain `TEXT` column — no CHECK constraint at v1,
30//! since validating "every CSV piece is a known variant" requires a
31//! regex which we'd have to escape per-variant. Application-layer
32//! enforcement via sqlx's `Decode` path is sufficient.
33
34use crate::orm::ChoiceField;
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36use sqlx::{Database, Decode, Encode, Postgres, Sqlite, Type};
37use std::fmt;
38use std::marker::PhantomData;
39use std::str::FromStr;
40
41/// An ordered list of [`ChoiceField`] variants, persisted as a single
42/// comma-separated TEXT column. The Rust type is the structural
43/// constraint: every element is statically `E`, so the only way an
44/// invalid value can land in the database is by a third-party process
45/// writing directly. sqlx's `Decode` then fails the next read.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct MultiChoice<E: ChoiceField> {
48    values: Vec<E>,
49}
50
51impl<E: ChoiceField> MultiChoice<E> {
52    /// An empty selection.
53    pub const fn new() -> Self {
54        Self { values: Vec::new() }
55    }
56
57    /// Construct from a `Vec<E>`. Duplicates are NOT removed; callers
58    /// that need a set should dedup their input.
59    pub fn from_vec(values: Vec<E>) -> Self {
60        Self { values }
61    }
62
63    /// Borrow the underlying slice.
64    pub fn as_slice(&self) -> &[E] {
65        &self.values
66    }
67
68    /// Take the underlying `Vec<E>`.
69    pub fn into_vec(self) -> Vec<E> {
70        self.values
71    }
72
73    /// Number of selected variants.
74    pub fn len(&self) -> usize {
75        self.values.len()
76    }
77
78    /// True when no variants are selected.
79    pub fn is_empty(&self) -> bool {
80        self.values.is_empty()
81    }
82
83    /// Append a variant. No de-duplication.
84    pub fn push(&mut self, value: E) {
85        self.values.push(value);
86    }
87
88    /// True when `value` appears at least once.
89    pub fn contains(&self, value: &E) -> bool
90    where
91        E: PartialEq,
92    {
93        self.values.contains(value)
94    }
95
96    /// The DB-stored TEXT value for the current selection. Empty string
97    /// for an empty selection.
98    pub fn to_csv(&self) -> String {
99        let mut out = String::new();
100        for (i, v) in self.values.iter().enumerate() {
101            if i > 0 {
102                out.push(',');
103            }
104            out.push_str(v.as_str());
105        }
106        out
107    }
108
109    /// Parse a comma-separated DB string into a `MultiChoice<E>`.
110    /// Empty input yields an empty selection. Whitespace around
111    /// individual entries is trimmed. Unknown segments return
112    /// `Err(unknown_segment)`.
113    pub fn from_csv(s: &str) -> Result<Self, String> {
114        if s.is_empty() {
115            return Ok(Self::new());
116        }
117        let mut values = Vec::new();
118        for part in s.split(',') {
119            let part = part.trim();
120            if part.is_empty() {
121                continue;
122            }
123            match E::from_str_ok(part) {
124                Some(v) => values.push(v),
125                None => return Err(part.to_string()),
126            }
127        }
128        Ok(Self { values })
129    }
130}
131
132impl<E: ChoiceField> From<Vec<E>> for MultiChoice<E> {
133    fn from(values: Vec<E>) -> Self {
134        Self::from_vec(values)
135    }
136}
137
138impl<E: ChoiceField> FromIterator<E> for MultiChoice<E> {
139    fn from_iter<I: IntoIterator<Item = E>>(iter: I) -> Self {
140        Self {
141            values: iter.into_iter().collect(),
142        }
143    }
144}
145
146impl<E: ChoiceField> IntoIterator for MultiChoice<E> {
147    type Item = E;
148    type IntoIter = std::vec::IntoIter<E>;
149    fn into_iter(self) -> Self::IntoIter {
150        self.values.into_iter()
151    }
152}
153
154impl<'a, E: ChoiceField> IntoIterator for &'a MultiChoice<E> {
155    type Item = &'a E;
156    type IntoIter = std::slice::Iter<'a, E>;
157    fn into_iter(self) -> Self::IntoIter {
158        self.values.iter()
159    }
160}
161
162impl<E: ChoiceField> std::ops::Deref for MultiChoice<E> {
163    type Target = [E];
164    fn deref(&self) -> &Self::Target {
165        &self.values
166    }
167}
168
169impl<E: ChoiceField> fmt::Display for MultiChoice<E> {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(&self.to_csv())
172    }
173}
174
175impl<E: ChoiceField> FromStr for MultiChoice<E> {
176    type Err = String;
177    fn from_str(s: &str) -> Result<Self, Self::Err> {
178        Self::from_csv(s)
179    }
180}
181
182// =========================================================================
183// serde
184// =========================================================================
185
186/// On the wire (REST, admin JSON, fixtures) a `MultiChoice<E>` is the
187/// natural JSON array of strings — `["design", "frontend"]` — not the
188/// CSV form used at the DB layer. The two storage shapes are
189/// deliberately separate: the CSV form is an implementation detail of
190/// the TEXT column, and external consumers never see it.
191impl<E: ChoiceField> Serialize for MultiChoice<E> {
192    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
193    where
194        S: Serializer,
195    {
196        use serde::ser::SerializeSeq;
197        let mut seq = serializer.serialize_seq(Some(self.values.len()))?;
198        for v in &self.values {
199            seq.serialize_element(v.as_str())?;
200        }
201        seq.end()
202    }
203}
204
205/// Deserialize accepts both the natural JSON array form
206/// (`["design","frontend"]`) and the CSV string form
207/// (`"design,frontend"`). The latter is what HTML form posts produce
208/// when the admin's hidden CSV input round-trips through
209/// `application/x-www-form-urlencoded`.
210impl<'de, E: ChoiceField> Deserialize<'de> for MultiChoice<E> {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: Deserializer<'de>,
214    {
215        struct V<E>(PhantomData<E>);
216        impl<'de, E: ChoiceField> serde::de::Visitor<'de> for V<E> {
217            type Value = MultiChoice<E>;
218            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219                f.write_str("a CSV string or a JSON array of choice strings")
220            }
221            fn visit_str<X: serde::de::Error>(self, s: &str) -> Result<Self::Value, X> {
222                MultiChoice::from_csv(s)
223                    .map_err(|bad| X::custom(format!("unknown MultiChoice variant `{bad}`")))
224            }
225            fn visit_string<X: serde::de::Error>(self, s: String) -> Result<Self::Value, X> {
226                self.visit_str(&s)
227            }
228            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
229            where
230                A: serde::de::SeqAccess<'de>,
231            {
232                let mut values: Vec<E> = Vec::new();
233                while let Some(s) = seq.next_element::<String>()? {
234                    match E::from_str_ok(&s) {
235                        Some(v) => values.push(v),
236                        None => {
237                            return Err(serde::de::Error::custom(format!(
238                                "unknown MultiChoice variant `{s}`"
239                            )));
240                        }
241                    }
242                }
243                Ok(MultiChoice { values })
244            }
245        }
246        deserializer.deserialize_any(V::<E>(PhantomData))
247    }
248}
249
250// =========================================================================
251// sqlx — TEXT column on both backends
252// =========================================================================
253
254impl<E: ChoiceField, DB: Database> Type<DB> for MultiChoice<E>
255where
256    String: Type<DB>,
257{
258    fn type_info() -> DB::TypeInfo {
259        <String as Type<DB>>::type_info()
260    }
261    fn compatible(ty: &DB::TypeInfo) -> bool {
262        <String as Type<DB>>::compatible(ty)
263    }
264}
265
266impl<'q, E: ChoiceField> Encode<'q, Sqlite> for MultiChoice<E> {
267    fn encode_by_ref(
268        &self,
269        buf: &mut <Sqlite as Database>::ArgumentBuffer<'q>,
270    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
271        let csv = self.to_csv();
272        <String as Encode<'q, Sqlite>>::encode(csv, buf)
273    }
274}
275
276impl<'r, E: ChoiceField> Decode<'r, Sqlite> for MultiChoice<E> {
277    fn decode(value: <Sqlite as Database>::ValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
278        let s = <String as Decode<'r, Sqlite>>::decode(value)?;
279        MultiChoice::<E>::from_csv(&s).map_err(|bad| {
280            format!(
281                "unknown MultiChoice<{}> variant `{bad}`",
282                std::any::type_name::<E>()
283            )
284            .into()
285        })
286    }
287}
288
289impl<'q, E: ChoiceField> Encode<'q, Postgres> for MultiChoice<E> {
290    fn encode_by_ref(
291        &self,
292        buf: &mut <Postgres as Database>::ArgumentBuffer<'q>,
293    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
294        let csv = self.to_csv();
295        <String as Encode<'q, Postgres>>::encode(csv, buf)
296    }
297}
298
299impl<'r, E: ChoiceField> Decode<'r, Postgres> for MultiChoice<E> {
300    fn decode(
301        value: <Postgres as Database>::ValueRef<'r>,
302    ) -> Result<Self, sqlx::error::BoxDynError> {
303        let s = <String as Decode<'r, Postgres>>::decode(value)?;
304        MultiChoice::<E>::from_csv(&s).map_err(|bad| {
305            format!(
306                "unknown MultiChoice<{}> variant `{bad}`",
307                std::any::type_name::<E>()
308            )
309            .into()
310        })
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::orm::ChoiceField;
318
319    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
320    enum Tag {
321        Design,
322        Frontend,
323        Backend,
324    }
325
326    impl ChoiceField for Tag {
327        const VALUES: &'static [&'static str] = &["design", "frontend", "backend"];
328        const LABELS: &'static [&'static str] = &["Design", "Frontend", "Backend"];
329        fn as_str(&self) -> &'static str {
330            match self {
331                Tag::Design => "design",
332                Tag::Frontend => "frontend",
333                Tag::Backend => "backend",
334            }
335        }
336        fn from_str_ok(s: &str) -> Option<Self> {
337            match s {
338                "design" => Some(Tag::Design),
339                "frontend" => Some(Tag::Frontend),
340                "backend" => Some(Tag::Backend),
341                _ => None,
342            }
343        }
344    }
345
346    #[test]
347    fn csv_roundtrip() {
348        let mc: MultiChoice<Tag> = vec![Tag::Design, Tag::Backend].into();
349        assert_eq!(mc.to_csv(), "design,backend");
350        let parsed: MultiChoice<Tag> = MultiChoice::from_csv("design,backend").unwrap();
351        assert_eq!(parsed, mc);
352    }
353
354    #[test]
355    fn empty_csv_is_empty_selection() {
356        let mc: MultiChoice<Tag> = MultiChoice::from_csv("").unwrap();
357        assert!(mc.is_empty());
358        assert_eq!(mc.to_csv(), "");
359    }
360
361    #[test]
362    fn csv_trims_whitespace_and_skips_blanks() {
363        let mc: MultiChoice<Tag> = MultiChoice::from_csv(" design , , backend ").unwrap();
364        assert_eq!(mc.as_slice(), &[Tag::Design, Tag::Backend]);
365    }
366
367    #[test]
368    fn csv_rejects_unknown_variant() {
369        let err = MultiChoice::<Tag>::from_csv("design,bogus").unwrap_err();
370        assert_eq!(err, "bogus");
371    }
372
373    #[test]
374    fn serde_emits_json_array() {
375        let mc: MultiChoice<Tag> = vec![Tag::Design, Tag::Frontend].into();
376        let json = serde_json::to_string(&mc).unwrap();
377        assert_eq!(json, r#"["design","frontend"]"#);
378    }
379
380    #[test]
381    fn serde_accepts_json_array() {
382        let mc: MultiChoice<Tag> = serde_json::from_str(r#"["design","backend"]"#).unwrap();
383        assert_eq!(mc.as_slice(), &[Tag::Design, Tag::Backend]);
384    }
385
386    #[test]
387    fn serde_accepts_csv_string() {
388        let mc: MultiChoice<Tag> = serde_json::from_str(r#""design,backend""#).unwrap();
389        assert_eq!(mc.as_slice(), &[Tag::Design, Tag::Backend]);
390    }
391
392    #[test]
393    fn deref_to_slice() {
394        let mc: MultiChoice<Tag> = vec![Tag::Design].into();
395        let s: &[Tag] = &mc;
396        assert_eq!(s, &[Tag::Design]);
397    }
398}