use crate::schema::TableSchema;
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a Rango model",
label = "this type does not implement `Model`",
note = "add `#[derive(Model)]` (or `#[rango::model]`) to `{Self}`"
)]
pub trait Model: crate::ModelHooks {
type Pk: PrimaryKey;
fn table_name() -> &'static str;
fn schema() -> TableSchema;
}
pub trait PrimaryKey:
Copy
+ Eq
+ std::hash::Hash
+ std::fmt::Debug
+ Send
+ Sync
+ 'static
+ crate::ToSqlValue
+ serde::Serialize
+ serde::de::DeserializeOwned
{
const COLUMN_TYPE: crate::ColumnType;
fn null_value() -> crate::SqlValue;
fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError>;
}
impl PrimaryKey for uuid::Uuid {
const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Uuid;
fn null_value() -> crate::SqlValue {
crate::SqlValue::NullUuid
}
fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
row.get_uuid(col)
}
}
impl PrimaryKey for i32 {
const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Int;
fn null_value() -> crate::SqlValue {
crate::SqlValue::NullInt
}
fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
row.get_i32(col)
}
}
impl PrimaryKey for i64 {
const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::BigInt;
fn null_value() -> crate::SqlValue {
crate::SqlValue::NullBigInt
}
fn from_row(row: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
row.get_i64(col)
}
}
pub trait PrimaryKeyField {
type Pk: PrimaryKey;
}
impl PrimaryKeyField for crate::FieldUuid {
type Pk = uuid::Uuid;
}
impl PrimaryKeyField for crate::FieldInt {
type Pk = i32;
}
impl PrimaryKeyField for crate::FieldAutoInt {
type Pk = i32;
}
impl PrimaryKeyField for crate::FieldBigInt {
type Pk = i64;
}
impl PrimaryKeyField for crate::FieldAutoBigInt {
type Pk = i64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum UnsupportedPk {}
impl crate::ToSqlValue for UnsupportedPk {
fn to_sql_value(&self) -> crate::SqlValue {
match *self {}
}
}
impl PrimaryKey for UnsupportedPk {
const COLUMN_TYPE: crate::ColumnType = crate::ColumnType::Uuid;
fn null_value() -> crate::SqlValue {
crate::SqlValue::NullUuid
}
fn from_row(_: &dyn crate::RangoRow, col: &str) -> Result<Self, crate::RowError> {
Err(crate::RowError::new(format!(
"{col}: the target model has no UUID, integer or bigint primary key"
)))
}
}
pub trait ModelMixin {
fn mixin_name() -> &'static str;
}
#[doc(hidden)]
pub fn unknown_column<M: Model>(name: &str) -> Option<String> {
let schema = M::schema();
if schema.columns.iter().any(|c| c.name == name) {
return None;
}
let hint = schema
.columns
.iter()
.map(|c| (edit_distance(name, &c.name), &c.name))
.min()
.filter(|(d, c)| *d <= c.len().max(name.len()).div_ceil(3))
.map(|(_, c)| format!(", did you mean `{c}`?"))
.unwrap_or_default();
Some(format!(
"unknown column `{name}` on table `{}`{hint}",
M::table_name()
))
}
#[doc(hidden)]
pub fn check_columns<'a, M: Model>(names: impl IntoIterator<Item = &'a str>) -> crate::Result<()> {
for n in names {
if let Some(msg) = unknown_column::<M>(n) {
return Err(crate::Error::Invalid(msg));
}
}
Ok(())
}
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let sub = prev[j] + usize::from(ca != *cb);
cur.push(sub.min(prev[j + 1] + 1).min(cur[j] + 1));
}
prev = cur;
}
prev[b.len()]
}
#[cfg(test)]
mod fuzz_tests {
use super::{Model, unknown_column};
use crate::query::{quote_ident, quote_ident_mysql};
use crate::schema::{ColumnDef, ColumnType, TableSchema};
struct XorShift64(u64);
impl XorShift64 {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
}
const SEED: u64 = 0x1234_5678_9abc_def0;
const ALPHABET: [char; 18] = [
'"', '`', '\'', ';', '-', '/', '*', '\\', ' ', '\n', '\0', 'a', 'Z', '_', 'é', '€',
'\u{FF02}', '\u{202E}',
];
fn random_string(rng: &mut XorShift64, max_len: usize) -> String {
let len = (rng.next_u64() % max_len as u64) as usize;
(0..len)
.map(|_| ALPHABET[(rng.next_u64() % ALPHABET.len() as u64) as usize])
.collect()
}
fn unquote(s: &str, q: char) -> Option<String> {
let mut chars = s.chars();
if chars.next()? != q {
return None;
}
let mut out = String::new();
loop {
match chars.next()? {
c if c == q => match chars.next() {
Some(q2) if q2 == q => out.push(q),
None => return Some(out),
Some(_) => return None,
},
c => out.push(c),
}
}
}
struct TestModel;
impl crate::ModelHooks for TestModel {}
impl Model for TestModel {
type Pk = uuid::Uuid;
fn table_name() -> &'static str {
"test_models"
}
fn schema() -> TableSchema {
TableSchema {
table_name: "test_models".to_string(),
columns: vec![
column("id", true, false, true),
column("email", false, true, true),
column("created_at", false, false, false),
],
..TableSchema::default()
}
}
}
fn column(name: &str, primary_key: bool, unique: bool, index: bool) -> ColumnDef {
ColumnDef {
name: name.to_string(),
col_type: ColumnType::Text,
nullable: false,
primary_key,
unique,
default: None,
references: None,
index,
}
}
#[test]
fn quote_ident_is_one_token_roundtrip() {
let mut rng = XorShift64::new(SEED);
for _ in 0..10_000 {
let s = random_string(&mut rng, 32);
assert_eq!(
unquote("e_ident(&s), '"'),
Some(s.clone()),
"quote_ident roundtrip failed for input {:?}",
s
);
assert_eq!(
unquote("e_ident_mysql(&s), '`'),
Some(s.clone()),
"quote_ident_mysql roundtrip failed for input {:?}",
s
);
}
}
#[test]
fn unknown_column_suggests_close_names() {
let msg = unknown_column::<TestModel>("emial").unwrap();
assert!(msg.ends_with("did you mean `email`?"), "{msg}");
let msg = unknown_column::<TestModel>("zzzzzz").unwrap();
assert!(!msg.contains("did you mean"), "{msg}");
}
#[test]
fn unknown_column_only_accepts_exact_names() {
for name in ["id", "email", "created_at"] {
assert_eq!(
unknown_column::<TestModel>(name),
None,
"exact column name should be known: {:?}",
name
);
}
let mut rng = XorShift64::new(SEED);
for _ in 0..10_000 {
let s = random_string(&mut rng, 32);
if s != "id" && s != "email" && s != "created_at" {
assert!(
unknown_column::<TestModel>(&s).is_some(),
"non-exact name should be unknown: {:?}",
s
);
}
}
let near_misses = [
"Email",
"email ",
" email",
"email\0",
"\"email\"",
"email;--",
"email\u{200B}",
"ema\u{456}l",
"",
];
for name in near_misses {
assert!(
unknown_column::<TestModel>(name).is_some(),
"near-miss name should be unknown: {:?}",
name
);
}
}
}