cratestack-sqlx 0.3.1

Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services.
Documentation
use cratestack_sql::{FilterValue, OrderTarget};

use crate::{
    FieldRef, FilterExpr, ModelColumn, ModelDescriptor, OrderClause, PolicyExpr, ReadPolicy,
    ReadPredicate, SortDirection, SqlColumnValue, SqlValue,
    query::render_update_preview_sql,
    render::{render_filter_expr_sql, render_order_clause_sql, render_read_policy_sql},
};
use cratestack_core::{CoolContext, Value};

#[test]
fn select_projection_aliases_sql_columns_to_rust_fields() {
    let descriptor = ModelDescriptor::<(), i64>::new(
        "Post",
        "posts",
        &[
            ModelColumn {
                rust_name: "id",
                sql_name: "id",
            },
            ModelColumn {
                rust_name: "authorId",
                sql_name: "author_id",
            },
        ],
        "id",
        &["id", "authorId"],
        &["author"],
        &["id", "authorId", "author.email"],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        &[],
        None,
        false,
        &[],
        &[],
        None,
        None,
        &[],
    );

    assert_eq!(
        descriptor.select_projection(),
        "id AS \"id\", author_id AS \"authorId\""
    );
}

#[test]
fn create_preview_sql_numbers_placeholders() {
    let values = [
        SqlColumnValue {
            column: "title",
            value: SqlValue::String("hello".to_owned()),
        },
        SqlColumnValue {
            column: "published",
            value: SqlValue::Bool(true),
        },
    ];

    let columns = values
        .iter()
        .map(|value| value.column)
        .collect::<Vec<_>>()
        .join(", ");
    let placeholders = (1..=values.len())
        .map(|index| format!("${index}"))
        .collect::<Vec<_>>()
        .join(", ");

    assert_eq!(columns, "title, published");
    assert_eq!(placeholders, "$1, $2");
}

#[test]
fn field_ref_builds_filter_and_order() {
    let filter = FieldRef::<(), bool>::new("published").is_true();
    let order = FieldRef::<(), String>::new("title").desc();
    let contains = FieldRef::<(), String>::new("title").contains("hel");
    let maybe_null = FieldRef::<(), Option<String>>::new("subtitle").is_null();
    let in_filter = FieldRef::<(), i64>::new("id").in_([1_i64, 2_i64]);

    assert_eq!(filter.column, "published");
    assert_eq!(filter.value, FilterValue::Single(SqlValue::Bool(true)));
    assert_eq!(order.direction, SortDirection::Desc);
    assert!(matches!(order.target, OrderTarget::Column("title")));
    assert_eq!(
        contains.value,
        FilterValue::Single(SqlValue::String("%hel%".to_owned()))
    );
    assert_eq!(maybe_null.column, "subtitle");
    assert_eq!(
        in_filter.value,
        FilterValue::Many(vec![SqlValue::Int(1), SqlValue::Int(2)])
    );
}

#[test]
fn relation_filters_render_explicit_quantifiers() {
    let some = FilterExpr::relation_some(
        "users",
        "id",
        "sessions",
        "user_id",
        FieldRef::<(), String>::new("label")
            .contains("Revoked")
            .into(),
    );
    let every = FilterExpr::relation_every(
        "users",
        "id",
        "sessions",
        "user_id",
        FieldRef::<(), String>::new("label")
            .contains("Session")
            .into(),
    );
    let none = FilterExpr::relation_none(
        "users",
        "id",
        "sessions",
        "user_id",
        FieldRef::<(), Option<String>>::new("revoked_at")
            .is_not_null()
            .into(),
    );
    let mut bind_index = 1usize;
    let mut some_sql = String::new();
    render_filter_expr_sql(&some, &mut some_sql, &mut bind_index);
    let mut every_sql = String::new();
    render_filter_expr_sql(&every, &mut every_sql, &mut bind_index);
    let mut none_sql = String::new();
    render_filter_expr_sql(&none, &mut none_sql, &mut bind_index);

    assert_eq!(
        some_sql,
        "EXISTS (SELECT 1 FROM sessions WHERE sessions.user_id = users.id AND label LIKE $1)"
    );
    assert_eq!(
        every_sql,
        "NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.user_id = users.id AND NOT (label LIKE $2))"
    );
    assert_eq!(
        none_sql,
        "NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.user_id = users.id AND revoked_at IS NOT NULL)"
    );
}

#[test]
fn relation_scalar_order_preview_uses_correlated_subquery() {
    let clause = OrderClause::relation_scalar(
        "posts",
        "author_id",
        "users",
        "id",
        "users.email",
        SortDirection::Asc,
    );
    let mut sql = String::new();
    render_order_clause_sql(&clause, &mut sql);

    assert_eq!(
        sql,
        "(SELECT users.email FROM users WHERE users.id = posts.author_id LIMIT 1) ASC NULLS LAST"
    );
}

#[test]
fn relation_policy_preview_uses_exists_subquery() {
    let policy = [ReadPolicy {
        expr: PolicyExpr::Predicate(ReadPredicate::Relation {
            quantifier: crate::RelationQuantifier::ToOne,
            parent_table: "posts",
            parent_column: "author_id",
            related_table: "users",
            related_column: "id",
            expr: &PolicyExpr::Predicate(ReadPredicate::FieldEqAuth {
                column: "email",
                auth_field: "email",
            }),
        }),
    }];
    let ctx = CoolContext::authenticated([(
        "email".to_owned(),
        Value::String("owner@example.com".to_owned()),
    )]);

    let mut bind_index = 1usize;
    let sql = render_read_policy_sql(&policy, &[], &ctx, &mut bind_index)
        .expect("policy preview should render");

    assert_eq!(
        sql,
        "EXISTS (SELECT 1 FROM users WHERE users.id = posts.author_id AND email = $1)"
    );
}

#[test]
fn nested_relation_policy_preview_uses_recursive_exists_and_quantifiers() {
    let policy = [ReadPolicy {
        expr: PolicyExpr::Predicate(ReadPredicate::Relation {
            quantifier: crate::RelationQuantifier::ToOne,
            parent_table: "posts",
            parent_column: "author_id",
            related_table: "users",
            related_column: "id",
            expr: &PolicyExpr::Predicate(ReadPredicate::Relation {
                quantifier: crate::RelationQuantifier::Every,
                parent_table: "users",
                parent_column: "id",
                related_table: "memberships",
                related_column: "user_id",
                expr: &PolicyExpr::Predicate(ReadPredicate::FieldEqLiteral {
                    column: "active",
                    value: crate::PolicyLiteral::Bool(true),
                }),
            }),
        }),
    }];
    let ctx = CoolContext::anonymous();

    let mut bind_index = 1usize;
    let sql = render_read_policy_sql(&policy, &[], &ctx, &mut bind_index)
        .expect("policy preview should render");

    assert_eq!(
        sql,
        "EXISTS (SELECT 1 FROM users WHERE users.id = posts.author_id AND NOT EXISTS (SELECT 1 FROM memberships WHERE memberships.user_id = users.id AND NOT (active = $1)))"
    );
}

#[test]
fn filter_expr_and_or_flattens_matching_groups() {
    let left = FilterExpr::from(FieldRef::<(), i64>::new("id").eq(1_i64));
    let right = FilterExpr::from(FieldRef::<(), bool>::new("published").is_true());
    let third = FilterExpr::from(FieldRef::<(), String>::new("title").contains("Post"));

    let and_expr = left.clone().and(right.clone()).and(third.clone());
    let or_expr = left.or(right).or(third);

    assert!(matches!(and_expr, FilterExpr::All(filters) if filters.len() == 3));
    assert!(matches!(or_expr, FilterExpr::Any(filters) if filters.len() == 3));
}

#[test]
fn filter_expr_not_wraps_and_unwraps_double_negation() {
    let filter = FilterExpr::from(FieldRef::<(), bool>::new("published").is_true());
    let negated = filter.clone().not();
    let restored = negated.clone().not();
    let mut bind_index = 1usize;
    let mut sql = String::new();
    render_filter_expr_sql(&negated, &mut sql, &mut bind_index);

    assert_eq!(sql, "NOT (published = $1)");
    assert_eq!(restored, filter);
}

#[test]
fn update_preview_sql_unversioned_renders_simple_where_clause() {
    let sql = render_update_preview_sql(
        "accounts",
        "id",
        None,
        &["balance", "updated_at"],
        "id AS \"id\", balance AS \"balance\"",
    );
    assert_eq!(
        sql,
        "UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3 RETURNING id AS \"id\", balance AS \"balance\""
    );
}

#[test]
fn update_preview_sql_versioned_bumps_version_and_filters_on_expected() {
    let sql = render_update_preview_sql(
        "accounts",
        "id",
        Some("version"),
        &["balance"],
        "id AS \"id\", version AS \"version\"",
    );
    assert_eq!(
        sql,
        "UPDATE accounts SET balance = $1, version = version + 1 WHERE id = $2 AND version = $3 RETURNING id AS \"id\", version AS \"version\""
    );
}