use fraiseql_core::db::{
PostgresDialect,
postgres::PostgresWhereGenerator,
where_clause::{WhereClause, WhereOperator},
};
use serde_json::json;
const fn pg() -> PostgresWhereGenerator {
PostgresWhereGenerator::new(PostgresDialect)
}
#[test]
fn where_eq_operator() {
let clause = WhereClause::Field {
path: vec!["email".to_string()],
operator: WhereOperator::Eq,
value: json!("alice@example.com"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert_eq!(sql, "data->>'email' = $1");
assert_eq!(params.len(), 1);
}
#[test]
fn where_gt_operator() {
let clause = WhereClause::Field {
path: vec!["score".to_string()],
operator: WhereOperator::Gt,
value: json!(100),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains('>'), "Expected '>' in: {sql}");
assert!(sql.contains("score"), "Expected field name in: {sql}");
assert_eq!(params.len(), 1);
}
#[test]
fn where_gt_with_cast() {
let clause = WhereClause::Field {
path: vec!["created_at".to_string()],
operator: WhereOperator::Gt,
value: json!("2024-01-01T00:00:00Z"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains("created_at"), "Expected field name in: {sql}");
assert!(sql.contains('>'), "Expected '>' in: {sql}");
assert_eq!(params.len(), 1);
}
#[test]
fn where_ilike_operator() {
let clause = WhereClause::Field {
path: vec!["name".to_string()],
operator: WhereOperator::Ilike,
value: json!("%alice%"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains("ILIKE"), "Expected ILIKE in: {sql}");
assert!(sql.contains("name"), "Expected field name in: {sql}");
assert_eq!(params.len(), 1);
}
#[test]
fn special_chars_ilike_clause() {
let clause = WhereClause::Field {
path: vec!["title".to_string()],
operator: WhereOperator::Ilike,
value: json!("%rust_lang%"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains("ILIKE"), "Expected ILIKE in: {sql}");
assert_eq!(params.len(), 1, "Special chars must stay as a single parameter");
let param_str = params[0].as_str().unwrap_or("");
assert!(param_str.contains('%'), "Pattern percent must survive as-is");
}
#[test]
fn where_in_operator() {
let clause = WhereClause::Field {
path: vec!["status".to_string()],
operator: WhereOperator::In,
value: json!(["active", "pending", "review"]),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains("ANY") || sql.contains("IN"), "Expected ANY or IN in: {sql}");
assert!(sql.contains("status"), "Expected field name in: {sql}");
assert!(!sql.contains("active"), "Literals must not appear in SQL: {sql}");
assert!(!params.is_empty(), "Must have at least one parameter");
}
#[test]
fn where_is_null() {
let clause = WhereClause::Field {
path: vec!["deleted_at".to_string()],
operator: WhereOperator::IsNull,
value: json!(true),
};
let (sql, _params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains("IS NULL"), "Expected IS NULL in: {sql}");
assert!(sql.contains("deleted_at"), "Expected field name in: {sql}");
}
#[test]
fn where_is_not_null() {
let clause = WhereClause::Field {
path: vec!["published_at".to_string()],
operator: WhereOperator::IsNull,
value: json!(false),
};
let (sql, _params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains("IS NOT NULL"), "Expected IS NOT NULL in: {sql}");
assert!(sql.contains("published_at"), "Expected field name in: {sql}");
}
#[test]
fn multiple_where_clauses_and() {
let clause = WhereClause::And(vec![
WhereClause::Field {
path: vec!["published".to_string()],
operator: WhereOperator::Eq,
value: json!(true),
},
WhereClause::Field {
path: vec!["author_id".to_string()],
operator: WhereOperator::Eq,
value: json!("00000000-0000-0000-0000-000000000001"),
},
]);
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains(" AND "), "Expected AND in: {sql}");
assert!(sql.contains("published"), "Expected first field in: {sql}");
assert!(sql.contains("author_id"), "Expected second field in: {sql}");
assert_eq!(params.len(), 2, "Two fields → two params");
}
#[test]
fn boolean_literal_eq_clause() {
let clause = WhereClause::Field {
path: vec!["published".to_string()],
operator: WhereOperator::Eq,
value: json!(true),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains("published"), "Expected field name in: {sql}");
assert_eq!(params.len(), 1, "Boolean must be parameterized");
assert!(!sql.contains("'true'"), "Must not inline boolean literal: {sql}");
assert!(!sql.contains("'false'"), "Must not inline boolean literal: {sql}");
}
#[test]
fn keyset_pagination_where_clause() {
let clause = WhereClause::Field {
path: vec!["id".to_string()],
operator: WhereOperator::Gt,
value: json!("cursor-value-here"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains('>'), "Keyset pagination must use > not OFFSET: {sql}");
assert!(sql.contains("id"), "Expected cursor field in: {sql}");
assert_eq!(params.len(), 1, "Cursor must be a single parameter");
assert!(
!sql.to_uppercase().contains("OFFSET"),
"Keyset must not fall back to OFFSET: {sql}"
);
}
#[test]
fn field_projection_sql() {
use fraiseql_core::db::PostgresProjectionGenerator;
let gen = PostgresProjectionGenerator::new();
let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
let sql = gen.generate_projection_sql(&fields).expect("generate");
assert!(sql.contains("jsonb_build_object"), "Expected jsonb_build_object in: {sql}");
assert!(sql.contains("'id'"), "Expected 'id' key in: {sql}");
assert!(sql.contains("'name'"), "Expected 'name' key in: {sql}");
assert!(sql.contains("'email'"), "Expected 'email' key in: {sql}");
assert!(sql.contains("id"), "Expected id in: {sql}");
assert!(sql.contains("name"), "Expected name in: {sql}");
assert!(sql.contains("email"), "Expected email in: {sql}");
}
#[test]
fn aggregate_sum_produces_correct_sql() {
use fraiseql_core::{
compiler::{
aggregate_types::AggregateFunction,
aggregation::{AggregateSelection, AggregationPlanner, AggregationRequest},
fact_table::{DimensionColumn, FactTableMetadata, MeasureColumn, SqlType},
},
db::types::DatabaseType,
runtime::AggregationSqlGenerator,
};
let metadata = FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![MeasureColumn {
name: "amount".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
}],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
};
let request = AggregationRequest {
table_name: "tf_sales".to_string(),
where_clause: None,
group_by: vec![],
aggregates: vec![AggregateSelection::MeasureAggregate {
measure: "amount".to_string(),
function: AggregateFunction::Sum,
alias: "total".to_string(),
}],
having: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let plan = AggregationPlanner::plan(request, metadata).expect("plan");
let sql = AggregationSqlGenerator::new(DatabaseType::PostgreSQL)
.generate_parameterized(&plan)
.expect("generate");
assert!(sql.sql.contains("SUM(amount)"), "Expected SUM(amount) in: {}", sql.sql);
assert!(sql.sql.contains("tf_sales"), "Expected table name in: {}", sql.sql);
assert!(
!sql.sql.to_uppercase().contains("GROUP BY"),
"SUM without grouping must not emit GROUP BY: {}",
sql.sql
);
}
#[test]
fn aggregate_group_by_produces_correct_sql() {
use fraiseql_core::{
compiler::{
aggregate_types::AggregateFunction,
aggregation::{
AggregateSelection, AggregationPlanner, AggregationRequest, GroupBySelection,
},
fact_table::{
DimensionColumn, DimensionPath, FactTableMetadata, MeasureColumn, SqlType,
},
},
db::types::DatabaseType,
runtime::AggregationSqlGenerator,
};
let metadata = FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![MeasureColumn {
name: "amount".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
}],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![DimensionPath {
name: "category".to_string(),
json_path: "data->>'category'".to_string(),
data_type: "text".to_string(),
}],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
};
let request = AggregationRequest {
table_name: "tf_sales".to_string(),
where_clause: None,
group_by: vec![GroupBySelection::Dimension {
path: "category".to_string(),
alias: "category".to_string(),
}],
aggregates: vec![AggregateSelection::MeasureAggregate {
measure: "amount".to_string(),
function: AggregateFunction::Sum,
alias: "total".to_string(),
}],
having: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let plan = AggregationPlanner::plan(request, metadata).expect("plan");
let sql = AggregationSqlGenerator::new(DatabaseType::PostgreSQL)
.generate_parameterized(&plan)
.expect("generate");
assert!(sql.sql.to_uppercase().contains("GROUP BY"), "Expected GROUP BY in: {}", sql.sql);
assert!(sql.sql.contains("category"), "Expected dimension name in: {}", sql.sql);
assert!(sql.sql.contains("SUM(amount)"), "Expected SUM aggregate in: {}", sql.sql);
}
fn pg_function_call_sql(function_name: &str, arg_count: usize) -> String {
let placeholders: Vec<String> = (1..=arg_count).map(|i| format!("${i}")).collect();
format!("SELECT * FROM {function_name}({})", placeholders.join(", "))
}
#[test]
fn mutation_create_sql_shape() {
let sql = pg_function_call_sql("fn_create_post", 4);
assert_eq!(sql, "SELECT * FROM fn_create_post($1, $2, $3, $4)");
}
#[test]
fn mutation_update_sql_shape() {
let sql = pg_function_call_sql("fn_update_post", 6);
assert_eq!(sql, "SELECT * FROM fn_update_post($1, $2, $3, $4, $5, $6)");
}
#[test]
fn mutation_delete_sql_shape() {
let sql = pg_function_call_sql("fn_delete_post", 2);
assert_eq!(sql, "SELECT * FROM fn_delete_post($1, $2)");
}
#[test]
fn mutation_zero_arg_sql_shape() {
let sql = pg_function_call_sql("fn_noop", 0);
assert_eq!(sql, "SELECT * FROM fn_noop()");
}
#[test]
fn rls_combined_where_clause() {
let clause = WhereClause::And(vec![
WhereClause::Field {
path: vec!["published".to_string()],
operator: WhereOperator::Eq,
value: json!(true),
},
WhereClause::Field {
path: vec!["tenant_id".to_string()],
operator: WhereOperator::Eq,
value: json!("tenant-abc"),
},
]);
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.to_uppercase().contains(" AND "), "Expected AND in: {sql}");
assert!(sql.contains("published"), "Expected app WHERE field in: {sql}");
assert!(sql.contains("tenant_id"), "Expected RLS field in: {sql}");
assert_eq!(params.len(), 2, "Two parameterized values");
}
#[test]
fn rls_only_clause() {
let clause = WhereClause::Field {
path: vec!["tenant_id".to_string()],
operator: WhereOperator::Eq,
value: json!("tenant-abc"),
};
let (sql, params) = pg().generate(&clause).expect("generate");
assert!(sql.contains("tenant_id"), "Expected RLS field in: {sql}");
assert!(sql.contains("= $1"), "Expected parameterized equality in: {sql}");
assert_eq!(params.len(), 1);
}