tablo-core 0.10.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
//! Dependent choices: the related rows whose column equals the parent field's value.

use toasty::stmt::{List, Query};
use topcoat::context::Cx;

use crate::{
    DeclarationErrorKind,
    schema::{ChoiceField, Field, OptionSource, Schema},
    test_support::memory_db,
    values::FormValue,
};

#[derive(Debug, toasty::Model, Clone)]
struct Country {
    #[key]
    #[auto]
    id: uuid::Uuid,
    name: String,
}

#[derive(Debug, toasty::Model, Clone)]
struct City {
    #[key]
    #[auto]
    id: uuid::Uuid,
    country_id: uuid::Uuid,
    name: String,
}

#[derive(Debug, toasty::Model)]
struct Address {
    #[key]
    #[auto]
    id: uuid::Uuid,
    country_id: uuid::Uuid,
    city_id: uuid::Uuid,
}

macro_rules! source {
    ($source:ident, $model:ident $(, $creates:literal)?) => {
        struct $source;
        impl OptionSource for $source {
            type Model = $model;
            $(fn creates() -> bool {
                $creates
            })?
            fn label(_cx: &Cx, record: &$model) -> String {
                record.name.clone()
            }
            fn scoped_query(_cx: &Cx) -> topcoat::Result<Query<List<$model>>> {
                Ok(Query::all())
            }
            fn lists(_cx: &Cx) -> bool {
                true
            }
        }
    };
}

source!(Countries, Country);
source!(Cities, City);
// Sources with a create page, as a resource is.
source!(CreatingCountries, Country, true);
source!(CreatingCities, City, true);

fn country() -> ChoiceField {
    Field::choice(Address::fields().country_id()).relationship::<Countries>()
}

fn city(country: &ChoiceField) -> ChoiceField {
    Field::choice(Address::fields().city_id())
        .relationship::<Cities>()
        .depends_on(country, City::fields().country_id())
}

/// Two countries with one city each.
struct World {
    cx: Cx,
    france: String,
    paris: String,
    berlin: String,
}

async fn world() -> World {
    let mut db = memory_db(toasty::models!(Country, City, Address)).await;
    let mut country_with = async |country: &str, city: &str| {
        let country = toasty::create!(Country {
            name: country.to_string()
        })
        .exec(&mut db)
        .await
        .unwrap();
        let city = toasty::create!(City {
            country_id: country.id,
            name: city.to_string(),
        })
        .exec(&mut db)
        .await
        .unwrap();
        (country.id.to_string(), city.id.to_string())
    };
    let (france, paris) = country_with("France", "Paris").await;
    let (_, berlin) = country_with("Germany", "Berlin").await;
    World {
        cx: Cx::default().with(db),
        france,
        paris,
        berlin,
    }
}

/// A submission must name a row of the parent value it posts, at validation and in the write.
#[tokio::test]
async fn a_dependent_choice_refuses_a_row_of_another_parent_value() {
    let world = world().await;
    let country = country();
    let city = city(&country);
    let check = async |value: &str, parent: Option<&str>| {
        city.validate_exists(&world.cx, &value.into(), parent, None)
            .await
    };
    assert!(check(&world.paris, Some(&world.france)).await.is_empty());
    for parent in [
        Some(world.france.as_str()),
        Some(""),
        None,
        Some("not-a-key"),
    ] {
        assert_eq!(
            check(&world.berlin, parent).await,
            ["City id is invalid"],
            "parent {parent:?}"
        );
    }

    let mut handle = crate::db::db(&world.cx).unwrap();
    let mut tx = handle.transaction().await.unwrap();
    assert!(
        city.recheck(
            &world.cx,
            &(&world.paris).into(),
            Some(&world.france),
            None,
            &mut tx
        )
        .await
        .is_empty()
    );
    assert_eq!(
        city.recheck(
            &world.cx,
            &(&world.berlin).into(),
            Some(&world.france),
            None,
            &mut tx
        )
        .await,
        ["City id is invalid"]
    );
}

#[test]
fn a_dependent_choice_names_a_column_of_its_source_and_a_placed_parent() {
    let misdeclared = |field: ChoiceField| {
        let country = country();
        let field = field.depends_on(&country, City::fields().country_id());
        Schema::new((country, field)).declaration_errors()
    };
    let error = [DeclarationErrorKind::MisdeclaredDependentChoice {
        field: "city_id".to_string(),
    }];
    assert_eq!(
        misdeclared(Field::choice(Address::fields().city_id()).relationship::<Countries>()),
        error,
        "a column of another model than the source"
    );
    assert_eq!(
        misdeclared(Field::choice(Address::fields().city_id()).options(["a"])),
        error,
        "no relationship to narrow"
    );

    assert_eq!(
        Schema::new(
            Field::choice(Address::fields().city_id())
                .options(["a"])
                .options_where(|_cx| City::fields().name().eq("Paris"))
        )
        .declaration_errors(),
        [DeclarationErrorKind::UnfilterableChoice {
            field: "city_id".to_string(),
        }],
        "a filter with no relationship to narrow"
    );

    assert_eq!(
        Schema::new(city(&country())).declaration_errors(),
        [DeclarationErrorKind::UnplacedParentField {
            field: "city_id".to_string(),
            parent: "country_id".to_string(),
        }]
    );

    // The browser lists a radio or button choice's options once, so it cannot narrow them; a
    // choice can still depend on a radio parent, whose checked radio posts the value.
    for listed in [city(&country()).radio(), city(&country()).buttons()] {
        assert_eq!(
            Schema::new((country(), listed)).declaration_errors(),
            [DeclarationErrorKind::ListedDependentChoice {
                field: "city_id".to_string(),
            }]
        );
    }
    let radio = country().radio();
    let dependent = city(&radio);
    assert!(
        Schema::new((radio, dependent))
            .declaration_errors()
            .is_empty()
    );
    let country = country();
    let city = city(&country);
    assert!(Schema::new((country, city)).declaration_errors().is_empty());
}

/// Past the cap a choice that cannot search keeps the key the record stores, which its select
/// renders, and refuses any other.
#[tokio::test]
async fn a_choice_past_the_cap_keeps_its_stored_key() {
    let world = world().await;
    let france: uuid::Uuid = world.france.parse().unwrap();
    let mut db = crate::db::db(&world.cx).unwrap();
    for i in 0..crate::schema::MAX_RELATIONSHIP_OPTIONS {
        toasty::create!(City {
            country_id: france,
            name: format!("Town {i}"),
        })
        .exec(&mut db)
        .await
        .unwrap();
    }
    let country = country();
    let city = city(&country);
    let paris = FormValue::from(world.paris.as_str());
    let check = async |stored: Option<&FormValue>| {
        city.validate_exists(&world.cx, &paris, Some(&world.france), stored)
            .await
    };
    assert!(check(Some(&paris)).await.is_empty(), "the stored key");
    assert_eq!(
        check(None).await,
        ["City id could not load options, retry"],
        "a key the record does not store"
    );
}

/// Past the cap a searchable dependent choice checks the posted key among its parent's rows.
#[tokio::test]
async fn a_searchable_dependent_choice_past_the_cap_checks_among_its_parent_rows() {
    let world = world().await;
    let france: uuid::Uuid = world.france.parse().unwrap();
    let mut db = crate::db::db(&world.cx).unwrap();
    for i in 0..crate::schema::MAX_RELATIONSHIP_OPTIONS {
        toasty::create!(City {
            country_id: france,
            name: format!("Town {i}"),
        })
        .exec(&mut db)
        .await
        .unwrap();
    }
    let country = country();
    let city = city(&country).searchable();
    assert!(
        city.validate_exists(&world.cx, &(&world.paris).into(), Some(&world.france), None)
            .await
            .is_empty()
    );
    assert_eq!(
        city.validate_exists(
            &world.cx,
            &(&world.berlin).into(),
            Some(&world.france),
            None
        )
        .await,
        ["City id is invalid"]
    );
}

#[test]
fn a_creatable_choice_offers_a_resource_s_records() {
    let error = |field: &str| {
        [DeclarationErrorKind::UncreatableChoice {
            field: field.to_string(),
        }]
    };
    assert_eq!(
        Schema::new(country().creatable()).declaration_errors(),
        error("country_id"),
        "a source that is not a resource has no create page"
    );
    assert_eq!(
        Schema::new(
            Field::choice(Address::fields().country_id())
                .options(["a"])
                .creatable()
        )
        .declaration_errors(),
        error("country_id"),
        "static options have no related record"
    );
}

#[test]
fn only_a_single_independent_enabled_select_is_creatable() {
    let creating =
        || Field::choice(Address::fields().country_id()).relationship::<CreatingCountries>();
    let refused = [DeclarationErrorKind::UncreatableChoice {
        field: "country_id".to_string(),
    }];
    let errors = |schema: Schema| {
        schema
            .declaration_errors()
            .into_iter()
            .filter(|error| matches!(error, DeclarationErrorKind::UncreatableChoice { .. }))
            .collect::<Vec<_>>()
    };
    assert_eq!(errors(Schema::new(creating().creatable())), []);
    assert_eq!(
        errors(Schema::new(creating().multiple().creatable())),
        refused,
        "a multiple choice"
    );
    assert_eq!(
        errors(Schema::new(creating().buttons().creatable())),
        refused,
        "buttons"
    );
    let shown = Field::choice(Address::fields().city_id()).options(["a", "b"]);
    assert_eq!(
        errors(Schema::new((
            creating().disabled_when(&shown, ["a"]).creatable(),
            shown,
        ))),
        refused,
        "a choice a condition disables posts nothing"
    );
    let country = country();
    let city = Field::choice(Address::fields().city_id())
        .relationship::<CreatingCities>()
        .depends_on(&country, City::fields().country_id())
        .creatable();
    assert_eq!(
        errors(Schema::new((country, city))),
        [DeclarationErrorKind::UncreatableChoice {
            field: "city_id".to_string(),
        }],
        "a dependent choice"
    );
}