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
185impl TableName {
186    /// Prepend `prefix` to this table name in place, reusing the existing
187    /// `String` allocation when capacity allows. No-op when
188    /// `prefix.is_empty()`.
189    ///
190    /// Unifies the two pre-existing prepend patterns in
191    /// `vespertide-core` (`action/prefix.rs`'s private `add_prefix` and
192    /// `schema/constraint.rs`'s open-coded
193    /// `format!("{prefix}{table}").into()`) — every "prefix a `TableName`"
194    /// site now reads the same way and avoids the fresh `format!`
195    /// allocation the latter shape always paid.
196    ///
197    /// ```rust
198    /// use vespertide_core::schema::names::TableName;
199    ///
200    /// let prefixed = TableName::new("user").with_prefix("tenant_");
201    /// assert_eq!(prefixed.as_str(), "tenant_user");
202    ///
203    /// // Empty prefix is a pure no-op.
204    /// let unchanged = TableName::new("user").with_prefix("");
205    /// assert_eq!(unchanged.as_str(), "user");
206    /// ```
207    #[must_use]
208    pub fn with_prefix(self, prefix: &str) -> Self {
209        if prefix.is_empty() {
210            return self;
211        }
212        let mut s = self.0;
213        s.insert_str(0, prefix);
214        Self(s)
215    }
216}
217
218/// Convert any slice of stringy items (typically a `Vec<ColumnName>`) into
219/// a `Vec<String>` — hoisted out of 14+ verbatim
220/// `xxx.iter().map(ToString::to_string).collect()` chains across the planner
221/// and query crates.
222///
223/// The generic bound `T: ToString` keeps the helper usable with both the
224/// 0.2.0 [`ColumnName`] newtype (where `Display` produces the bare
225/// identifier) and the older `&str` / `String` aliases that still sit
226/// behind a few `Vec<&&str>` collect call sites.
227///
228/// ```rust
229/// use vespertide_core::schema::names::{ColumnName, names_to_strings};
230///
231/// let cols: Vec<ColumnName> = vec!["a".into(), "b".into()];
232/// assert_eq!(names_to_strings(&cols), vec!["a".to_string(), "b".to_string()]);
233///
234/// // Works with `&str` slices too — same `ToString` bound, no allocation
235/// // change versus the inline pattern.
236/// let strs = ["x", "y", "z"];
237/// assert_eq!(names_to_strings(&strs), vec!["x".to_string(), "y".to_string(), "z".to_string()]);
238///
239/// // Empty slice is the canonical empty `Vec<String>`.
240/// let empty: Vec<ColumnName> = vec![];
241/// assert_eq!(names_to_strings(&empty), Vec::<String>::new());
242/// ```
243#[must_use]
244pub fn names_to_strings<T: ToString>(names: &[T]) -> Vec<String> {
245    names.iter().map(ToString::to_string).collect()
246}
247
248/// Join a slice of [`ColumnName`]s with a separator using a single buffer
249/// — no intermediate `Vec<String>` allocation.
250///
251/// Mirrors the buffer-push pattern used by
252/// `vespertide_query::sql::helpers::quote_idents`. Folds four open-coded
253/// `cols.iter().map(...).collect::<Vec<_>>().join(sep)` callsites in
254/// `vespertide-planner::validate::*` into a single shared helper.
255///
256/// ```rust
257/// use vespertide_core::schema::names::{ColumnName, join_column_names};
258///
259/// let cols: Vec<ColumnName> = vec!["a".into(), "b".into(), "c".into()];
260/// assert_eq!(join_column_names(&cols, ", "), "a, b, c");
261/// assert_eq!(join_column_names(&cols, "_"), "a_b_c");
262/// assert_eq!(join_column_names(&[], ", "), "");
263/// ```
264#[must_use]
265pub fn join_column_names(columns: &[ColumnName], separator: &str) -> String {
266    // Pre-size exactly: sum of column-name lengths plus one separator between
267    // each adjacent pair. Finishes matching the buffer-push pattern this
268    // helper's doc-comment claims to follow — `quote_ident` already pre-sizes
269    // via `out.reserve(name.len() + 2)`. Output stays byte-identical.
270    let cap = columns.iter().map(|c| c.as_str().len()).sum::<usize>()
271        + separator.len() * columns.len().saturating_sub(1);
272    let mut out = String::with_capacity(cap);
273    for (i, c) in columns.iter().enumerate() {
274        if i > 0 {
275            out.push_str(separator);
276        }
277        out.push_str(c.as_str());
278    }
279    out
280}
281
282#[cfg(test)]
283mod tests {
284    //! Coverage-closure tests for the `impl_name_newtype!` expansions.
285    //! Tarpaulin attributes hits at the macro definition lines (91, 92 for
286    //! `new`, 119, 120 for `From<$ty> for String`). Doctests do not run
287    //! under tarpaulin, so we exercise the same paths from real `#[test]`s.
288    use super::*;
289
290    #[test]
291    fn table_name_new_constructs_from_str_literal() {
292        // Covers lines 91, 92 via TableName::new.
293        let name = TableName::new("user");
294        assert_eq!(name.as_str(), "user");
295    }
296
297    #[test]
298    fn column_name_new_constructs_from_owned_string() {
299        // Covers lines 91, 92 via ColumnName::new (different newtype).
300        let name = ColumnName::new(String::from("email"));
301        assert_eq!(name.as_str(), "email");
302    }
303
304    #[test]
305    fn index_name_new_constructs_from_str_ref() {
306        // Covers lines 91, 92 via IndexName::new.
307        let owned = "ix_user__email".to_string();
308        let name = IndexName::new(&*owned);
309        assert_eq!(name.as_str(), "ix_user__email");
310    }
311
312    #[test]
313    fn table_name_into_string_via_from() {
314        // Covers lines 119, 120 (`From<TableName> for String`).
315        let name = TableName::new("orders");
316        let s: String = String::from(name);
317        assert_eq!(s, "orders");
318    }
319
320    #[test]
321    fn column_name_into_string_via_from() {
322        // Covers lines 119, 120 (`From<ColumnName> for String`).
323        let name = ColumnName::new("created_at");
324        let s: String = String::from(name);
325        assert_eq!(s, "created_at");
326    }
327
328    #[test]
329    fn index_name_into_string_via_from() {
330        // Covers lines 119, 120 (`From<IndexName> for String`).
331        let name = IndexName::new("ix_orders__id");
332        let s: String = String::from(name);
333        assert_eq!(s, "ix_orders__id");
334    }
335
336    #[test]
337    fn into_inner_consumes_newtype_back_into_string() {
338        // Covers the `into_inner` macro-expansion lines. `String::from` goes
339        // through the separate `From<$ty> for String` impl, so the tests above
340        // do not reach this body; call it explicitly on each newtype.
341        assert_eq!(TableName::new("user").into_inner(), "user");
342        assert_eq!(ColumnName::new("email").into_inner(), "email");
343        assert_eq!(
344            IndexName::new("ix_user__email").into_inner(),
345            "ix_user__email"
346        );
347    }
348
349    #[test]
350    fn with_prefix_empty_is_a_no_op() {
351        // Covers the `prefix.is_empty()` early return in `TableName::with_prefix`.
352        // Only the doctest exercised it, and doctests do not run under tarpaulin.
353        let unchanged = TableName::new("user").with_prefix("");
354        assert_eq!(unchanged.as_str(), "user");
355    }
356
357    #[test]
358    fn with_prefix_prepends_in_place() {
359        // Sibling of the empty case: the non-empty branch must actually prepend.
360        let prefixed = TableName::new("user").with_prefix("tenant_");
361        assert_eq!(prefixed.as_str(), "tenant_user");
362    }
363
364    #[test]
365    fn join_column_names_empty_returns_empty_string() {
366        // Empty-slice path: `for ... in []` never runs, returns the
367        // pristine `String::new()`. Locks the empty-input contract that
368        // the deleted `validate::constraint_drops::join_columns` helper
369        // used to assert directly.
370        let cols: Vec<ColumnName> = vec![];
371        assert_eq!(join_column_names(&cols, ", "), "");
372        assert_eq!(join_column_names(&cols, "_"), "");
373    }
374
375    #[test]
376    fn join_column_names_comma_separator() {
377        // The validate-diagnostic site shape: `"a, b, c"`. Single-column
378        // case must skip the separator entirely (no leading `", "`).
379        let cols: Vec<ColumnName> = vec!["a".into(), "b".into(), "c".into()];
380        assert_eq!(join_column_names(&cols, ", "), "a, b, c");
381
382        let single: Vec<ColumnName> = vec!["only".into()];
383        assert_eq!(join_column_names(&single, ", "), "only");
384    }
385
386    #[test]
387    fn join_column_names_underscore_separator() {
388        // The `ix_{table}__{cols}` index-name builder shape: `"fk_id"`.
389        // Locks the separator variation against the validate
390        // `build_suggested_index_name` callsite.
391        let cols: Vec<ColumnName> = vec!["fk".into(), "id".into()];
392        assert_eq!(join_column_names(&cols, "_"), "fk_id");
393
394        let composite: Vec<ColumnName> = vec!["tenant_id".into(), "user_id".into()];
395        assert_eq!(join_column_names(&composite, "_"), "tenant_id_user_id");
396    }
397
398    /// `names_to_strings` empty-slice produces an empty `Vec<String>`. Locks
399    /// the same empty-input contract previously baked into the inline
400    /// `xxx.iter().map(ToString::to_string).collect()` form at every replaced
401    /// callsite.
402    #[test]
403    fn names_to_strings_empty_returns_empty_vec() {
404        let cols: Vec<ColumnName> = vec![];
405        assert_eq!(names_to_strings(&cols), Vec::<String>::new());
406    }
407
408    /// `names_to_strings` on a populated slice produces the canonical
409    /// per-element `to_string()` output in source order — the contract every
410    /// planner / query crate call site silently depended on.
411    #[test]
412    fn names_to_strings_two_element_case() {
413        let cols: Vec<ColumnName> = vec!["a".into(), "b".into()];
414        let out = names_to_strings(&cols);
415        assert_eq!(out, vec!["a".to_string(), "b".to_string()]);
416    }
417}