cratestack_sql/values/decimal_like.rs
1//! Object-safe decimal payload for [`super::SqlValue::Decimal`]
2//! (cratestack#505 Direction 2 — see
3//! `docs/design/decimal-backend-additivity.md` §7(b)).
4//!
5//! `SqlValue` is L1 shared infrastructure: exactly one compiled copy per
6//! build, matched exhaustively across `cratestack-sqlx` and
7//! `cratestack-rusqlite`. Before this change its `Decimal` variant held
8//! the single concrete `cratestack_core::Decimal` alias, which is exactly
9//! the union-collision cratestack#505 reports — two independent schemas
10//! choosing different backends can't both put their value into the same
11//! enum variant naming one fixed type.
12//!
13//! `Box<dyn DecimalLike>` sidesteps that without making `SqlValue` itself
14//! generic (which would have propagated a type parameter through
15//! `ModelDescriptor`/`ReadSource`/`WriteSource` and every generated model
16//! struct — see the design doc §7's cost discussion of that alternative,
17//! "(a)"). Any concrete decimal type can be boxed into this variant; the
18//! two backend-specific encode/decode boundaries
19//! (`cratestack-sqlx::push_bind_value`, `cratestack-rusqlite`'s TEXT
20//! round-trip) downcast back to a concrete type only where they actually
21//! need one (sqlx; the rusqlite boundary never needs to downcast at all,
22//! since it only ever calls `Display`/`FromStr`).
23//!
24//! Unconditional — no `#[cfg]` gate, no dependency on `rust_decimal` or
25//! `bigdecimal` in this crate. `DecimalLike` blanket-implements for any
26//! type satisfying `cratestack_core::DecimalValue`'s bounds, so both
27//! concrete backends get it for free the moment `cratestack-core` is in
28//! scope, with no per-backend code here.
29
30use std::any::Any;
31use std::fmt::{Debug, Display};
32
33use cratestack_core::DecimalValue;
34
35/// Object-safe counterpart to [`DecimalValue`]. `Debug`/`Display` are
36/// automatically implemented for `dyn DecimalLike` (supertrait methods are
37/// always available on a trait object); `Clone`/`PartialEq` are not
38/// object-safe (`Self: Sized`), so this trait provides hand-rolled
39/// equivalents (`clone_boxed`, `dyn_eq`) that [`Box<dyn DecimalLike>`]'s
40/// own `Clone`/`PartialEq` impls (below) delegate to.
41pub trait DecimalLike: Debug + Display + Send + Sync {
42 fn clone_boxed(&self) -> Box<dyn DecimalLike>;
43 fn dyn_eq(&self, other: &dyn DecimalLike) -> bool;
44 fn as_any(&self) -> &dyn Any;
45}
46
47impl<T> DecimalLike for T
48where
49 T: DecimalValue,
50{
51 fn clone_boxed(&self) -> Box<dyn DecimalLike> {
52 Box::new(self.clone())
53 }
54
55 fn dyn_eq(&self, other: &dyn DecimalLike) -> bool {
56 other
57 .as_any()
58 .downcast_ref::<T>()
59 .is_some_and(|o| self == o)
60 }
61
62 fn as_any(&self) -> &dyn Any {
63 self
64 }
65}
66
67// `Box<T>` is a "fundamental" type (see the `#[fundamental]` attribute in
68// the standard library), which relaxes the orphan rule for exactly this
69// shape: `Clone`/`PartialEq` are foreign traits and `Box` is a foreign
70// type, but `dyn DecimalLike` is local, so `impl Clone for Box<dyn
71// DecimalLike>` is legal. This is the standard pattern for making a boxed
72// trait object `Clone`/`PartialEq`.
73impl Clone for Box<dyn DecimalLike> {
74 fn clone(&self) -> Self {
75 self.as_ref().clone_boxed()
76 }
77}
78
79impl PartialEq for Box<dyn DecimalLike> {
80 fn eq(&self, other: &Self) -> bool {
81 self.as_ref().dyn_eq(other.as_ref())
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[derive(Debug, Clone, PartialEq, PartialOrd)]
90 struct Fake(i64);
91
92 impl Display for Fake {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 write!(f, "{}", self.0)
95 }
96 }
97
98 impl std::str::FromStr for Fake {
99 type Err = std::num::ParseIntError;
100 fn from_str(s: &str) -> Result<Self, Self::Err> {
101 s.parse().map(Fake)
102 }
103 }
104
105 impl From<i64> for Fake {
106 fn from(value: i64) -> Self {
107 Fake(value)
108 }
109 }
110
111 #[test]
112 fn boxed_decimal_like_clones_and_compares_by_value() {
113 // `assert_eq!`/`assert_ne!` (not `assert!(a == b)`) trip E0507 here —
114 // their expansion needs `Box<dyn DecimalLike>: Copy` for the
115 // dereference in its match-guard comparison, which a boxed trait
116 // object never is. Plain `==`/`!=` inside `assert!` only ever
117 // reborrows, so it doesn't hit that path.
118 let a: Box<dyn DecimalLike> = Box::new(Fake(42));
119 let b = a.clone();
120 assert!(a == b);
121 let c: Box<dyn DecimalLike> = Box::new(Fake(7));
122 assert!(a != c);
123 }
124
125 #[test]
126 fn boxed_decimal_like_formats_via_display() {
127 let a: Box<dyn DecimalLike> = Box::new(Fake(42));
128 assert_eq!(a.to_string(), "42");
129 }
130}