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}