qail-core 1.1.1

AST-native query builder - type-safe expressions, zero SQL strings
Documentation
use crate::transpiler::nosql::{dynamo::ToDynamo, mongo::ToMongo, qdrant::ToQdrant};

#[test]
fn test_qdrant_search() {
    use crate::ast::*;
    // Qdrant with vector search uses special syntax, use manual construction
    let mut cmd = Qail::get("points");
    cmd.columns.push(Expr::Named("id".to_string()));
    cmd.columns.push(Expr::Named("score".to_string()));
    cmd.cages.push(Cage {
        kind: CageKind::Filter,
        conditions: vec![
            Condition {
                left: Expr::Named("vector".to_string()),
                op: Operator::Fuzzy,
                value: Value::String("cute cat".to_string()),
                is_array_unnest: false,
            },
            Condition {
                left: Expr::Named("city".to_string()),
                op: Operator::Eq,
                value: Value::String("London".to_string()),
                is_array_unnest: false,
            },
        ],
        logical_op: LogicalOp::And,
    });
    cmd.cages.push(Cage {
        kind: CageKind::Limit(10),
        conditions: vec![],
        logical_op: LogicalOp::And,
    });
    let qdrant = cmd.to_qdrant_search();

    assert!(qdrant.contains("{{EMBED:cute cat}}"));
    assert!(qdrant.contains("\"filter\": { \"must\": ["));
    assert!(qdrant.contains("\"key\": \"city\", \"match\": { \"value\": \"London\" }"));
}

#[test]
fn test_qdrant_or_filter_output() {
    use crate::ast::{Operator, Qail};

    let qdrant = Qail::get("points")
        .or_filter("city", Operator::Eq, "London")
        .or_filter("country", Operator::Eq, "UK")
        .to_qdrant_search();

    assert!(
        qdrant.contains("\"should\": ["),
        "Expected should group: {qdrant}"
    );
}

#[test]
fn test_qdrant_and_plus_or_filter_output() {
    use crate::ast::{Operator, Qail};

    let qdrant = Qail::get("points")
        .filter("is_active", Operator::Eq, true)
        .or_filter("city", Operator::Eq, "London")
        .or_filter("country", Operator::Eq, "UK")
        .to_qdrant_search();

    assert!(
        qdrant.contains("\"must\": ["),
        "Expected must group: {qdrant}"
    );
    assert!(
        qdrant.contains("\"should\": ["),
        "Expected should group: {qdrant}"
    );
}

#[test]
fn test_qdrant_multiple_or_cages_remain_separate_groups() {
    use crate::ast::{Cage, CageKind, Condition, Expr, LogicalOp, Operator, Qail, Value};

    let qdrant = Qail {
        table: "points".to_string(),
        cages: vec![
            Cage {
                kind: CageKind::Filter,
                conditions: vec![Condition {
                    left: Expr::Named("tenant_id".to_string()),
                    op: Operator::Eq,
                    value: Value::String("t1".to_string()),
                    is_array_unnest: false,
                }],
                logical_op: LogicalOp::And,
            },
            Cage {
                kind: CageKind::Filter,
                conditions: vec![
                    Condition {
                        left: Expr::Named("city".to_string()),
                        op: Operator::Eq,
                        value: Value::String("London".to_string()),
                        is_array_unnest: false,
                    },
                    Condition {
                        left: Expr::Named("city".to_string()),
                        op: Operator::Eq,
                        value: Value::String("Paris".to_string()),
                        is_array_unnest: false,
                    },
                ],
                logical_op: LogicalOp::Or,
            },
            Cage {
                kind: CageKind::Filter,
                conditions: vec![
                    Condition {
                        left: Expr::Named("country".to_string()),
                        op: Operator::Eq,
                        value: Value::String("UK".to_string()),
                        is_array_unnest: false,
                    },
                    Condition {
                        left: Expr::Named("country".to_string()),
                        op: Operator::Eq,
                        value: Value::String("FR".to_string()),
                        is_array_unnest: false,
                    },
                ],
                logical_op: LogicalOp::Or,
            },
        ],
        ..Default::default()
    }
    .to_qdrant_search();

    let should_count = qdrant.matches("\"should\": [").count();
    assert!(
        should_count >= 2,
        "Expected multiple nested OR groups, got {should_count}: {qdrant}"
    );
}

#[test]
fn test_qdrant_json_strings_are_escaped() {
    use crate::ast::{Cage, CageKind, Condition, Expr, LogicalOp, Operator, Qail, Value};

    let search = Qail {
        table: "points".to_string(),
        columns: vec![Expr::Named("payload\"key".to_string())],
        cages: vec![Cage {
            kind: CageKind::Filter,
            conditions: vec![
                Condition {
                    left: Expr::Named("vector".to_string()),
                    op: Operator::Fuzzy,
                    value: Value::String("cute \"cat\"".to_string()),
                    is_array_unnest: false,
                },
                Condition {
                    left: Expr::Named("city\", \"must\": [".to_string()),
                    op: Operator::Eq,
                    value: Value::String("London\"}, \"must\": []".to_string()),
                    is_array_unnest: false,
                },
            ],
            logical_op: LogicalOp::And,
        }],
        ..Default::default()
    }
    .to_qdrant_search();

    let parsed: serde_json::Value =
        serde_json::from_str(&search).expect("qdrant search JSON must stay valid");
    assert_eq!(parsed["vector"], "{{EMBED:cute \"cat\"}}");
    assert_eq!(parsed["with_payload"]["include"][0], "payload\"key");
    assert_eq!(parsed["filter"]["must"][0]["key"], "city\", \"must\": [");
    assert_eq!(
        parsed["filter"]["must"][0]["match"]["value"],
        "London\"}, \"must\": []"
    );

    let upsert = Qail {
        action: crate::ast::Action::Add,
        table: "points".to_string(),
        cages: vec![Cage {
            kind: CageKind::Payload,
            conditions: vec![Condition {
                left: Expr::Named("name\"bad".to_string()),
                op: Operator::Eq,
                value: Value::String("Ana\"bad".to_string()),
                is_array_unnest: false,
            }],
            logical_op: LogicalOp::And,
        }],
        ..Default::default()
    }
    .to_qdrant_search();

    let parsed: serde_json::Value =
        serde_json::from_str(&upsert).expect("qdrant upsert JSON must stay valid");
    assert_eq!(parsed["points"][0]["payload"]["name\"bad"], "Ana\"bad");
}

#[test]
fn test_mongo_shell_fragments_are_escaped() {
    use crate::ast::{
        Action, Cage, CageKind, Condition, Expr, LogicalOp, Operator, Qail, SortOrder, Value,
    };

    let insert = Qail {
        action: Action::Add,
        table: "users\"); db.dropDatabase(); //".to_string(),
        cages: vec![Cage {
            kind: CageKind::Payload,
            conditions: vec![Condition {
                left: Expr::Named("name\"bad".to_string()),
                op: Operator::Eq,
                value: Value::String("Ana\"bad".to_string()),
                is_array_unnest: false,
            }],
            logical_op: LogicalOp::And,
        }],
        ..Default::default()
    }
    .to_mongo();

    assert!(insert.starts_with("db.getCollection("), "{insert}");
    assert!(
        insert.contains("\"users\\\"); db.dropDatabase(); //\""),
        "{insert}"
    );
    assert!(
        insert.contains("\"name\\\"bad\": \"Ana\\\"bad\""),
        "{insert}"
    );

    let find = Qail {
        table: "events; db.evil()".to_string(),
        columns: vec![Expr::Named("payload\"key".to_string())],
        cages: vec![
            Cage {
                kind: CageKind::Filter,
                conditions: vec![Condition {
                    left: Expr::Named("city\", $where: evil".to_string()),
                    op: Operator::Eq,
                    value: Value::String("London\" }".to_string()),
                    is_array_unnest: false,
                }],
                logical_op: LogicalOp::And,
            },
            Cage {
                kind: CageKind::Sort(SortOrder::Desc),
                conditions: vec![Condition {
                    left: Expr::Named("score\"bad".to_string()),
                    op: Operator::Eq,
                    value: Value::Null,
                    is_array_unnest: false,
                }],
                logical_op: LogicalOp::And,
            },
        ],
        ..Default::default()
    }
    .to_mongo();

    assert!(find.starts_with("db.getCollection("), "{find}");
    assert!(find.contains("\"events; db.evil()\""), "{find}");
    assert!(find.contains("\"payload\\\"key\": 1"), "{find}");
    assert!(
        find.contains("\"city\\\", $where: evil\": \"London\\\" }\""),
        "{find}"
    );
    assert!(find.contains(".sort({ \"score\\\"bad\": -1 })"), "{find}");
}

#[test]
fn test_dynamo_json_and_expression_names_are_escaped() {
    use crate::ast::{Action, Cage, CageKind, Condition, Expr, LogicalOp, Operator, Qail, Value};

    let get = Qail {
        table: "users\"bad".to_string(),
        columns: vec![Expr::Named("payload\"key".to_string())],
        cages: vec![Cage {
            kind: CageKind::Filter,
            conditions: vec![
                Condition {
                    left: Expr::Named("city\", #x = :evil".to_string()),
                    op: Operator::Eq,
                    value: Value::String("London\"bad".to_string()),
                    is_array_unnest: false,
                },
                Condition {
                    left: Expr::Named("index".to_string()),
                    op: Operator::Eq,
                    value: Value::String("gsi\"bad".to_string()),
                    is_array_unnest: false,
                },
            ],
            logical_op: LogicalOp::And,
        }],
        ..Default::default()
    }
    .to_dynamo();

    let parsed: serde_json::Value =
        serde_json::from_str(&get).expect("dynamo get JSON must stay valid");
    assert_eq!(parsed["TableName"], "users\"bad");
    assert_eq!(parsed["IndexName"], "gsi\"bad");
    assert_eq!(parsed["FilterExpression"], "#f1 = :v1");
    assert_eq!(
        parsed["ExpressionAttributeNames"]["#f1"],
        "city\", #x = :evil"
    );
    assert_eq!(parsed["ExpressionAttributeNames"]["#p1"], "payload\"key");
    assert_eq!(
        parsed["ExpressionAttributeValues"][":v1"]["S"],
        "London\"bad"
    );
    assert_eq!(parsed["ProjectionExpression"], "#p1");

    let update = Qail {
        action: Action::Set,
        table: "users".to_string(),
        cages: vec![
            Cage {
                kind: CageKind::Filter,
                conditions: vec![Condition {
                    left: Expr::Named("pk\"bad".to_string()),
                    op: Operator::Eq,
                    value: Value::String("user\"1".to_string()),
                    is_array_unnest: false,
                }],
                logical_op: LogicalOp::And,
            },
            Cage {
                kind: CageKind::Payload,
                conditions: vec![Condition {
                    left: Expr::Named("set\", danger = :x".to_string()),
                    op: Operator::Eq,
                    value: Value::String("active\"yes".to_string()),
                    is_array_unnest: false,
                }],
                logical_op: LogicalOp::And,
            },
        ],
        ..Default::default()
    }
    .to_dynamo();

    let parsed: serde_json::Value =
        serde_json::from_str(&update).expect("dynamo update JSON must stay valid");
    assert_eq!(parsed["Key"]["pk\"bad"]["S"], "user\"1");
    assert_eq!(parsed["UpdateExpression"], "SET #u101 = :u101");
    assert_eq!(
        parsed["ExpressionAttributeNames"]["#u101"],
        "set\", danger = :x"
    );
    assert_eq!(
        parsed["ExpressionAttributeValues"][":u101"]["S"],
        "active\"yes"
    );
}