Skip to main content

vespertide_core/schema/
names.rs

1//! Newtype wrappers for schema identifiers (tables, columns, indexes).
2//!
3//! These wrap `String` to provide compile-time type safety: a function
4//! taking `TableName` cannot accidentally receive a `ColumnName`. Wire
5//! format is preserved exactly via `#[serde(transparent)]` — JSON
6//! migration scripts, schema files, and CLI output deserialize/serialize
7//! byte-identically with the previous String-alias version.
8//!
9//! Convention: always `snake_case`, enforced by CLI / planner naming
10//! validation rather than by the type system.
11
12use std::fmt;
13
14/// The name of a database table, always in `snake_case` by convention.
15///
16/// Construction:
17///
18/// ```rust
19/// use vespertide_core::schema::names::TableName;
20///
21/// let via_new: TableName = TableName::new("user");
22/// let via_from: TableName = "user".into();
23///
24/// assert_eq!(via_new.as_str(), "user");
25/// assert!(via_new == "user");
26/// assert_eq!(via_new.to_string(), "user");
27/// assert_eq!(via_new, via_from);
28/// ```
29///
30/// JSON wire format is byte-identical to a plain `String` thanks to
31/// `#[serde(transparent)]`. See [`ColumnName`] for a serde round-trip example.
32#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34#[serde(transparent)]
35#[cfg_attr(feature = "schema", schemars(transparent))]
36pub struct TableName(String);
37
38/// The name of a table column, always in `snake_case` by convention.
39///
40/// Construction and serde round-trip:
41///
42/// ```rust
43/// use vespertide_core::schema::names::ColumnName;
44///
45/// let col: ColumnName = ColumnName::new("email");
46/// let via_from: ColumnName = "email".into();
47///
48/// assert_eq!(col.as_str(), "email");
49/// assert!(col == "email");
50/// assert_eq!(col, via_from);
51///
52/// // Wire format is byte-identical to a plain JSON string.
53/// let json = serde_json::to_string(&col).unwrap();
54/// assert_eq!(json, r#""email""#);
55/// let back: ColumnName = serde_json::from_str(&json).unwrap();
56/// assert_eq!(back, col);
57/// ```
58#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(transparent)]
61#[cfg_attr(feature = "schema", schemars(transparent))]
62pub struct ColumnName(String);
63
64/// The name of a database index, conventionally `ix_{table}__{columns}`.
65///
66/// Construction:
67///
68/// ```rust
69/// use vespertide_core::schema::names::IndexName;
70///
71/// let idx: IndexName = IndexName::new("ix_user__email");
72/// let via_from: IndexName = "ix_user__email".into();
73///
74/// assert_eq!(idx.as_str(), "ix_user__email");
75/// assert!(idx == "ix_user__email");
76/// assert_eq!(idx.to_string(), "ix_user__email");
77/// assert_eq!(idx, via_from);
78/// ```
79#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[serde(transparent)]
82#[cfg_attr(feature = "schema", schemars(transparent))]
83pub struct IndexName(String);
84
85// Implement common ergonomics. Each newtype gets the same impl block via a
86// declarative macro to avoid 60 lines of triplication.
87macro_rules! impl_name_newtype {
88    ($ty:ident) => {
89        impl $ty {
90            #[must_use]
91            pub fn new(s: impl Into<String>) -> Self {
92                Self(s.into())
93            }
94
95            #[must_use]
96            pub fn as_str(&self) -> &str {
97                &self.0
98            }
99
100            #[must_use]
101            pub fn into_inner(self) -> String {
102                self.0
103            }
104        }
105
106        impl From<String> for $ty {
107            fn from(s: String) -> Self {
108                Self(s)
109            }
110        }
111
112        impl From<&str> for $ty {
113            fn from(s: &str) -> Self {
114                Self(s.to_string())
115            }
116        }
117
118        impl From<$ty> for String {
119            fn from(t: $ty) -> Self {
120                t.0
121            }
122        }
123
124        impl From<&$ty> for String {
125            fn from(t: &$ty) -> Self {
126                t.0.clone()
127            }
128        }
129
130        impl AsRef<str> for $ty {
131            fn as_ref(&self) -> &str {
132                &self.0
133            }
134        }
135
136        impl std::ops::Deref for $ty {
137            type Target = str;
138            fn deref(&self) -> &str {
139                &self.0
140            }
141        }
142
143        impl fmt::Display for $ty {
144            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145                fmt::Display::fmt(&self.0, f)
146            }
147        }
148
149        impl fmt::Debug for $ty {
150            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151                fmt::Debug::fmt(&self.0, f)
152            }
153        }
154
155        impl std::borrow::Borrow<str> for $ty {
156            fn borrow(&self) -> &str {
157                &self.0
158            }
159        }
160
161        impl PartialEq<str> for $ty {
162            fn eq(&self, other: &str) -> bool {
163                self.0 == other
164            }
165        }
166
167        impl PartialEq<&str> for $ty {
168            fn eq(&self, other: &&str) -> bool {
169                &self.0 == *other
170            }
171        }
172
173        impl PartialEq<String> for $ty {
174            fn eq(&self, other: &String) -> bool {
175                &self.0 == other
176            }
177        }
178    };
179}
180
181impl_name_newtype!(TableName);
182impl_name_newtype!(ColumnName);
183impl_name_newtype!(IndexName);
184
185#[cfg(test)]
186mod tests {
187    //! Coverage-closure tests for the `impl_name_newtype!` expansions.
188    //! Tarpaulin attributes hits at the macro definition lines (91, 92 for
189    //! `new`, 119, 120 for `From<$ty> for String`). Doctests do not run
190    //! under tarpaulin, so we exercise the same paths from real `#[test]`s.
191    use super::*;
192
193    #[test]
194    fn table_name_new_constructs_from_str_literal() {
195        // Covers lines 91, 92 via TableName::new.
196        let name = TableName::new("user");
197        assert_eq!(name.as_str(), "user");
198    }
199
200    #[test]
201    fn column_name_new_constructs_from_owned_string() {
202        // Covers lines 91, 92 via ColumnName::new (different newtype).
203        let name = ColumnName::new(String::from("email"));
204        assert_eq!(name.as_str(), "email");
205    }
206
207    #[test]
208    fn index_name_new_constructs_from_str_ref() {
209        // Covers lines 91, 92 via IndexName::new.
210        let owned = "ix_user__email".to_string();
211        let name = IndexName::new(&*owned);
212        assert_eq!(name.as_str(), "ix_user__email");
213    }
214
215    #[test]
216    fn table_name_into_string_via_from() {
217        // Covers lines 119, 120 (`From<TableName> for String`).
218        let name = TableName::new("orders");
219        let s: String = String::from(name);
220        assert_eq!(s, "orders");
221    }
222
223    #[test]
224    fn column_name_into_string_via_from() {
225        // Covers lines 119, 120 (`From<ColumnName> for String`).
226        let name = ColumnName::new("created_at");
227        let s: String = String::from(name);
228        assert_eq!(s, "created_at");
229    }
230
231    #[test]
232    fn index_name_into_string_via_from() {
233        // Covers lines 119, 120 (`From<IndexName> for String`).
234        let name = IndexName::new("ix_orders__id");
235        let s: String = String::from(name);
236        assert_eq!(s, "ix_orders__id");
237    }
238}