icydb-model-macros 0.301.0

Procedural macros for IcyDB application models
Documentation
use crate::node::index::{Index, IndexExpressionSpec, IndexKeyItemSpec};
use darling::{FromMeta, ast::NestedMeta};
use proc_macro2::Span;
use quote::quote;
use syn::LitStr;

fn field_list(values: &[&str]) -> Vec<LitStr> {
    values
        .iter()
        .map(|value| LitStr::new(value, Span::call_site()))
        .collect()
}

fn parse_index(tokens: proc_macro2::TokenStream) -> Result<Index, darling::Error> {
    let args = NestedMeta::parse_meta_list(tokens).expect("test meta should parse");
    Index::from_list(&args)
}

#[test]
fn automatic_names_preserve_parsed_predicates_and_ignore_cosmetic_sql() {
    let resolve = |sql: &str| {
        parse_index(quote!(field = "email", predicate = #sql))
            .unwrap()
            .resolved_name("User")
            .unwrap()
    };
    let expected = resolve("active = true AND email IS NOT NULL");
    for sql in [
        " ( active=TRUE and email is not null ) ",
        "active = true AND (email IS NOT NULL)",
    ] {
        assert_eq!(resolve(sql), expected);
    }
    assert_ne!(resolve("active = false AND email IS NOT NULL"), expected);
    assert_ne!(resolve("active = true OR email IS NOT NULL"), expected);
    assert_ne!(resolve("email = 'a'"), resolve("email = 'A'"));
    assert_ne!(resolve("email = other"), resolve("email = 'other'"));
    assert_eq!(resolve("1 < score"), resolve("score > 1"));
    assert_ne!(resolve("score IN (1, 2)"), resolve("score NOT IN (1, 2)"));
}

#[test]
fn explicit_names_are_optional_and_independent_of_the_definition() {
    let index = parse_index(quote!(name = "chosen", field = "email")).unwrap();
    assert_eq!(index.resolved_name("User").unwrap(), "chosen");
    assert_eq!(index.resolved_name("Other").unwrap(), "chosen");
    let changed = parse_index(quote!(
        name = "chosen",
        field = "score",
        unique,
        predicate = "active = true"
    ))
    .unwrap();
    assert_eq!(changed.resolved_name("User").unwrap(), "chosen");
    let automatic = parse_index(quote!(field = "email")).unwrap();
    assert_eq!(
        automatic.resolved_name("User").unwrap(),
        icydb_schema::canonical_index_name("User", &["email"], false, None)
    );
}

#[test]
fn from_list_parses_scalar_field_shorthand() {
    let index = parse_index(quote!(field = "email")).expect("scalar index shorthand should parse");

    assert_eq!(
        index.fields.iter().map(LitStr::value).collect::<Vec<_>>(),
        ["email"],
    );
    assert!(!index.unique);
    assert_eq!(index.predicate, None);
}

#[test]
fn from_list_parses_fields_unique_and_predicate() {
    let index = parse_index(quote!(
        fields = ["tenant_id", "LOWER(email)"],
        unique,
        predicate = "active = true"
    ))
    .expect("index fields syntax should parse");

    assert_eq!(
        index.fields.iter().map(LitStr::value).collect::<Vec<_>>(),
        ["tenant_id", "LOWER(email)"],
    );
    assert!(index.unique);
    assert_eq!(index.predicate.as_deref(), Some("active = true"));
}

#[test]
fn from_list_rejects_mixed_field_and_fields_syntax() {
    let err = parse_index(quote!(field = "email", fields = ["tenant_id", "email"]))
        .expect_err("index field and fields syntax should be mutually exclusive");

    assert!(
        err.to_string().contains(
            "index(...) accepts either one field = \"...\" argument or one fields = [...] argument"
        ),
        "unexpected error: {err}",
    );
}

#[test]
fn from_list_rejects_comma_string_fields() {
    let err = parse_index(quote!(fields = "tenant_id, email"))
        .expect_err("index fields should reject comma strings");

    assert!(
        err.to_string().contains("not a comma-string"),
        "unexpected error: {err}",
    );
}

#[test]
fn parsed_key_items_accept_supported_expression_and_field_mix() {
    let index = Index {
        name: None,
        fields: field_list(&["tenant_id", "LOWER(email)"]),
        unique: true,
        predicate: None,
    };

    let key_items = index
        .parsed_key_items()
        .expect("supported index fields should parse");

    assert_eq!(
        key_items,
        vec![
            IndexKeyItemSpec::Field(syn::parse_quote!(tenant_id)),
            IndexKeyItemSpec::Expression(IndexExpressionSpec::Lower(syn::parse_quote!(email))),
        ],
    );
}

#[test]
fn generated_name_uses_expression_key_item_canonical_text() {
    let index = Index {
        name: None,
        fields: field_list(&["LOWER(email)"]),
        unique: false,
        predicate: None,
    };

    assert_eq!(
        index.resolved_name("User").unwrap(),
        icydb_schema::canonical_index_name("User", &["LOWER(email)"], false, None)
    );
}