fraiseql-cli 2.19.1

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
#![allow(clippy::panic)] // Reason: test code, a panic is the failure mechanism
//! Tests for the pagination index advice (#1307).
//!
//! No database: the catalog reads live in `fraiseql_db` and the rule that decides
//! what counts as advice is pure, so it is pinned here — in the crate's `--lib`
//! tests, which is what the required leg runs.

use fraiseql_core::schema::{AutoParams, PaginationOrder, QueryDefinition};
use fraiseql_db::postgres::IndexInfo;

use super::{Advice, advise, tie_break_key};

fn index(name: &str, keys: &[&str]) -> IndexInfo {
    IndexInfo {
        name:   name.to_string(),
        unique: false,
        keys:   keys.iter().map(|k| (*k).to_string()).collect(),
    }
}

/// A paginating list query ordered by a native column, sortable on `status`.
fn invoices() -> QueryDefinition {
    let mut q = QueryDefinition::new("invoices", "Invoice");
    q.sql_source = Some("v_invoice".to_string());
    q.pagination_order = Some(PaginationOrder::Column("pk_invoice".to_string()));
    q.auto_params = AutoParams {
        has_order_by: true,
        has_limit: true,
        ..AutoParams::default()
    };
    // As `compile --database` records it: keyed by the argument, valued by the column's type.
    q.native_columns
        .insert("status".to_string(), fraiseql_core::schema::NativeColumn::nullable("text"));
    q
}

#[test]
fn a_query_with_no_page_order_has_nothing_to_advise() {
    let mut q = invoices();
    q.pagination_order = None;
    assert_eq!(tie_break_key(&q), None);
    assert!(advise(&q, "tb_invoice", &[index("ix", &["status"])]).is_empty());
}

/// The larger cost: nothing leads with the tie-break, so every page sorts the
/// whole relation.
#[test]
fn an_unindexed_tie_break_is_reported_with_its_ddl() {
    let found = advise(&invoices(), "tb_invoice", &[]);
    let first = found.first().expect("an unindexed tie-break is advice");
    assert_eq!(
        first.advice,
        Advice::TieBreakUnindexed {
            tie_break: "pk_invoice".to_string(),
        }
    );
    assert_eq!(first.ddl, vec!["CREATE INDEX ON tb_invoice (pk_invoice);"]);
}

/// An index that *contains* the tie-break but does not lead with it does not
/// order by it: `(status, pk_invoice)` orders `pk_invoice` only within a `status`.
#[test]
fn a_tie_break_in_a_trailing_position_is_still_unindexed_for_the_default_order() {
    let found = advise(&invoices(), "tb_invoice", &[index("ix", &["status", "pk_invoice"])]);
    assert!(
        found.iter().any(|a| matches!(a.advice, Advice::TieBreakUnindexed { .. })),
        "a trailing tie-break does not serve the default ordering: {found:?}"
    );
}

/// The #1307 case itself: the sort key is indexed alone, so the tie-break turns
/// an index scan into an incremental sort.
#[test]
fn an_indexed_sort_key_without_the_tie_break_is_reported() {
    let indexes = [
        index("ix_pk", &["pk_invoice"]),
        index("ix_status", &["status"]),
    ];
    let found = advise(&invoices(), "tb_invoice", &indexes);

    let composite = found
        .iter()
        .find(|a| matches!(a.advice, Advice::MissingComposite { .. }))
        .expect("an indexed sort key with no composite is advice");
    assert_eq!(
        composite.advice,
        Advice::MissingComposite {
            sort_keys: vec!["status".to_string()],
            tie_break: "pk_invoice".to_string(),
        }
    );
    assert_eq!(composite.ddl, vec!["CREATE INDEX ON tb_invoice (status, pk_invoice);"]);
    assert!(
        !found.iter().any(|a| matches!(a.advice, Advice::TieBreakUnindexed { .. })),
        "the tie-break leads ix_pk, so it is not unindexed: {found:?}"
    );
}

/// The composite exists — the state the advice asks for produces no advice, which
/// is what makes the report actionable rather than permanent.
#[test]
fn the_composite_index_silences_both_findings() {
    let indexes = [
        index("ix_pk", &["pk_invoice"]),
        index("ix_pair", &["status", "pk_invoice"]),
    ];
    assert_eq!(advise(&invoices(), "tb_invoice", &indexes), Vec::new());
}

/// A sort key that is not indexed at all is already a full sort with or without
/// the tie-break, so a composite is not the remedy and this pass does not claim it.
#[test]
fn an_unindexed_sort_key_is_not_this_passs_finding() {
    let found = advise(&invoices(), "tb_invoice", &[index("ix_pk", &["pk_invoice"])]);
    assert_eq!(found, Vec::new(), "{found:?}");
}

/// Without `orderBy` there is no client ordering to tie-break, so only the default
/// ordering is in question.
#[test]
fn a_query_without_order_by_gets_no_composite_advice() {
    let mut q = invoices();
    q.auto_params.has_order_by = false;
    let found =
        advise(&q, "tb_invoice", &[index("ix_pk", &["pk_invoice"]), index("ix_s", &["status"])]);
    assert!(
        !found.iter().any(|a| matches!(a.advice, Advice::MissingComposite { .. })),
        "{found:?}"
    );
}

// ── The derived identity: the `id` column (#1569), never the document ─────────

fn identity_query() -> QueryDefinition {
    let mut q = invoices();
    q.pagination_order = Some(PaginationOrder::Identity);
    q
}

#[test]
fn the_derived_identity_is_the_id_column() {
    assert_eq!(tie_break_key(&identity_query()), Some("id".to_string()));
}

#[test]
fn an_index_on_the_id_column_serves_the_derived_identity() {
    let found = advise(&identity_query(), "tb_invoice", &[index("ix_id", &["id"])]);
    assert!(
        !found.iter().any(|a| matches!(a.advice, Advice::TieBreakUnindexed { .. })),
        "the id index already orders by the identity: {found:?}"
    );
}

#[test]
fn a_missing_id_index_is_advised() {
    let found = advise(&identity_query(), "tb_invoice", &[]);
    let first = found.first().expect("advice");
    assert_eq!(first.ddl, vec!["CREATE INDEX ON tb_invoice (id);"]);
}

#[test]
fn a_composite_over_the_identity_names_the_id_column_second() {
    let found = advise(
        &identity_query(),
        "tb_invoice",
        &[index("ix_id", &["id"]), index("ix_status", &["status"])],
    );
    let composite = found
        .iter()
        .find(|a| matches!(a.advice, Advice::MissingComposite { .. }))
        .expect("advice");
    assert_eq!(composite.ddl, vec!["CREATE INDEX ON tb_invoice (status, id);"]);
}

/// `native_columns` is a `HashMap`, so an unsorted report would differ between
/// runs of the same schema and a reader could not diff two doctor runs.
#[test]
fn the_sort_keys_are_reported_in_a_stable_order() {
    let mut q = invoices();
    // Keyed by argument name (`issuedAt` under camelCase), valued by type: the column is the
    // key's snake_case, never the type a value holds.
    for (key, pg_type) in [
        ("status", "text"),
        ("issuedAt", "timestamptz"),
        ("amount", "numeric"),
    ] {
        q.native_columns
            .insert(key.to_string(), fraiseql_core::schema::NativeColumn::nullable(pg_type));
    }
    let indexes = [
        index("ix_pk", &["pk_invoice"]),
        index("ix_status", &["status"]),
        index("ix_issued", &["issued_at"]),
        index("ix_amount", &["amount"]),
    ];
    let Some(Advice::MissingComposite { sort_keys, .. }) =
        advise(&q, "tb_invoice", &indexes).into_iter().map(|a| a.advice).next()
    else {
        panic!("expected composite advice");
    };
    assert_eq!(sort_keys, vec!["amount", "issued_at", "status"]);
}