Skip to main content

rango_orm_core/
model.rs

1use crate::schema::TableSchema;
2
3/// Core trait implemented by `#[derive(Model)]`.
4/// Every model must be able to describe its own schema.
5/// `ModelHooks` is a supertrait: the derive emits an empty impl unless `#[model(hooks)]` is set.
6#[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    /// The Rust type of the primary key value (`Uuid`, `i32`, `i64`).
13    /// `ForeignKey<Self>` and `ManyToMany<Self>` columns follow it.
14    type Pk: PrimaryKey;
15
16    /// The table name in the database.
17    fn table_name() -> &'static str;
18
19    /// The full schema description for this model.
20    fn schema() -> TableSchema;
21}
22
23/// A primary key value type that a `ForeignKey<M>` column can hold: `Uuid` (the default),
24/// `i32` (`FieldAutoInt` / `FieldInt`) or `i64` (`FieldAutoBigInt` / `FieldBigInt`).
25pub 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    /// Type of a foreign key column pointing at this key.
38    const COLUMN_TYPE: crate::ColumnType;
39
40    /// The SQL NULL of this type (for `Option<ForeignKey<M>>`).
41    fn null_value() -> crate::SqlValue;
42
43    /// Reads the key from `col`.
44    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
77/// The key type of a primary key field type: `FieldUuid` -> `Uuid`, `FieldAutoInt` and
78/// `FieldInt` -> `i32`, `FieldAutoBigInt` and `FieldBigInt` -> `i64`.
79/// Used by `#[derive(Model)]` to set `Model::Pk`.
80pub 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/// `Model::Pk` of a model whose primary key is none of the types above (a text key, say).
101/// Nothing can build one, so `ForeignKey<Model>` is unusable for it. Set by the derive.
102#[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
123/// Marker trait for abstract model mixins (`#[derive(ModelMixin)]`).
124/// A mixin is never a real table — its fields get flattened into models that use it.
125pub trait ModelMixin {
126    fn mixin_name() -> &'static str;
127}
128
129/// Defense in depth for column names passed as strings (`.eq("email", ..)`): an unknown
130/// column becomes a typed error before any SQL runs, instead of a database error.
131/// Returns the message for `Error::Invalid` so query builders can defer it to execution.
132#[doc(hidden)]
133pub fn unknown_column<M: Model>(name: &str) -> Option<String> {
134    // ponytail: builds M::schema() per call; cache the column list if it shows up in profiles.
135    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        // Close enough to be a typo: at most a third of the name.
145        .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/// Check that every name is a column of `M`; `Error::Invalid` otherwise (before any SQL runs).
155#[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
165/// Levenshtein distance, two rows.
166fn 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    //! Fuzz tests for identifier quoting and exact column matching.
183
184    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(&quote_ident(&s), '"'),
282                Some(s.clone()),
283                "quote_ident roundtrip failed for input {:?}",
284                s
285            );
286            assert_eq!(
287                unquote(&quote_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}