#![allow(clippy::unwrap_used, clippy::panic)]
#![allow(clippy::default_trait_access)] #![allow(clippy::items_after_statements)] use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use fraiseql_core::{
compiler::{
fact_table::{DimensionColumn, FactTableMetadata, FilterColumn, MeasureColumn, SqlType},
ir::{AuthoringIR, IRField, IRType},
parser::SchemaParser,
validator::SchemaValidator,
},
db::{
traits::{DatabaseAdapter, SupportsMutations},
types::{DatabaseType, JsonbValue, OrderByClause, PoolMetrics},
where_clause::{WhereClause, WhereOperator},
},
error::Result,
runtime::{Executor, aggregation::AggregationSqlGenerator},
schema::{CompiledSchema, SqlProjectionHint},
};
use fraiseql_test_utils::schema_builder::{TestQueryBuilder, TestSchemaBuilder};
use serde_json::json;
struct MockAdapter {
mock_results: Vec<JsonbValue>,
}
impl MockAdapter {
const fn new(mock_results: Vec<JsonbValue>) -> Self {
Self { mock_results }
}
}
#[async_trait]
impl DatabaseAdapter for MockAdapter {
async fn execute_with_projection(
&self,
view: &str,
_projection: Option<&SqlProjectionHint>,
where_clause: Option<&WhereClause>,
limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
self.execute_where_query(view, where_clause, limit, None, None).await
}
async fn execute_where_query(
&self,
_view: &str,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(self.mock_results.clone())
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
fn database_type(&self) -> DatabaseType {
DatabaseType::PostgreSQL
}
fn pool_metrics(&self) -> PoolMetrics {
PoolMetrics {
total_connections: 1,
active_connections: 0,
idle_connections: 1,
waiting_requests: 0,
}
}
async fn execute_raw_query(
&self,
_sql: &str,
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
let mut result = HashMap::new();
result.insert("count".to_string(), json!(10));
result.insert("revenue_sum".to_string(), json!(1500.50));
Ok(vec![result])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
let mut result = HashMap::new();
result.insert("count".to_string(), json!(10));
result.insert("revenue_sum".to_string(), json!(1500.50));
Ok(vec![result])
}
async fn execute_function_call(
&self,
_function_name: &str,
_args: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
impl SupportsMutations for MockAdapter {}
#[test]
fn test_schema_with_fact_tables_validation() {
let mut ir = AuthoringIR::new();
ir.fact_tables.insert(
"tf_sales".to_string(),
FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![
MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
},
MeasureColumn {
name: "quantity".to_string(),
sql_type: SqlType::Int,
nullable: false,
},
],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
},
);
let validator = SchemaValidator::new();
let result = validator.validate(ir);
result.unwrap_or_else(|e| panic!("expected schema with fact tables to pass validation: {e}"));
}
#[test]
fn test_validator_rejects_invalid_fact_table_prefix() {
let mut ir = AuthoringIR::new();
ir.fact_tables.insert(
"sales".to_string(),
FactTableMetadata {
table_name: "sales".to_string(),
measures: vec![MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
}],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
},
);
let validator = SchemaValidator::new();
let result = validator.validate(ir);
assert!(
result.is_err(),
"expected Err for fact table without tf_ prefix, got: {result:?}"
);
if let Err(e) = result {
let error_msg = format!("{}", e);
assert!(error_msg.contains("must start with 'tf_' prefix"));
}
}
#[test]
fn test_validator_rejects_fact_table_without_measures() {
let mut ir = AuthoringIR::new();
ir.fact_tables.insert(
"tf_sales".to_string(),
FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
},
);
let validator = SchemaValidator::new();
let result = validator.validate(ir);
assert!(result.is_err(), "expected Err for fact table without measures, got: {result:?}");
if let Err(e) = result {
let error_msg = format!("{}", e);
assert!(error_msg.contains("must have at least one measure"));
}
}
#[test]
fn test_validator_rejects_aggregate_type_without_count() {
let mut ir = AuthoringIR::new();
ir.types.push(IRType {
name: "SalesAggregate".to_string(),
fields: vec![IRField {
name: "revenue_sum".to_string(),
field_type: "Float".to_string(),
nullable: true,
description: None,
sql_column: None,
}],
sql_source: None,
description: None,
});
let validator = SchemaValidator::new();
let result = validator.validate(ir);
assert!(
result.is_err(),
"expected Err for aggregate type without count field, got: {result:?}"
);
if let Err(e) = result {
let error_msg = format!("{}", e);
assert!(error_msg.contains("must have a 'count' field"));
}
}
#[test]
fn test_validator_accepts_valid_aggregate_type() {
let mut ir = AuthoringIR::new();
ir.types.push(IRType {
name: "SalesAggregate".to_string(),
fields: vec![
IRField {
name: "count".to_string(),
field_type: "Int!".to_string(),
nullable: false,
description: None,
sql_column: None,
},
IRField {
name: "revenue_sum".to_string(),
field_type: "Float".to_string(),
nullable: true,
description: None,
sql_column: None,
},
],
sql_source: None,
description: None,
});
let validator = SchemaValidator::new();
let result = validator.validate(ir);
result.unwrap_or_else(|e| panic!("expected valid aggregate type to pass validation: {e}"));
}
#[test]
fn test_schema_compilation_with_fact_tables() {
let schema_json = json!({
"types": [],
"queries": [],
"mutations": [],
"subscriptions": [],
"fact_tables": {
"tf_sales": {
"table_name": "tf_sales",
"measures": [
{"name": "revenue", "sql_type": "Decimal", "nullable": false}
],
"dimensions": {
"name": "data",
"paths": []
},
"denormalized_filters": []
}
}
});
let parser = SchemaParser::new();
let ir = parser.parse(&schema_json.to_string()).expect("Failed to parse schema");
assert_eq!(ir.fact_tables.len(), 1);
assert!(ir.fact_tables.contains_key("tf_sales"));
let validator = SchemaValidator::new();
let validated_ir = validator.validate(ir).expect("Validation failed");
assert_eq!(validated_ir.fact_tables.len(), 1);
}
#[tokio::test]
async fn test_executor_classifies_regular_query() {
let schema = create_test_schema();
let adapter = Arc::new(MockAdapter::new(mock_user_results()));
let executor = Executor::new(schema, adapter);
let query = "{ users { id name } }";
let result = executor.execute(query, None).await;
result.unwrap_or_else(|e| panic!("expected regular query to succeed: {e}"));
}
#[tokio::test]
async fn test_executor_classifies_aggregate_query() {
let mut schema = CompiledSchema::new();
schema.add_fact_table(
"tf_sales".to_string(),
FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
}],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
},
);
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let query = "{ sales_aggregate { count } }";
let variables = json!({});
let result = executor.execute(query, Some(&variables)).await;
drop(result);
}
#[test]
fn test_end_to_end_fact_table_flow() {
let schema_json = json!({
"types": [
{
"name": "SalesAggregate",
"fields": [
{"name": "count", "type": "Int!", "nullable": false},
{"name": "revenue_sum", "type": "Float", "nullable": true}
],
"sql_source": null,
"description": null
}
],
"queries": [],
"mutations": [],
"subscriptions": [],
"fact_tables": {
"tf_sales": {
"table_name": "tf_sales",
"measures": [
{"name": "revenue", "sql_type": "Decimal", "nullable": false},
{"name": "quantity", "sql_type": "Int", "nullable": false}
],
"dimensions": {
"name": "data",
"paths": [
{"name": "category", "json_path": "data->>'category'", "data_type": "String"}
]
},
"denormalized_filters": [
{"name": "customer_id", "sql_type": "Uuid", "indexed": true}
]
}
}
});
let parser = SchemaParser::new();
let ir = parser.parse(&schema_json.to_string()).expect("Failed to parse");
assert_eq!(ir.fact_tables.len(), 1);
assert_eq!(ir.types.len(), 1);
let validator = SchemaValidator::new();
let validated_ir = validator.validate(ir).expect("Validation failed");
assert_eq!(validated_ir.fact_tables.len(), 1);
assert_eq!(validated_ir.types.len(), 1);
let fact_table = validated_ir.fact_tables.get("tf_sales").unwrap();
assert_eq!(fact_table.measures.len(), 2);
let aggregate_type = &validated_ir.types[0];
assert_eq!(aggregate_type.name, "SalesAggregate");
assert!(aggregate_type.fields.iter().any(|f| f.name == "count"));
}
fn create_test_schema() -> CompiledSchema {
TestSchemaBuilder::new()
.with_query(
TestQueryBuilder::new("users", "User")
.returns_list(true)
.with_sql_source("v_user")
.build(),
)
.build()
}
fn mock_user_results() -> Vec<JsonbValue> {
vec![
JsonbValue::new(json!({"id": "1", "name": "Alice"})),
JsonbValue::new(json!({"id": "2", "name": "Bob"})),
]
}
fn create_test_fact_table_metadata() -> FactTableMetadata {
FactTableMetadata {
table_name: "tf_sales".to_string(),
measures: vec![MeasureColumn {
name: "revenue".to_string(),
sql_type: SqlType::Decimal,
nullable: false,
}],
dimensions: DimensionColumn {
name: "data".to_string(),
paths: vec![],
},
denormalized_filters: vec![
FilterColumn {
name: "customer_id".to_string(),
sql_type: SqlType::Uuid,
indexed: true,
},
FilterColumn {
name: "occurred_at".to_string(),
sql_type: SqlType::Timestamp,
indexed: true,
},
],
calendar_dimensions: vec![],
partial_period: None,
native_measures: std::collections::HashMap::new(),
native_dimension_mapping: std::collections::HashMap::new(),
}
}
#[test]
fn test_where_denormalized_filter() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["customer_id".to_string()],
operator: WhereOperator::Eq,
value: json!("550e8400-e29b-41d4-a716-446655440000"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("WHERE"));
assert!(sql.contains("customer_id"));
assert!(sql.contains('='));
assert!(params.contains(&json!("550e8400-e29b-41d4-a716-446655440000")));
assert!(!sql.contains("->"));
}
#[test]
fn test_where_jsonb_dimension() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("WHERE"));
assert!(sql.contains("data->>'category'"));
assert!(sql.contains('='));
assert!(params.contains(&json!("electronics")));
}
#[test]
fn test_where_and_operator() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::And(vec![
WhereClause::Field {
path: vec!["customer_id".to_string()],
operator: WhereOperator::Eq,
value: json!("550e8400-e29b-41d4-a716-446655440000"),
},
WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
},
]);
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("customer_id"));
assert!(sql.contains("data->>'category'"));
assert!(sql.contains(" AND "));
}
#[test]
fn test_where_or_operator() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Or(vec![
WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
},
WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("furniture"),
},
]);
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("data->>'category'"));
assert!(sql.contains(" OR "));
assert!(params.contains(&json!("electronics")));
assert!(params.contains(&json!("furniture")));
}
#[test]
fn test_where_not_operator() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Not(Box::new(WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
}));
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("NOT"));
assert!(sql.contains("data->>'category'"));
}
#[test]
fn test_where_comparison_operators() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["occurred_at".to_string()],
operator: WhereOperator::Gt,
value: json!("2024-01-01"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains('>'));
let where_clause = WhereClause::Field {
path: vec!["occurred_at".to_string()],
operator: WhereOperator::Lte,
value: json!("2024-12-31"),
};
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("<="));
}
#[test]
fn test_where_in_operator() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::In,
value: json!(["electronics", "furniture", "clothing"]),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("IN"));
assert!(params.contains(&json!("electronics")));
assert!(params.contains(&json!("furniture")));
assert!(params.contains(&json!("clothing")));
}
#[test]
fn test_where_like_operators() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Contains,
value: json!("electr"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("LIKE"));
assert!(params.iter().any(|p| p.as_str() == Some("%electr%")));
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Startswith,
value: json!("electr"),
};
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("LIKE"));
assert!(params.iter().any(|p| p.as_str() == Some("electr%")));
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Endswith,
value: json!("onics"),
};
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("LIKE"));
assert!(params.iter().any(|p| p.as_str() == Some("%onics")));
}
#[test]
fn test_where_case_insensitive_postgresql() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Icontains,
value: json!("ELECTR"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("ILIKE"));
assert!(params.iter().any(|p| p.as_str() == Some("%ELECTR%")));
}
#[test]
fn test_where_case_insensitive_mysql() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Icontains,
value: json!("electr"),
};
let generator = AggregationSqlGenerator::new(DatabaseType::MySQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("UPPER"));
assert!(sql.contains("LIKE"));
}
#[test]
fn test_where_is_null_operator() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::IsNull,
value: json!(null),
};
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("IS NULL"));
assert!(sql.contains("data->>'category'"));
}
#[test]
fn test_where_multi_database_compatibility() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
};
let pg = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = pg
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("data->>'category'"));
let mysql = AggregationSqlGenerator::new(DatabaseType::MySQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = mysql
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("JSON_EXTRACT") || sql.contains("JSON_UNQUOTE"));
let sqlite = AggregationSqlGenerator::new(DatabaseType::SQLite);
let mut params: Vec<serde_json::Value> = vec![];
let sql = sqlite
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("json_extract"));
let mssql = AggregationSqlGenerator::new(DatabaseType::SQLServer);
let mut params: Vec<serde_json::Value> = vec![];
let sql = mssql
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("JSON_VALUE"));
}
#[test]
fn test_where_empty_clause() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::And(vec![]);
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert_eq!(sql, "");
}
#[test]
fn test_where_complex_nested() {
let metadata = create_test_fact_table_metadata();
let where_clause = WhereClause::And(vec![
WhereClause::Field {
path: vec!["customer_id".to_string()],
operator: WhereOperator::Eq,
value: json!("550e8400-e29b-41d4-a716-446655440000"),
},
WhereClause::Or(vec![
WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("electronics"),
},
WhereClause::Field {
path: vec!["category".to_string()],
operator: WhereOperator::Eq,
value: json!("furniture"),
},
]),
]);
let generator = AggregationSqlGenerator::new(DatabaseType::PostgreSQL);
let mut params: Vec<serde_json::Value> = vec![];
let sql = generator
.build_where_clause_parameterized(&where_clause, &metadata, &mut params)
.unwrap();
assert!(sql.contains("customer_id"));
assert!(sql.contains("data->>'category'"));
assert!(sql.contains(" AND "));
assert!(sql.contains(" OR "));
assert!(params.contains(&json!("electronics")));
assert!(params.contains(&json!("furniture")));
}