#![allow(clippy::unwrap_used)] use fraiseql_core::db::{
PostgresDialect, WhereClause, WhereOperator, postgres::PostgresWhereGenerator,
where_sql_generator::WhereSqlGenerator,
};
use proptest::prelude::*;
use serde_json::{Value, json};
fn arb_path_segment() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_]{0,20}".prop_map(String::from)
}
fn arb_path() -> impl Strategy<Value = Vec<String>> {
prop::collection::vec(arb_path_segment(), 1..=4)
}
fn arb_scalar_value() -> impl Strategy<Value = Value> {
prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
any::<i64>().prop_map(|n| json!(n)),
"[a-zA-Z0-9 _.@-]{0,50}".prop_map(Value::String),
]
}
fn arb_string_value() -> impl Strategy<Value = Value> {
"[a-zA-Z0-9 _.@-]{0,50}".prop_map(Value::String)
}
fn arb_array_value() -> impl Strategy<Value = Value> {
prop::collection::vec(arb_string_value(), 1..=5).prop_map(Value::Array)
}
fn arb_comparison_operator() -> impl Strategy<Value = WhereOperator> {
prop_oneof![
Just(WhereOperator::Eq),
Just(WhereOperator::Neq),
Just(WhereOperator::Gt),
Just(WhereOperator::Gte),
Just(WhereOperator::Lt),
Just(WhereOperator::Lte),
]
}
fn arb_string_operator() -> impl Strategy<Value = WhereOperator> {
prop_oneof![
Just(WhereOperator::Contains),
Just(WhereOperator::Icontains),
Just(WhereOperator::Startswith),
Just(WhereOperator::Istartswith),
Just(WhereOperator::Endswith),
Just(WhereOperator::Iendswith),
Just(WhereOperator::Like),
Just(WhereOperator::Ilike),
]
}
fn arb_comparison_field() -> impl Strategy<Value = WhereClause> {
(arb_path(), arb_comparison_operator(), arb_scalar_value()).prop_map(
|(path, operator, value)| WhereClause::Field {
path,
operator,
value,
},
)
}
fn arb_string_field() -> impl Strategy<Value = WhereClause> {
(arb_path(), arb_string_operator(), arb_string_value()).prop_map(|(path, operator, value)| {
WhereClause::Field {
path,
operator,
value,
}
})
}
fn arb_in_field() -> impl Strategy<Value = WhereClause> {
(
arb_path(),
prop_oneof![Just(WhereOperator::In), Just(WhereOperator::Nin)],
arb_array_value(),
)
.prop_map(|(path, operator, value)| WhereClause::Field {
path,
operator,
value,
})
}
fn arb_isnull_field() -> impl Strategy<Value = WhereClause> {
(arb_path(), any::<bool>()).prop_map(|(path, is_null)| WhereClause::Field {
path,
operator: WhereOperator::IsNull,
value: json!(is_null),
})
}
fn arb_leaf_clause() -> impl Strategy<Value = WhereClause> {
prop_oneof![
arb_comparison_field(),
arb_string_field(),
arb_in_field(),
arb_isnull_field(),
]
}
fn arb_where_clause() -> impl Strategy<Value = WhereClause> {
arb_leaf_clause().prop_recursive(3, 16, 4, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 1..=4).prop_map(WhereClause::And),
prop::collection::vec(inner.clone(), 1..=4).prop_map(WhereClause::Or),
inner.prop_map(|c| WhereClause::Not(Box::new(c))),
]
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn prop_postgres_never_inlines_string_values(
path in arb_path(),
value in "[a-zA-Z0-9]{1,20}",
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator: WhereOperator::Eq,
value: Value::String(value.clone()),
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert!(
sql.contains("$1"),
"SQL must use parameterized placeholder, got: {}", sql
);
prop_assert_eq!(params.len(), 1);
prop_assert_eq!(¶ms[0], &json!(value));
}
#[test]
fn prop_postgres_param_count_matches(clause in arb_where_clause()) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let result = gen.generate(&clause);
if let Ok((sql, params)) = result {
let placeholder_count = count_placeholders(&sql);
prop_assert_eq!(
placeholder_count, params.len(),
"Placeholder count ({}) != params length ({})\nSQL: {}",
placeholder_count, params.len(), sql
);
}
}
#[test]
fn prop_postgres_params_sequential(clause in arb_where_clause()) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let result = gen.generate(&clause);
if let Ok((sql, params)) = result {
for i in 1..=params.len() {
let placeholder = format!("${}", i);
prop_assert!(
sql.contains(&placeholder),
"Missing sequential placeholder {} in SQL: {}", placeholder, sql
);
}
}
}
#[test]
fn prop_postgres_injection_safe_string_values(
path in arb_path(),
prefix in "[a-zA-Z]{0,10}",
injection in prop_oneof![
Just("'; DROP TABLE users; --"),
Just("' OR '1'='1"),
Just("'; DELETE FROM data WHERE '1'='1"),
Just("\\'; TRUNCATE users; --"),
],
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let payload = format!("{}{}", prefix, injection);
let clause = WhereClause::Field {
path,
operator: WhereOperator::Eq,
value: Value::String(payload.clone()),
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert!(
!sql.contains("DROP"),
"SQL injection payload must not appear in SQL: {}", sql
);
prop_assert!(
!sql.contains("DELETE"),
"SQL injection payload must not appear in SQL: {}", sql
);
prop_assert!(
!sql.contains("TRUNCATE"),
"SQL injection payload must not appear in SQL: {}", sql
);
prop_assert_eq!(¶ms[0], &json!(payload));
}
#[test]
fn prop_postgres_injection_safe_path_segments(
prefix in "[a-zA-Z]{1,5}",
suffix in "[a-zA-Z]{1,5}",
operator in arb_comparison_operator(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let path_segment = format!("{}'{}", prefix, suffix);
let clause = WhereClause::Field {
path: vec![path_segment.clone()],
operator,
value: json!("safe_value"),
};
let result = gen.generate(&clause);
if let Ok((sql, _)) = result {
let escaped = path_segment.replace('\'', "''");
prop_assert!(
sql.contains(&escaped),
"Path should contain escaped segment '{}', got: {}", escaped, sql
);
prop_assert!(sql.contains("$1"), "Values should still be parameterized: {}", sql);
}
}
#[test]
fn prop_postgres_balanced_parentheses(clause in arb_where_clause()) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let result = gen.generate(&clause);
if let Ok((sql, _)) = result {
let open = sql.chars().filter(|c| *c == '(').count();
let close = sql.chars().filter(|c| *c == ')').count();
prop_assert_eq!(
open, close,
"Unbalanced parentheses in SQL: {}", sql
);
}
}
#[test]
fn prop_postgres_empty_logic_identity(
use_and in any::<bool>(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = if use_and {
WhereClause::And(vec![])
} else {
WhereClause::Or(vec![])
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert!(params.is_empty());
if use_and {
prop_assert_eq!(sql, "TRUE");
} else {
prop_assert_eq!(sql, "FALSE");
}
}
#[test]
fn prop_postgres_not_wraps_inner(inner in arb_leaf_clause()) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Not(Box::new(inner));
let result = gen.generate(&clause);
if let Ok((sql, _)) = result {
prop_assert!(
sql.starts_with("NOT ("),
"NOT clause should start with 'NOT (': {}", sql
);
prop_assert!(
sql.ends_with(')'),
"NOT clause should end with ')': {}", sql
);
}
}
#[test]
fn prop_postgres_isnull_no_params(
path in arb_path(),
is_null in any::<bool>(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator: WhereOperator::IsNull,
value: json!(is_null),
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert!(params.is_empty(), "IsNull should produce no params, got: {:?}", params);
if is_null {
prop_assert!(sql.contains("IS NULL"), "Expected IS NULL in: {}", sql);
prop_assert!(!sql.contains("IS NOT NULL"), "Should not contain IS NOT NULL: {}", sql);
} else {
prop_assert!(sql.contains("IS NOT NULL"), "Expected IS NOT NULL in: {}", sql);
}
}
#[test]
fn prop_postgres_in_param_count(
path in arb_path(),
values in prop::collection::vec(arb_string_value(), 1..=10),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator: WhereOperator::In,
value: Value::Array(values.clone()),
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert_eq!(
params.len(), values.len(),
"IN should produce one param per array element. SQL: {}", sql
);
}
#[test]
fn prop_postgres_like_parameterized(
path in arb_path(),
operator in arb_string_operator(),
value in "[a-zA-Z0-9]{1,20}",
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator,
value: Value::String(value.clone()),
};
let (sql, params) = gen.generate(&clause).unwrap();
prop_assert!(
sql.contains("$1"),
"LIKE/ILIKE must use parameterized placeholder, got: {}", sql
);
prop_assert!(!params.is_empty(), "LIKE/ILIKE must produce at least one param");
prop_assert!(
sql.contains("LIKE") || sql.contains("ILIKE"),
"String operators should produce LIKE or ILIKE: {}", sql
);
let expected_val = Value::String(value);
prop_assert!(
params.contains(&expected_val),
"Search term must appear in params"
);
}
#[test]
fn prop_postgres_numeric_casts(
path in arb_path(),
operator in arb_comparison_operator(),
value in any::<i64>(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator,
value: json!(value),
};
let (sql, _params) = gen.generate(&clause).unwrap();
prop_assert!(
sql.contains("::numeric"),
"Numeric comparisons should cast to ::numeric: {}", sql
);
}
#[test]
fn prop_postgres_boolean_casts(
path in arb_path(),
value in any::<bool>(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let clause = WhereClause::Field {
path,
operator: WhereOperator::Eq,
value: json!(value),
};
let (sql, _params) = gen.generate(&clause).unwrap();
prop_assert!(
sql.contains("::boolean"),
"Boolean comparisons should cast to ::boolean: {}", sql
);
}
#[test]
fn prop_postgres_generator_reusable(
clause1 in arb_leaf_clause(),
clause2 in arb_leaf_clause(),
) {
let gen = PostgresWhereGenerator::new(PostgresDialect);
let result1 = gen.generate(&clause1);
let result2 = gen.generate(&clause2);
if let (Ok((sql1, _)), Ok((sql2, _))) = (&result1, &result2) {
if sql1.contains('$') {
prop_assert!(sql1.contains("$1"), "First call should start at $1: {}", sql1);
}
if sql2.contains('$') {
prop_assert!(sql2.contains("$1"), "Second call should reset to $1: {}", sql2);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn prop_generic_generator_no_panic(clause in arb_where_clause()) {
let _ = WhereSqlGenerator::to_sql(&clause);
}
#[test]
fn prop_generic_generator_escapes_quotes(
path in arb_path(),
value in ".*'.*",
) {
let clause = WhereClause::Field {
path,
operator: WhereOperator::Eq,
value: Value::String(value.clone()),
};
let result = WhereSqlGenerator::to_sql(&clause);
if let Ok(sql) = result {
let quote_count = value.chars().filter(|c| *c == '\'').count();
let sql_quote_count = sql.chars().filter(|c| *c == '\'').count();
prop_assert!(
sql_quote_count >= quote_count * 2,
"Single quotes must be escaped. Value has {} quotes, SQL has {} quotes: {}",
quote_count, sql_quote_count, sql
);
}
}
#[test]
fn prop_generic_and_or_keywords(
clauses in prop::collection::vec(arb_leaf_clause(), 2..=4),
use_and in any::<bool>(),
) {
let clause = if use_and {
WhereClause::And(clauses)
} else {
WhereClause::Or(clauses)
};
let result = WhereSqlGenerator::to_sql(&clause);
if let Ok(sql) = result {
if use_and {
prop_assert!(
sql.contains(" AND "),
"AND clause should contain ' AND ': {}", sql
);
} else {
prop_assert!(
sql.contains(" OR "),
"OR clause should contain ' OR ': {}", sql
);
}
}
}
#[test]
fn prop_generic_balanced_parentheses(clause in arb_where_clause()) {
let result = WhereSqlGenerator::to_sql(&clause);
if let Ok(sql) = result {
let open = sql.chars().filter(|c| *c == '(').count();
let close = sql.chars().filter(|c| *c == ')').count();
prop_assert_eq!(
open, close,
"Unbalanced parentheses in SQL: {}", sql
);
}
}
#[test]
fn prop_generic_path_escaping(
prefix in "[a-zA-Z]{1,10}",
suffix in "[a-zA-Z]{1,10}",
) {
let injection = format!("{}'{}", prefix, suffix);
let clause = WhereClause::Field {
path: vec![injection.clone()],
operator: WhereOperator::Eq,
value: json!("value"),
};
let result = WhereSqlGenerator::to_sql(&clause);
if let Ok(sql) = result {
let escaped = injection.replace('\'', "''");
prop_assert!(
sql.contains(&escaped),
"Path should contain escaped version '{}', got: {}", escaped, sql
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn prop_where_clause_json_roundtrip(clause in arb_leaf_clause()) {
let json_str = serde_json::to_string(&clause).expect("serialization failed");
let restored: WhereClause =
serde_json::from_str(&json_str).expect("deserialization failed");
prop_assert_eq!(clause, restored);
}
#[test]
fn prop_where_clause_is_empty_consistency(clause in arb_where_clause()) {
match &clause {
WhereClause::And(v) | WhereClause::Or(v) => {
prop_assert_eq!(clause.is_empty(), v.is_empty());
}
WhereClause::Not(_) | WhereClause::Field { .. } => {
prop_assert!(!clause.is_empty());
}
_ => {}
}
}
#[test]
fn prop_operator_from_str_roundtrip(
op_name in prop_oneof![
Just("eq"), Just("neq"), Just("gt"), Just("gte"), Just("lt"), Just("lte"),
Just("in"), Just("nin"),
Just("contains"), Just("icontains"), Just("startswith"), Just("istartswith"),
Just("endswith"), Just("iendswith"), Just("like"), Just("ilike"),
Just("isnull"),
Just("array_contains"), Just("array_contained_by"), Just("array_overlaps"),
Just("matches"), Just("plain_query"), Just("phrase_query"), Just("websearch_query"),
],
) {
let parsed = WhereOperator::from_str(op_name);
prop_assert!(parsed.is_ok(), "Known operator '{}' should parse", op_name);
}
#[test]
fn prop_operator_rejects_unknown(
name in "[a-z]{1,10}",
) {
let known = [
"eq", "neq", "gt", "gte", "lt", "lte", "in", "nin",
"contains", "icontains", "startswith", "istartswith",
"endswith", "iendswith", "like", "ilike", "isnull",
];
prop_assume!(!known.contains(&name.as_str()));
let more_known = ["matches", "overlaps", "lca"];
prop_assume!(!more_known.contains(&name.as_str()));
let prefixed = [
"array_", "len_", "cosine_", "l2_", "l1_", "hamming_", "inner_",
"jaccard_", "plain_", "phrase_", "websearch_", "is_", "in_",
"contains_", "strictly_", "ancestor_", "descendant_", "matches_",
"depth_",
];
prop_assume!(!prefixed.iter().any(|p| name.starts_with(p)));
let result = WhereOperator::from_str(&name);
prop_assert!(result.is_err(), "Unknown operator '{}' should be rejected", name);
}
#[test]
fn prop_string_operator_classification(op in arb_string_operator()) {
prop_assert!(
op.is_string_operator(),
"{:?} should be classified as string operator", op
);
}
#[test]
fn prop_in_expects_array(use_in in any::<bool>()) {
let op = if use_in { WhereOperator::In } else { WhereOperator::Nin };
prop_assert!(op.expects_array(), "{:?} should expect array values", op);
}
#[test]
fn prop_comparison_not_array(op in arb_comparison_operator()) {
prop_assert!(!op.expects_array(), "{:?} should not expect array values", op);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn prop_field_where_generates_valid_sql(
path in arb_path(),
op in arb_comparison_operator(),
value in arb_scalar_value(),
) {
let clause = WhereClause::Field {
path,
operator: op,
value,
};
let generator = PostgresWhereGenerator::new(PostgresDialect);
if let Ok((sql, params)) = generator.generate(&clause) {
prop_assert!(!sql.is_empty(), "Generated SQL should not be empty");
let open = sql.chars().filter(|c| *c == '(').count();
let close = sql.chars().filter(|c| *c == ')').count();
prop_assert_eq!(open, close, "Parentheses should be balanced in: {}", sql);
prop_assert!(!params.is_empty(), "Should have parameters: {}", sql);
}
}
#[test]
fn prop_deep_field_paths_valid_sql(
path_segments in prop::collection::vec(arb_path_segment(), 1..10),
op in arb_comparison_operator(),
value in arb_scalar_value(),
) {
let clause = WhereClause::Field {
path: path_segments,
operator: op,
value,
};
let generator = PostgresWhereGenerator::new(PostgresDialect);
let _result = generator.generate(&clause);
}
#[test]
fn prop_sql_generation_deterministic(
path in arb_path(),
op in arb_comparison_operator(),
value in arb_scalar_value(),
) {
let clause = WhereClause::Field {
path,
operator: op,
value,
};
let gen1 = PostgresWhereGenerator::new(PostgresDialect);
let gen2 = PostgresWhereGenerator::new(PostgresDialect);
let result1 = gen1.generate(&clause);
let result2 = gen2.generate(&clause);
match (&result1, &result2) {
(Ok((sql1, _params1)), Ok((sql2, _params2))) => {
prop_assert_eq!(sql1, sql2, "SQL should be deterministic");
}
(Err(_), Err(_)) => {
}
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
prop_assert!(false, "SQL generation should be deterministic: got Ok vs Err");
}
}
}
}
fn count_placeholders(sql: &str) -> usize {
let mut count = 0;
let mut i = 1;
loop {
let placeholder = format!("${}", i);
if sql.contains(&placeholder) {
count += 1;
i += 1;
} else {
break;
}
}
count
}