AkurAI Build
Menu

Public repository / 89d76abbb01a83de2e71d39c6321fc0d7ef581be

AkurAI-Framework / crates/collections/tests/relations.rs

Latest change 802374bdcc6dcc0421804cba41a30f724cc4b6dc - feat(collections): relation fields with referential checks and ?expand= read-time expansion by Ólafur Búi Ólafsson

//! Tests for relation fields: schema, validation, `_meta`, and read-time
//! expansion. Every test gets its own file under `CARGO_TARGET_TMPDIR`.

use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};

use akurai_collections::{meta, CollError, Collection, Field, FieldKind, Store};
use akurai_json::Value;

static COUNTER: AtomicU32 = AtomicU32::new(0);

fn db_path() -> PathBuf {
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let mut p = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
    p.push(format!("rel-test-{n}-{}.db", std::process::id()));
    let _ = std::fs::remove_file(&p);
    p
}

fn obj(pairs: Vec<(&str, Value)>) -> Value {
    Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}

fn s(v: &str) -> Value {
    Value::Str(v.to_string())
}

fn authors() -> Collection {
    Collection::new(
        "authors",
        vec![Field::new("name", FieldKind::Text).required()],
    )
}

/// `posts` with an `author` relation pointing at `authors`.
fn posts() -> Collection {
    Collection::new(
        "posts",
        vec![
            Field::new("title", FieldKind::Text).required(),
            Field::relation("author", "authors"),
        ],
    )
}

#[test]
fn relation_field_stores_id_and_expands_on_read() {
    let authors = authors();
    let posts = posts();
    let all = vec![authors.clone(), posts.clone()];
    let mut store = Store::open(db_path()).unwrap();

    let ada = store
        .create(&authors, obj(vec![("name", s("Ada"))]))
        .unwrap();
    let ada_id = ada.get("id").and_then(Value::as_i64).unwrap();
    assert_eq!(ada_id, 1);

    let post = store
        .create(
            &posts,
            obj(vec![("title", s("Hello")), ("author", Value::Int(ada_id))]),
        )
        .unwrap();
    // The relation stores the plain integer id.
    assert_eq!(post.get("author").and_then(Value::as_i64), Some(1));
    // Without expansion, get/list are unchanged (no `_expanded` key).
    let plain = store.get(&posts, 1).unwrap().unwrap();
    assert!(plain.get("author_expanded").is_none());

    // get_expanded inlines the referenced author under `author_expanded`.
    let expanded = store
        .get_expanded(&posts, 1, &["author"], &all)
        .unwrap()
        .unwrap();
    assert_eq!(expanded.get("author").and_then(Value::as_i64), Some(1));
    let inlined = expanded.get("author_expanded").unwrap();
    assert_eq!(inlined.get("name").and_then(Value::as_str), Some("Ada"));
    assert_eq!(inlined.get("id").and_then(Value::as_i64), Some(1));

    // list_expanded does the same for every record.
    let listed = store
        .list_expanded(&posts, None, &["author"], &all)
        .unwrap();
    assert_eq!(listed.len(), 1);
    assert_eq!(
        listed[0]
            .get("author_expanded")
            .and_then(|v| v.get("name"))
            .and_then(Value::as_str),
        Some("Ada")
    );
}

#[test]
fn dangling_and_absent_references_expand_to_null() {
    let authors = authors();
    let posts = posts();
    let all = vec![authors.clone(), posts.clone()];
    let mut store = Store::open(db_path()).unwrap();

    // Reference an author id that does not exist (unchecked create accepts it).
    store
        .create(
            &posts,
            obj(vec![("title", s("Dangling")), ("author", Value::Int(999))]),
        )
        .unwrap();
    // A post with no author at all.
    store
        .create(&posts, obj(vec![("title", s("No author"))]))
        .unwrap();

    let dangling = store
        .get_expanded(&posts, 1, &["author"], &all)
        .unwrap()
        .unwrap();
    assert_eq!(dangling.get("author_expanded"), Some(&Value::Null));

    let absent = store
        .get_expanded(&posts, 2, &["author"], &all)
        .unwrap()
        .unwrap();
    assert_eq!(absent.get("author_expanded"), Some(&Value::Null));
}

#[test]
fn unknown_expand_names_are_ignored() {
    let authors = authors();
    let posts = posts();
    let all = vec![authors.clone(), posts.clone()];
    let mut store = Store::open(db_path()).unwrap();
    store
        .create(&authors, obj(vec![("name", s("Ada"))]))
        .unwrap();
    store
        .create(
            &posts,
            obj(vec![("title", s("T")), ("author", Value::Int(1))]),
        )
        .unwrap();

    // "title" is not a relation; "missing" is not a field at all. Both ignored.
    let expanded = store
        .get_expanded(&posts, 1, &["title", "missing"], &all)
        .unwrap()
        .unwrap();
    assert!(expanded.get("title_expanded").is_none());
    assert!(expanded.get("missing_expanded").is_none());
    // Asking to expand nothing yields the record unchanged.
    let none = store.get_expanded(&posts, 1, &[], &all).unwrap().unwrap();
    assert!(none.get("author_expanded").is_none());
}

#[test]
fn relation_validation_rejects_non_integer() {
    let posts = posts();
    let mut store = Store::open(db_path()).unwrap();
    let err = store
        .create(
            &posts,
            obj(vec![("title", s("T")), ("author", s("not an int"))]),
        )
        .unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));
}

#[test]
fn checked_create_verifies_referenced_record_exists() {
    let authors = authors();
    let posts = posts();
    let all = vec![authors.clone(), posts.clone()];
    let mut store = Store::open(db_path()).unwrap();

    // No author #1 yet → checked create must reject the dangling reference.
    let err = store
        .create_checked(
            &posts,
            obj(vec![("title", s("T")), ("author", Value::Int(1))]),
            &all,
        )
        .unwrap_err();
    assert!(matches!(err, CollError::Validation(_)));

    // Create the author, then the checked create succeeds.
    store
        .create(&authors, obj(vec![("name", s("Ada"))]))
        .unwrap();
    let ok = store
        .create_checked(
            &posts,
            obj(vec![("title", s("T")), ("author", Value::Int(1))]),
            &all,
        )
        .unwrap();
    assert_eq!(ok.get("author").and_then(Value::as_i64), Some(1));

    // checked update is likewise verified.
    let bad = store
        .update_checked(&posts, 1, obj(vec![("author", Value::Int(42))]), &all)
        .unwrap_err();
    assert!(matches!(bad, CollError::Validation(_)));
}

#[test]
fn checked_create_skips_existence_when_target_unknown() {
    let posts = posts();
    // `all` does NOT include the `authors` target, so existence isn't checked.
    let all = vec![posts.clone()];
    let mut store = Store::open(db_path()).unwrap();
    let ok = store
        .create_checked(
            &posts,
            obj(vec![("title", s("T")), ("author", Value::Int(7))]),
            &all,
        )
        .unwrap();
    assert_eq!(ok.get("author").and_then(Value::as_i64), Some(7));
}

#[test]
fn meta_reports_relation_type_and_target() {
    let manifest = meta(&[posts()]);
    let collections = match manifest.get("collections").unwrap() {
        Value::Array(a) => a,
        _ => panic!("collections should be an array"),
    };
    let fields = match collections[0].get("fields").unwrap() {
        Value::Array(a) => a,
        _ => panic!("fields should be an array"),
    };
    // field[1] is the `author` relation.
    let author = &fields[1];
    assert_eq!(author.get("name").and_then(Value::as_str), Some("author"));
    assert_eq!(author.get("type").and_then(Value::as_str), Some("relation"));
    assert_eq!(
        author.get("collection").and_then(Value::as_str),
        Some("authors")
    );
    // A non-relation field carries no `collection` key.
    assert!(fields[0].get("collection").is_none());
}

#[test]
fn relations_round_trip_across_reopen() {
    let authors = authors();
    let posts = posts();
    let all = vec![authors.clone(), posts.clone()];
    let path = db_path();
    {
        let mut store = Store::open(&path).unwrap();
        store
            .create(&authors, obj(vec![("name", s("Ada"))]))
            .unwrap();
        store
            .create(
                &posts,
                obj(vec![("title", s("Kept")), ("author", Value::Int(1))]),
            )
            .unwrap();
    }
    // Reopen and confirm the relation id and expansion survive.
    let mut store = Store::open(&path).unwrap();
    let expanded = store
        .get_expanded(&posts, 1, &["author"], &all)
        .unwrap()
        .unwrap();
    assert_eq!(expanded.get("author").and_then(Value::as_i64), Some(1));
    assert_eq!(
        expanded
            .get("author_expanded")
            .and_then(|v| v.get("name"))
            .and_then(Value::as_str),
        Some("Ada")
    );
}