1use crate::schema::TableSchema;
2
3#[diagnostic::on_unimplemented(
7 message = "`{Self}` is not a Rango model",
8 label = "this type does not implement `Model`",
9 note = "add `#[derive(Model)]` (or `#[rango::model]`) to `{Self}`"
10)]
11pub trait Model: crate::ModelHooks {
12 type Pk: PrimaryKey;
15
16 fn table_name() -> &'static str;
18
19 fn schema() -> TableSchema;
21}
22
23pub trait PrimaryKey:
26 Copy
27 + Eq
28 + std::hash::Hash
29 + std::fmt::Debug
30 + Send
31 + Sync
32 + 'static
33 + crate::ToSqlValue
34 + serde::Serialize
35 + serde::de::DeserializeOwned
36{
37 const COLUMN_TYPE: crate::ColumnType;
39
40 fn null_value() -> crate::SqlValue;
42
43 fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError>;
45}
46
47impl PrimaryKey for uuid::Uuid {
48 const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Uuid;
49 fn null_value() -> crate::SqlValue {
50 crate::SqlValue::NullUuid
51 }
52 fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
53 row.get_uuid(col)
54 }
55}
56
57impl PrimaryKey for i32 {
58 const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Int;
59 fn null_value() -> crate::SqlValue {
60 crate::SqlValue::NullInt
61 }
62 fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
63 row.get_i32(col)
64 }
65}
66
67impl PrimaryKey for i64 {
68 const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::BigInt;
69 fn null_value() -> crate::SqlValue {
70 crate::SqlValue::NullBigInt
71 }
72 fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
73 row.get_i64(col)
74 }
75}
76
77pub trait PrimaryKeyField {
81 type Pk: PrimaryKey;
82}
83
84impl PrimaryKeyField for crate::FieldUuid {
85 type Pk = uuid::Uuid;
86}
87impl PrimaryKeyField for crate::FieldInt {
88 type Pk = i32;
89}
90impl PrimaryKeyField for crate::FieldAutoInt {
91 type Pk = i32;
92}
93impl PrimaryKeyField for crate::FieldBigInt {
94 type Pk = i64;
95}
96impl PrimaryKeyField for crate::FieldAutoBigInt {
97 type Pk = i64;
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
103pub enum UnsupportedPk {}
104
105impl crate::ToSqlValue for UnsupportedPk {
106 fn to_sql_value(&self) -> crate::SqlValue {
107 match *self {}
108 }
109}
110
111impl PrimaryKey for UnsupportedPk {
112 const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Uuid;
113 fn null_value() -> crate::SqlValue {
114 crate::SqlValue::NullUuid
115 }
116 fn from_row(_: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
117 Err(crate::RowError::new(format!(
118 "{col}: the target model has no UUID, integer or bigint primary key"
119 )))
120 }
121}
122
123pub trait ModelMixin {
126 fn mixin_name() -> &'static str;
127}
128
129#[doc(hidden)]
133pub fn unknown_column<M: Model>(name: &str) -> Option<String> {
134 let schema = M::schema();
136 if schema.columns.iter().any(|c| c.name == name) {
137 return None;
138 }
139 let hint = schema
140 .columns
141 .iter()
142 .map(|c| (edit_distance(name, &c.name), &c.name))
143 .min()
144 .filter(|(d, c)| *d <= c.len().max(name.len()).div_ceil(3))
146 .map(|(_, c)| format!(", did you mean `{c}`?"))
147 .unwrap_or_default();
148 Some(format!(
149 "unknown column `{name}` on table `{}`{hint}",
150 M::table_name()
151 ))
152}
153
154#[doc(hidden)]
156pub fn check_columns<'a, M: Model>(names: impl IntoIterator<Item = &'a str>) -> crate::Result<()> {
157 for n in names {
158 if let Some(msg) = unknown_column::<M>(n) {
159 return Err(crate::Error::Invalid(msg));
160 }
161 }
162 Ok(())
163}
164
165fn edit_distance(a: &str, b: &str) -> usize {
167 let b: Vec<char> = b.chars().collect();
168 let mut prev: Vec<usize> = (0..=b.len()).collect();
169 for (i, ca) in a.chars().enumerate() {
170 let mut cur = vec![i + 1];
171 for (j, cb) in b.iter().enumerate() {
172 let sub = prev[j] + usize::from(ca != *cb);
173 cur.push(sub.min(prev[j + 1] + 1).min(cur[j] + 1));
174 }
175 prev = cur;
176 }
177 prev[b.len()]
178}
179
180#[cfg(test)]
181mod fuzz_tests {
182 use super::{Model, unknown_column};
185 use crate::query::{quote_ident, quote_ident_mysql};
186 use crate::schema::{ColumnDef, ColumnType, TableSchema};
187
188 struct XorShift64(u64);
189
190 impl XorShift64 {
191 fn new(seed: u64) -> Self {
192 Self(seed)
193 }
194
195 fn next_u64(&mut self) -> u64 {
196 let mut x = self.0;
197 x ^= x << 13;
198 x ^= x >> 7;
199 x ^= x << 17;
200 self.0 = x;
201 x
202 }
203 }
204
205 const SEED: u64 = 0x1234_5678_9abc_def0;
206
207 const ALPHABET: [char; 18] = [
208 '"', '`', '\'', ';', '-', '/', '*', '\\', ' ', '\n', '\0', 'a', 'Z', '_', 'é', '€',
209 '\u{FF02}', '\u{202E}',
210 ];
211
212 fn random_string(rng: &mut XorShift64, max_len: usize) -> String {
213 let len = (rng.next_u64() % max_len as u64) as usize;
214 (0..len)
215 .map(|_| ALPHABET[(rng.next_u64() % ALPHABET.len() as u64) as usize])
216 .collect()
217 }
218
219 fn unquote(s: &str, q: char) -> Option<String> {
220 let mut chars = s.chars();
221 if chars.next()? != q {
222 return None;
223 }
224
225 let mut out = String::new();
226 loop {
227 match chars.next()? {
228 c if c == q => match chars.next() {
229 Some(q2) if q2 == q => out.push(q),
230 None => return Some(out),
231 Some(_) => return None,
232 },
233 c => out.push(c),
234 }
235 }
236 }
237
238 struct TestModel;
239
240 impl crate::ModelHooks for TestModel {}
241
242 impl Model for TestModel {
243 type Pk = uuid::Uuid;
244
245 fn table_name() -> &'static str {
246 "test_models"
247 }
248
249 fn schema() -> TableSchema {
250 TableSchema {
251 table_name: "test_models".to_string(),
252 columns: vec![
253 column("id", true, false, true),
254 column("email", false, true, true),
255 column("created_at", false, false, false),
256 ],
257 ..TableSchema::default()
258 }
259 }
260 }
261
262 fn column(name: &str, primary_key: bool, unique: bool, index: bool) -> ColumnDef {
263 ColumnDef {
264 name: name.to_string(),
265 col_type: ColumnType::Text,
266 nullable: false,
267 primary_key,
268 unique,
269 default: None,
270 references: None,
271 index,
272 }
273 }
274
275 #[test]
276 fn quote_ident_is_one_token_roundtrip() {
277 let mut rng = XorShift64::new(SEED);
278 for _ in 0..10_000 {
279 let s = random_string(&mut rng, 32);
280 assert_eq!(
281 unquote("e_ident(&s), '"'),
282 Some(s.clone()),
283 "quote_ident roundtrip failed for input {:?}",
284 s
285 );
286 assert_eq!(
287 unquote("e_ident_mysql(&s), '`'),
288 Some(s.clone()),
289 "quote_ident_mysql roundtrip failed for input {:?}",
290 s
291 );
292 }
293 }
294
295 #[test]
296 fn unknown_column_suggests_close_names() {
297 let msg = unknown_column::<TestModel>("emial").unwrap();
298 assert!(msg.ends_with("did you mean `email`?"), "{msg}");
299 let msg = unknown_column::<TestModel>("zzzzzz").unwrap();
300 assert!(!msg.contains("did you mean"), "{msg}");
301 }
302
303 #[test]
304 fn unknown_column_only_accepts_exact_names() {
305 for name in ["id", "email", "created_at"] {
306 assert_eq!(
307 unknown_column::<TestModel>(name),
308 None,
309 "exact column name should be known: {:?}",
310 name
311 );
312 }
313
314 let mut rng = XorShift64::new(SEED);
315 for _ in 0..10_000 {
316 let s = random_string(&mut rng, 32);
317 if s != "id" && s != "email" && s != "created_at" {
318 assert!(
319 unknown_column::<TestModel>(&s).is_some(),
320 "non-exact name should be unknown: {:?}",
321 s
322 );
323 }
324 }
325
326 let near_misses = [
327 "Email",
328 "email ",
329 " email",
330 "email\0",
331 "\"email\"",
332 "email;--",
333 "email\u{200B}",
334 "ema\u{456}l",
335 "",
336 ];
337 for name in near_misses {
338 assert!(
339 unknown_column::<TestModel>(name).is_some(),
340 "near-miss name should be unknown: {:?}",
341 name
342 );
343 }
344 }
345}