#![allow(clippy::unwrap_used)]
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use crate::{
db::{
SupportsMutations,
traits::DatabaseAdapter,
types::{DatabaseType, JsonbValue, PoolMetrics, sql_hints::OrderByClause},
where_clause::WhereClause,
},
error::{FraiseQLError, Result},
runtime::{
Executor, RuntimeConfig,
executor::test_support::{MockAdapter, ReadOnlyMockAdapter},
},
schema::CompiledSchema,
};
mod mutation {
use super::*;
struct SelectionSetFilterMockAdapter;
#[async_trait]
impl DatabaseAdapter for SelectionSetFilterMockAdapter {
async fn execute_function_call(
&self,
_function_name: &str,
_args: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
use serde_json::json;
let mut row = std::collections::HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert(
"entity".to_string(),
json!({
"id": "123",
"name": "Alice",
"email": "alice@example.com",
"bio": "Software engineer"
}),
);
row.insert("entity_type".to_string(), json!("User"));
row.insert("message".to_string(), json!(""));
Ok(vec![row])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&crate::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
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(vec![])
}
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>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
impl SupportsMutations for SelectionSetFilterMockAdapter {}
struct EmptySelectionMockAdapter;
#[async_trait]
impl DatabaseAdapter for EmptySelectionMockAdapter {
async fn execute_function_call(
&self,
_function_name: &str,
_args: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
use serde_json::json;
let mut row = std::collections::HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert(
"entity".to_string(),
json!({
"id": "123",
"name": "Alice",
"email": "alice@example.com"
}),
);
row.insert("entity_type".to_string(), json!("User"));
row.insert("message".to_string(), json!(""));
Ok(vec![row])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&crate::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
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(vec![])
}
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>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
impl SupportsMutations for EmptySelectionMockAdapter {}
#[tokio::test]
async fn test_mutation_falls_back_to_operation_table_when_sql_source_none() {
use crate::schema::{MutationDefinition, MutationOperation};
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
name: "createUser".to_string(),
return_type: "User".to_string(),
sql_source: None,
operation: MutationOperation::Insert {
table: "fn_create_user".to_string(),
},
..MutationDefinition::new("createUser", "User")
});
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let err = executor.execute("mutation { createUser { id } }", None).await.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("has no sql_source configured"),
"executor still failed on missing sql_source instead of using operation.table: {msg}"
);
assert!(
msg.contains("function returned no rows") || msg.contains("no rows"),
"expected 'no rows' error after fallback, got: {msg}"
);
}
#[tokio::test]
async fn test_mutation_rejected_by_non_capable_adapter() {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let adapter = Arc::new(ReadOnlyMockAdapter);
let executor = Executor::new(schema, adapter);
let err = executor.execute("mutation { createUser { id } }", None).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not support mutations"),
"expected 'does not support mutations' diagnostic, got: {msg}"
);
assert!(msg.contains("createUser"), "error message should name the mutation, got: {msg}");
}
#[tokio::test]
async fn test_mutation_errors_when_both_sql_source_and_table_absent() {
use crate::schema::{MutationDefinition, MutationOperation};
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
name: "deleteUser".to_string(),
return_type: "User".to_string(),
sql_source: None,
operation: MutationOperation::Custom,
..MutationDefinition::new("deleteUser", "User")
});
let adapter = Arc::new(MockAdapter::new(vec![]));
let executor = Executor::new(schema, adapter);
let err = executor.execute("mutation { deleteUser { id } }", None).await.unwrap_err();
assert!(
err.to_string().contains("has no sql_source configured"),
"expected sql_source error, got: {err}"
);
}
#[tokio::test]
async fn test_mutation_guard_returns_validation_error_not_database_or_internal() {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let adapter = Arc::new(ReadOnlyMockAdapter);
let executor = Executor::new(schema, adapter);
let err = executor.execute("mutation { createUser { id } }", None).await.unwrap_err();
assert!(
matches!(err, FraiseQLError::Validation { .. }),
"expected FraiseQLError::Validation for read-only adapter, got: {err:?}"
);
}
#[tokio::test]
async fn test_mutation_guard_error_message_is_actionable() {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
sql_source: Some("fn_delete_account".to_string()),
..MutationDefinition::new("deleteAccount", "User")
});
let adapter = Arc::new(ReadOnlyMockAdapter);
let executor = Executor::new(schema, adapter);
let err = executor.execute("mutation { deleteAccount { id } }", None).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("deleteAccount"),
"mutation guard message should name the mutation, got: {msg}"
);
assert!(
msg.contains("mutation") || msg.contains("does not support"),
"mutation guard message should explain the reason, got: {msg}"
);
}
#[tokio::test]
async fn test_mutation_selection_set_filters_response_fields() {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let adapter = Arc::new(SelectionSetFilterMockAdapter);
let executor = Executor::new(schema, adapter);
let result = executor.execute("mutation { createUser { id name } }", None).await.unwrap();
let data = result.get("data").and_then(|d| d.get("createUser")).unwrap();
assert!(data.get("__typename").is_some(), "response must include __typename");
assert!(data.get("id").is_some(), "response must include selected field 'id'");
assert!(data.get("name").is_some(), "response must include selected field 'name'");
assert!(
data.get("email").is_none(),
"response must NOT include non-selected field 'email'"
);
assert!(data.get("bio").is_none(), "response must NOT include non-selected field 'bio'");
}
#[tokio::test]
async fn test_mutation_empty_selection_set_returns_all_fields() {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
schema.mutations.push(MutationDefinition {
sql_source: Some("fn_create_user".to_string()),
..MutationDefinition::new("createUser", "User")
});
let adapter = Arc::new(EmptySelectionMockAdapter);
let executor = Executor::new(schema, adapter);
let result = executor.execute("mutation { createUser }", None).await.unwrap();
let data = result.get("data").and_then(|d| d.get("createUser")).unwrap();
assert!(data.get("__typename").is_some(), "response must include __typename");
assert!(data.get("id").is_some(), "response must include all field 'id'");
assert!(data.get("name").is_some(), "response must include all field 'name'");
assert!(data.get("email").is_some(), "response must include all field 'email'");
}
struct CapturingFunctionCallAdapter {
captured_args: std::sync::Mutex<Vec<serde_json::Value>>,
}
impl CapturingFunctionCallAdapter {
fn new() -> Self {
Self {
captured_args: std::sync::Mutex::new(Vec::new()),
}
}
fn args(&self) -> Vec<serde_json::Value> {
self.captured_args.lock().unwrap().clone()
}
}
#[async_trait]
impl DatabaseAdapter for CapturingFunctionCallAdapter {
async fn execute_function_call(
&self,
_function_name: &str,
args: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
use serde_json::json;
*self.captured_args.lock().unwrap() = args.to_vec();
let mut row = std::collections::HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert("entity".to_string(), json!({"id": "1"}));
row.insert("entity_type".to_string(), json!("User"));
row.insert("message".to_string(), json!(""));
Ok(vec![row])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&crate::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
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(vec![])
}
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>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
impl SupportsMutations for CapturingFunctionCallAdapter {}
fn schema_with_update_mutation() -> CompiledSchema {
use crate::schema::{
FieldType, InputFieldDefinition, InputObjectDefinition, MutationDefinition,
MutationOperation,
};
let mut schema = CompiledSchema::new();
schema.input_types.push(InputObjectDefinition {
name: "UpdateUserInput".to_string(),
fields: vec![
InputFieldDefinition::new("id", "ID!"),
InputFieldDefinition::new("name", "String"),
InputFieldDefinition::new("email", "String"),
],
description: None,
metadata: None,
});
schema.mutations.push(MutationDefinition {
name: "update_user".to_string(),
return_type: "User".to_string(),
sql_source: Some("update_user".to_string()),
operation: MutationOperation::Update {
table: "update_user".to_string(),
},
arguments: vec![crate::schema::ArgumentDefinition {
name: "input".to_string(),
arg_type: FieldType::Input("UpdateUserInput".to_string()),
nullable: false,
default_value: None,
description: None,
deprecation: None,
}],
..MutationDefinition::new("update_user", "User")
});
schema
}
fn schema_with_insert_mutation() -> CompiledSchema {
use crate::schema::{
FieldType, InputFieldDefinition, InputObjectDefinition, MutationDefinition,
MutationOperation,
};
let mut schema = CompiledSchema::new();
schema.input_types.push(InputObjectDefinition {
name: "CreateUserInput".to_string(),
fields: vec![
InputFieldDefinition::new("name", "String!"),
InputFieldDefinition::new("email", "String!"),
],
description: None,
metadata: None,
});
schema.mutations.push(MutationDefinition {
name: "create_user".to_string(),
return_type: "User".to_string(),
sql_source: Some("create_user".to_string()),
operation: MutationOperation::Insert {
table: "create_user".to_string(),
},
arguments: vec![crate::schema::ArgumentDefinition {
name: "input".to_string(),
arg_type: FieldType::Input("CreateUserInput".to_string()),
nullable: false,
default_value: None,
description: None,
deprecation: None,
}],
..MutationDefinition::new("create_user", "User")
});
schema
}
#[tokio::test]
async fn update_mutation_passes_input_as_single_jsonb_arg() {
let schema = schema_with_update_mutation();
let adapter = Arc::new(CapturingFunctionCallAdapter::new());
let adapter_ref = Arc::clone(&adapter);
let executor = Executor::new(schema, adapter);
let vars = serde_json::json!({
"input": { "id": "abc", "name": "Alice", "email": "alice@example.com" }
});
executor
.execute_mutation("update_user", Some(&vars), &HashMap::new())
.await
.unwrap();
let captured = adapter_ref.args();
assert_eq!(captured.len(), 1, "update mutation must pass exactly one JSONB arg");
assert!(
captured[0].is_object(),
"the single arg must be a JSON object (JSONB), got: {:?}",
captured[0]
);
assert_eq!(captured[0]["id"], "abc");
assert_eq!(captured[0]["name"], "Alice");
}
#[tokio::test]
async fn insert_mutation_flattens_fields_to_positional_args() {
let schema = schema_with_insert_mutation();
let adapter = Arc::new(CapturingFunctionCallAdapter::new());
let adapter_ref = Arc::clone(&adapter);
let executor = Executor::new(schema, adapter);
let vars = serde_json::json!({
"input": { "name": "Bob", "email": "bob@example.com" }
});
executor
.execute_mutation("create_user", Some(&vars), &HashMap::new())
.await
.unwrap();
let captured = adapter_ref.args();
assert_eq!(captured.len(), 2, "insert mutation must flatten to two positional args");
assert_eq!(captured[0], "Bob");
assert_eq!(captured[1], "bob@example.com");
}
#[tokio::test]
async fn update_mutation_preserves_explicit_null_in_jsonb() {
let schema = schema_with_update_mutation();
let adapter = Arc::new(CapturingFunctionCallAdapter::new());
let adapter_ref = Arc::clone(&adapter);
let executor = Executor::new(schema, adapter);
let vars = serde_json::json!({
"input": { "id": "abc", "name": null }
});
executor
.execute_mutation("update_user", Some(&vars), &HashMap::new())
.await
.unwrap();
let captured = adapter_ref.args();
assert_eq!(captured.len(), 1);
let obj = captured[0].as_object().unwrap();
assert!(obj.contains_key("name"), "key 'name' must be present in JSONB (explicit null)");
assert!(obj["name"].is_null(), "'name' must be null, not absent");
}
#[tokio::test]
async fn update_mutation_absent_field_not_in_jsonb() {
let schema = schema_with_update_mutation();
let adapter = Arc::new(CapturingFunctionCallAdapter::new());
let adapter_ref = Arc::clone(&adapter);
let executor = Executor::new(schema, adapter);
let vars = serde_json::json!({
"input": { "id": "abc", "name": "Alice" }
});
executor
.execute_mutation("update_user", Some(&vars), &HashMap::new())
.await
.unwrap();
let captured = adapter_ref.args();
assert_eq!(captured.len(), 1);
let obj = captured[0].as_object().unwrap();
assert!(
!obj.contains_key("email"),
"absent field 'email' must NOT appear in JSONB (leave DB value unchanged)"
);
}
}
mod mutation_audit {
use std::sync::{Arc, Mutex};
use tracing::Subscriber;
use tracing_subscriber::{Layer, Registry, layer::Context, prelude::*};
use super::*;
use crate::{
db::types::{DatabaseType, PoolMetrics},
schema::MutationOperation,
};
struct AuditMockAdapter;
#[async_trait]
impl DatabaseAdapter for AuditMockAdapter {
async fn execute_function_call(
&self,
_function_name: &str,
_args: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
use serde_json::json;
let mut row = std::collections::HashMap::new();
row.insert("succeeded".to_string(), json!(true));
row.insert("state_changed".to_string(), json!(true));
row.insert("entity".to_string(), json!({"id": "1"}));
row.insert("entity_type".to_string(), json!("User"));
row.insert("message".to_string(), json!(""));
Ok(vec![row])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&crate::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[OrderByClause]>,
) -> Result<Vec<JsonbValue>> {
Ok(vec![])
}
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(vec![])
}
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>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
impl SupportsMutations for AuditMockAdapter {}
struct CapturingLayer {
events: Arc<Mutex<Vec<String>>>,
}
impl<S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>> Layer<S>
for CapturingLayer
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() == "fraiseql::mutation_audit" {
self.events.lock().unwrap().push(event.metadata().name().to_string());
}
}
}
fn schema_with_insert_mutation() -> CompiledSchema {
use crate::schema::MutationDefinition;
let mut schema = CompiledSchema::new();
let mut def = MutationDefinition::new("createUser", "User");
def.sql_source = Some("fn_create_user".to_string());
def.operation = MutationOperation::Insert {
table: "users".to_string(),
};
schema.mutations.push(def);
schema
}
#[test]
fn kind_str_insert() {
assert_eq!(
MutationOperation::Insert {
table: "users".to_string(),
}
.kind_str(),
"insert"
);
}
#[test]
fn kind_str_update() {
assert_eq!(
MutationOperation::Update {
table: "users".to_string(),
}
.kind_str(),
"update"
);
}
#[test]
fn kind_str_delete() {
assert_eq!(
MutationOperation::Delete {
table: "users".to_string(),
}
.kind_str(),
"delete"
);
}
#[test]
fn kind_str_custom() {
assert_eq!(MutationOperation::Custom.kind_str(), "custom");
}
#[test]
fn audit_mutations_default_false() {
assert!(
!RuntimeConfig::default().audit_mutations,
"audit_mutations must default to false"
);
}
#[tokio::test]
async fn audit_event_emitted_when_enabled() {
let captured = Arc::new(Mutex::new(Vec::<String>::new()));
let layer = CapturingLayer {
events: captured.clone(),
};
let subscriber = Registry::default().with(layer);
let _guard = tracing::subscriber::set_default(subscriber);
let schema = schema_with_insert_mutation();
let config = RuntimeConfig {
audit_mutations: true,
..RuntimeConfig::default()
};
let executor = Executor::with_config(schema, Arc::new(AuditMockAdapter), config);
executor.execute_mutation("createUser", None, &HashMap::new()).await.unwrap();
let events = captured.lock().unwrap();
assert!(
!events.is_empty(),
"Expected a mutation audit event when audit_mutations=true, got none"
);
}
#[tokio::test]
async fn no_audit_event_when_disabled() {
let captured = Arc::new(Mutex::new(Vec::<String>::new()));
let layer = CapturingLayer {
events: captured.clone(),
};
let subscriber = Registry::default().with(layer);
let _guard = tracing::subscriber::set_default(subscriber);
let schema = schema_with_insert_mutation();
let executor = Executor::new(schema, Arc::new(AuditMockAdapter));
executor.execute_mutation("createUser", None, &HashMap::new()).await.unwrap();
let events = captured.lock().unwrap();
assert!(
events.is_empty(),
"Expected no audit events when audit_mutations=false, got: {events:?}"
);
}
}