fletch-orm 1.0.0

Lightweight database toolkit built on SQLx
Documentation
#![cfg(all(
    feature = "sqlite",
    feature = "uuid",
    feature = "chrono",
    feature = "json"
))]
#![expect(
    clippy::unwrap_used,
    reason = "test fixture — setup failure should panic"
)]

use chrono::{DateTime, Utc};
use fletch_orm::built_query::BuiltQuery;
use fletch_orm::{Column, ColumnType, Entity, FromRow, Pool, Value};
use serde_json::Value as JsonValue;
use uuid::Uuid;

const TEST_UUID: Uuid = uuid::uuid!("550e8400-e29b-41d4-a716-446655440000");
const TEST_TS_STR: &str = "2024-06-15T12:30:45Z";
const TEST_JSON: &str = r#"{"key":"value","nested":{"arr":[1,2,null]}}"#;

fn test_ts() -> DateTime<Utc> {
    DateTime::parse_from_rfc3339(TEST_TS_STR)
        .unwrap()
        .with_timezone(&Utc)
}

fn test_json() -> JsonValue {
    serde_json::from_str(TEST_JSON).unwrap()
}

#[derive(Debug, Clone, PartialEq, FromRow)]
struct TypedRecord {
    id: i64,
    record_uuid: Uuid,
    metadata: JsonValue,
    created_at: DateTime<Utc>,
}

impl Entity for TypedRecord {
    type Id = i64;

    fn table_name() -> &'static str {
        "typed_records"
    }

    fn id_column() -> &'static str {
        "id"
    }

    fn columns() -> &'static [Column] {
        static COLS: [Column; 4] = [
            Column::new("id", ColumnType::Integer),
            Column::new("record_uuid", ColumnType::Uuid),
            Column::new("metadata", ColumnType::Json),
            Column::new("created_at", ColumnType::Timestamp),
        ];
        &COLS
    }

    fn id(&self) -> Self::Id {
        self.id
    }

    fn values(&self) -> Vec<Value> {
        vec![
            Value::I64(self.id),
            Value::Uuid(self.record_uuid),
            Value::Json(self.metadata.clone()),
            Value::Timestamp(self.created_at),
        ]
    }
}

#[derive(Debug, PartialEq, FromRow)]
struct NullableRow {
    id: i64,
    maybe_json: Option<JsonValue>,
}

async fn setup_pool() -> Pool<sqlx::Sqlite> {
    let pool = Pool::<sqlx::Sqlite>::connect("sqlite::memory:")
        .await
        .unwrap();
    sqlx::query(
        "CREATE TABLE typed_records (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            record_uuid TEXT NOT NULL,
            metadata TEXT NOT NULL,
            created_at TEXT NOT NULL
        )",
    )
    .execute(pool.inner())
    .await
    .unwrap();
    pool
}

async fn seed_record(pool: &Pool<sqlx::Sqlite>) {
    sqlx::query(
        "INSERT INTO typed_records (id, record_uuid, metadata, created_at) VALUES (1, ?, ?, ?)",
    )
    .bind(TEST_UUID)
    .bind(test_json())
    .bind(test_ts())
    .execute(pool.inner())
    .await
    .unwrap();
}

async fn setup_nullable_pool() -> Pool<sqlx::Sqlite> {
    let pool = Pool::<sqlx::Sqlite>::connect("sqlite::memory:")
        .await
        .unwrap();
    sqlx::query(
        "CREATE TABLE nullable_typed (
            id INTEGER PRIMARY KEY,
            maybe_json TEXT
        )",
    )
    .execute(pool.inner())
    .await
    .unwrap();
    pool
}

#[tokio::test]
async fn fetch_all_binds_uuid() {
    let pool = setup_pool().await;
    seed_record(&pool).await;

    let rows: Vec<TypedRecord> = pool
        .fetch_all(
            "SELECT id, record_uuid, metadata, created_at FROM typed_records WHERE record_uuid = ?",
            &[Value::Uuid(TEST_UUID)],
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].record_uuid, TEST_UUID);
}

#[tokio::test]
async fn fetch_all_binds_json() {
    let pool = setup_pool().await;
    seed_record(&pool).await;

    let rows: Vec<TypedRecord> = pool
        .fetch_all(
            "SELECT id, record_uuid, metadata, created_at FROM typed_records WHERE metadata = ?",
            &[Value::Json(test_json())],
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].metadata, test_json());
}

#[tokio::test]
async fn fetch_all_binds_timestamp() {
    let pool = setup_pool().await;
    seed_record(&pool).await;

    let rows: Vec<TypedRecord> = pool
        .fetch_all(
            "SELECT id, record_uuid, metadata, created_at FROM typed_records WHERE created_at = ?",
            &[Value::Timestamp(test_ts())],
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].created_at, test_ts());
}

#[tokio::test]
async fn fetch_all_binds_all_three_in_where() {
    let pool = setup_pool().await;
    seed_record(&pool).await;

    let rows: Vec<TypedRecord> = pool
        .fetch_all(
            "SELECT id, record_uuid, metadata, created_at FROM typed_records \
             WHERE record_uuid = ? AND metadata = ? AND created_at = ?",
            &[
                Value::Uuid(TEST_UUID),
                Value::Json(test_json()),
                Value::Timestamp(test_ts()),
            ],
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].record_uuid, TEST_UUID);
    assert_eq!(rows[0].metadata, test_json());
    assert_eq!(rows[0].created_at, test_ts());
}

#[tokio::test]
async fn insert_and_find_by_id_typed_columns() {
    let pool = setup_pool().await;

    let entity = TypedRecord {
        id: 0,
        record_uuid: TEST_UUID,
        metadata: test_json(),
        created_at: test_ts(),
    };

    let inserted = pool.insert(&entity).await.unwrap();
    assert_ne!(inserted.id, 0);
    assert_eq!(inserted.record_uuid, TEST_UUID);
    assert_eq!(inserted.metadata, test_json());
    assert_eq!(inserted.created_at, test_ts());

    let found = pool.find_by_id::<TypedRecord>(inserted.id).await.unwrap();
    assert_eq!(found, inserted);
}

#[tokio::test]
async fn fetch_all_binds_json_null() {
    let pool = setup_nullable_pool().await;

    BuiltQuery {
        sql: "INSERT INTO nullable_typed (id, maybe_json) VALUES (1, ?)".into(),
        values: vec![Value::Null],
    }
    .execute::<sqlx::Sqlite, _>(pool.inner())
    .await
    .unwrap();

    let rows: Vec<NullableRow> = pool
        .fetch_all(
            "SELECT id, maybe_json FROM nullable_typed WHERE id = ?",
            &[Value::I64(1)],
        )
        .await
        .unwrap();

    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].maybe_json, None);
}