#![allow(clippy::unwrap_used, clippy::panic)] use fraiseql_core::schema::{
ArgumentDefinition, AutoParams, Cardinality, FieldDefinition, FieldType, QueryDefinition,
Relationship, RestConfig, TypeDefinition,
};
use super::*;
fn test_config() -> RestConfig {
RestConfig {
max_page_size: 100,
default_page_size: 20,
max_filter_bytes: 4096,
..RestConfig::default()
}
}
fn user_type_def() -> TypeDefinition {
TypeDefinition::new("User", "v_user")
.with_field(FieldDefinition::new("id", FieldType::Uuid))
.with_field(FieldDefinition::new("name", FieldType::String))
.with_field(FieldDefinition::new("email", FieldType::String))
.with_field(FieldDefinition::new("age", FieldType::Int))
.with_field(FieldDefinition::new("active", FieldType::Boolean))
}
fn list_query_def() -> QueryDefinition {
QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
auto_params: AutoParams::all(),
arguments: vec![
ArgumentDefinition::optional("where", FieldType::Json),
ArgumentDefinition::optional("orderBy", FieldType::Json),
ArgumentDefinition::optional("limit", FieldType::Int),
ArgumentDefinition::optional("offset", FieldType::Int),
],
..default_query_def()
}
}
fn single_query_def() -> QueryDefinition {
QueryDefinition {
name: "user".to_string(),
return_type: "User".to_string(),
returns_list: false,
arguments: vec![ArgumentDefinition::new("id", FieldType::Uuid)],
..default_query_def()
}
}
fn relay_query_def() -> QueryDefinition {
QueryDefinition {
name: "users".to_string(),
return_type: "User".to_string(),
returns_list: true,
relay: true,
relay_cursor_column: Some("pk_user".to_string()),
auto_params: AutoParams::all(),
arguments: vec![
ArgumentDefinition::optional("first", FieldType::Int),
ArgumentDefinition::optional("after", FieldType::String),
ArgumentDefinition::optional("last", FieldType::Int),
ArgumentDefinition::optional("before", FieldType::String),
],
..default_query_def()
}
}
fn default_query_def() -> QueryDefinition {
QueryDefinition::new("test", "Test")
}
fn extractor_list<'a>(
config: &'a RestConfig,
query_def: &'a QueryDefinition,
type_def: &'a TypeDefinition,
) -> RestParamExtractor<'a> {
RestParamExtractor::new(config, query_def, Some(type_def))
}
#[test]
fn path_param_int_coercion() {
let config = test_config();
let qd = QueryDefinition {
arguments: vec![ArgumentDefinition::new("id", FieldType::Int)],
..single_query_def()
};
let td = user_type_def();
let ext = RestParamExtractor::new(&config, &qd, Some(&td));
let result = ext.extract(&[("id", "123")], &[]).unwrap();
assert_eq!(result.path_params, vec![("id".to_string(), serde_json::json!(123))]);
}
#[test]
fn path_param_uuid_passthrough() {
let config = test_config();
let qd = single_query_def();
let td = user_type_def();
let ext = RestParamExtractor::new(&config, &qd, Some(&td));
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let result = ext.extract(&[("id", uuid)], &[]).unwrap();
assert_eq!(result.path_params, vec![("id".to_string(), serde_json::json!(uuid))]);
}
#[test]
fn offset_pagination_explicit() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("limit", "10"), ("offset", "5")]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 10,
offset: 5,
}
);
}
#[test]
fn offset_pagination_defaults() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 20, offset: 0,
}
);
}
#[test]
fn limit_clamped_to_max_page_size() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("limit", "500")]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 100,
offset: 0,
}
);
}
#[test]
fn cursor_pagination_explicit() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("first", "10"), ("after", "abc")]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Cursor {
first: Some(10),
after: Some("abc".to_string()),
last: None,
before: None,
}
);
}
#[test]
fn cursor_pagination_defaults() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Cursor {
first: Some(20), after: None,
last: None,
before: None,
}
);
}
#[test]
fn first_clamped_to_max_page_size() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("first", "500")]).unwrap();
match result.pagination {
PaginationParams::Cursor { first, .. } => assert_eq!(first, Some(100)),
other => panic!("expected Cursor, got {other:?}"),
}
}
#[test]
fn an_absent_limit_is_recorded_as_absent_and_still_planned_as_the_default() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 20,
offset: 0,
},
"the plan still serves the default page"
);
assert_eq!(
result.requested_pagination,
RequestedPagination::default(),
"and the request is recorded as having asked for none of it"
);
}
#[test]
fn an_absent_cursor_is_recorded_as_absent_and_still_planned_as_the_default() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Cursor {
first: Some(20),
after: None,
last: None,
before: None,
},
"the plan still serves the default page"
);
assert_eq!(
result.requested_pagination,
RequestedPagination::default(),
"and no cursor parameter was sent, which is what an export has to be able to see"
);
}
#[test]
fn a_supplied_limit_is_recorded_unclamped_and_planned_clamped() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("limit", "500")]).unwrap();
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 100, offset: 0,
},
"a page is bounded by `max_page_size`"
);
assert_eq!(
result.requested_pagination.limit,
Some(500),
"an export total is not a page, so what was asked for is kept whole"
);
}
#[test]
fn a_repeated_limit_resolves_once_for_the_plan_and_the_request() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("limit", "5"), ("limit", "9")]).unwrap();
assert_eq!(
result.requested_pagination.limit,
Some(9),
"last-wins, the same resolution the projection and the plan use"
);
assert_eq!(
result.pagination,
PaginationParams::Offset {
limit: 9,
offset: 0,
},
"and the plan agrees, because there is only one reader left"
);
}
#[test]
fn every_cursor_parameter_is_recorded_as_sent() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let forward = ext.extract(&[], &[("first", "10"), ("after", "abc")]).unwrap();
assert_eq!(
forward.requested_pagination,
RequestedPagination {
first: Some(10),
after: Some("abc".to_string()),
..RequestedPagination::default()
}
);
let backward = ext.extract(&[], &[("last", "7"), ("before", "xyz")]).unwrap();
assert_eq!(
backward.requested_pagination,
RequestedPagination {
last: Some(7),
before: Some("xyz".to_string()),
..RequestedPagination::default()
}
);
}
#[test]
fn a_single_resource_route_records_no_requested_pagination() {
let config = test_config();
let qd = single_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let result = ext.extract(&[("id", uuid)], &[("limit", "5")]).unwrap();
assert_eq!(result.pagination, PaginationParams::None);
assert_eq!(result.requested_pagination, RequestedPagination::default());
}
#[test]
fn relay_rejects_limit_offset() {
let config = test_config();
let qd = relay_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("limit", "10")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cursor-based pagination"), "got: {msg}");
assert!(msg.contains("first"), "got: {msg}");
}
#[test]
fn offset_rejects_first_after() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("first", "10")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("offset-based pagination"), "got: {msg}");
assert!(msg.contains("limit"), "got: {msg}");
}
#[test]
fn simple_equality_filter() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("name", "Alice")]).unwrap();
assert_eq!(result.where_clause, Some(serde_json::json!({ "name": { "eq": "Alice" } })));
}
#[test]
fn bracket_operator_filter() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("name[icontains]", "Ali")]).unwrap();
assert_eq!(result.where_clause, Some(serde_json::json!({ "name": { "icontains": "Ali" } })));
}
#[test]
fn bracket_operator_invalid() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("name[beginsWith]", "A")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown bracket operator"), "got: {msg}");
assert!(msg.contains("Available bracket operators"), "got: {msg}");
}
#[test]
fn json_filter_passthrough() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"name":{"startswith":"A"}}"#;
let result = ext.extract(&[], &[("filter", filter)]).unwrap();
assert_eq!(result.where_clause, Some(serde_json::json!({ "name": { "startswith": "A" } })));
}
#[test]
fn filter_exceeding_max_bytes() {
let config = RestConfig {
max_filter_bytes: 10,
..test_config()
};
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"name":{"eq":"very long value here"}}"#;
let err = ext.extract(&[], &[("filter", filter)]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("exceeds maximum size"), "got: {msg}");
}
#[test]
fn filter_unknown_field() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"bogus":{"eq":"x"}}"#;
let err = ext.extract(&[], &[("filter", filter)]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown field 'bogus'"), "got: {msg}");
assert!(msg.contains("Available fields"), "got: {msg}");
}
#[test]
fn filter_unknown_operator() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"name":{"bogusOp":"x"}}"#;
let err = ext.extract(&[], &[("filter", filter)]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown filter operator"), "got: {msg}");
assert!(msg.contains("Available operators"), "got: {msg}");
}
#[test]
fn filter_nesting_depth_exceeded() {
let config = test_config();
let qd = list_query_def();
let ext = RestParamExtractor::new(&config, &qd, None);
let mut json = r#""leaf""#.to_string();
for i in 0..65 {
json = format!(r#"{{"k{i}":{json}}}"#);
}
let filter = &json;
let err = ext.extract(&[], &[("filter", filter)]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nesting depth"), "got: {msg}");
}
#[test]
fn sort_ascending_descending() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("sort", "name,-age")]).unwrap();
assert_eq!(
result.order_by,
Some(serde_json::json!([
{ "field": "name", "direction": "ASC" },
{ "field": "age", "direction": "DESC" },
]))
);
}
#[test]
fn sort_invalid_field() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("sort", "bogus")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown field 'bogus'"), "got: {msg}");
}
#[test]
fn select_fields() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("select", "id,name")]).unwrap();
assert_eq!(
result.field_selection,
RestFieldSpec::Fields(vec!["id".to_string(), "name".to_string()])
);
}
#[test]
fn select_dot_notation_rejects_non_count_suffix() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("select", "address.city")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unsupported dot-suffix"), "got: {msg}");
}
#[test]
fn unknown_param_rejected() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("unknown", "x")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown query parameter"), "got: {msg}");
assert!(msg.contains("Available parameters"), "got: {msg}");
}
#[test]
fn coerce_int() {
let result = coerce_to_type("42", &FieldType::Int).unwrap();
assert_eq!(result, serde_json::json!(42));
}
#[test]
fn coerce_float() {
let result = coerce_to_type("2.78", &FieldType::Float).unwrap();
assert_eq!(result, serde_json::json!(2.78));
}
#[test]
fn coerce_boolean_true() {
assert_eq!(coerce_to_type("true", &FieldType::Boolean).unwrap(), serde_json::json!(true));
assert_eq!(coerce_to_type("1", &FieldType::Boolean).unwrap(), serde_json::json!(true));
assert_eq!(coerce_to_type("yes", &FieldType::Boolean).unwrap(), serde_json::json!(true));
}
#[test]
fn coerce_boolean_false() {
assert_eq!(coerce_to_type("false", &FieldType::Boolean).unwrap(), serde_json::json!(false));
assert_eq!(coerce_to_type("0", &FieldType::Boolean).unwrap(), serde_json::json!(false));
}
#[test]
fn coerce_boolean_invalid() {
let err = coerce_to_type("maybe", &FieldType::Boolean).unwrap_err();
assert!(err.to_string().contains("Expected boolean"), "{err}");
}
#[test]
fn coerce_string_passthrough() {
let result = coerce_to_type("hello", &FieldType::String).unwrap();
assert_eq!(result, serde_json::json!("hello"));
}
#[test]
fn coerce_json_value() {
let result = coerce_to_type(r#"{"key":"val"}"#, &FieldType::Json).unwrap();
assert_eq!(result, serde_json::json!({"key": "val"}));
}
#[test]
fn coerce_list_csv() {
let result = coerce_to_type("a,b,c", &FieldType::List(Box::new(FieldType::String))).unwrap();
assert_eq!(result, serde_json::json!(["a", "b", "c"]));
}
#[test]
fn coerce_list_json_array() {
let result =
coerce_to_type(r#"["a","b"]"#, &FieldType::List(Box::new(FieldType::String))).unwrap();
assert_eq!(result, serde_json::json!(["a", "b"]));
}
#[test]
fn single_resource_no_pagination() {
let config = test_config();
let qd = single_query_def();
let td = user_type_def();
let ext = RestParamExtractor::new(&config, &qd, Some(&td));
let result = ext.extract(&[("id", "550e8400-e29b-41d4-a716-446655440000")], &[]).unwrap();
assert_eq!(result.pagination, PaginationParams::None);
}
#[test]
fn total_params_exceeding_max() {
let config = test_config();
let qd = list_query_def();
let ext = RestParamExtractor::new(&config, &qd, None);
let pairs: Vec<(String, String)> =
(0..1001).map(|i| (format!("f{i}"), format!("v{i}"))).collect();
let query_pairs: Vec<(&str, &str)> =
pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let err = ext.extract(&[], &query_pairs).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Too many parameters"), "got: {msg}");
}
#[test]
fn parse_bracket_key_valid() {
assert_eq!(
parse_bracket_key("name[icontains]"),
Some(("name".to_string(), "icontains".to_string()))
);
}
#[test]
fn parse_bracket_key_no_brackets() {
assert_eq!(parse_bracket_key("name"), None);
}
#[test]
fn parse_bracket_key_empty_op() {
assert_eq!(parse_bracket_key("name[]"), None);
}
#[test]
fn parse_bracket_key_empty_field() {
assert_eq!(parse_bracket_key("[op]"), None);
}
#[test]
fn json_depth_flat() {
assert_eq!(json_depth(&serde_json::json!("hello")), 1);
}
#[test]
fn json_depth_nested_object() {
assert_eq!(json_depth(&serde_json::json!({"a": {"b": "c"}})), 3);
}
#[test]
fn json_depth_nested_array() {
assert_eq!(json_depth(&serde_json::json!([[[1]]])), 4);
}
#[test]
fn parse_select_entries_flat_fields() {
let entries = parse_select_entries("id,name,email").unwrap();
assert_eq!(
entries,
vec![
SelectEntry::Field("id".to_string()),
SelectEntry::Field("name".to_string()),
SelectEntry::Field("email".to_string()),
]
);
}
#[test]
fn parse_select_entries_embedded() {
let entries = parse_select_entries("id,name,posts(id,title)").unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0], SelectEntry::Field("id".to_string()));
assert_eq!(entries[1], SelectEntry::Field("name".to_string()));
match &entries[2] {
SelectEntry::Embedded(spec) => {
assert_eq!(spec.relationship, "posts");
assert!(spec.rename.is_none());
assert_eq!(
spec.fields,
vec![
SelectEntry::Field("id".to_string()),
SelectEntry::Field("title".to_string()),
]
);
},
_ => panic!("Expected Embedded"),
}
}
#[test]
fn parse_select_entries_nested_depth_2() {
let entries = parse_select_entries("id,posts(id,title,comments(id,body))").unwrap();
assert_eq!(entries.len(), 2);
match &entries[1] {
SelectEntry::Embedded(spec) => {
assert_eq!(spec.relationship, "posts");
assert_eq!(spec.fields.len(), 3);
match &spec.fields[2] {
SelectEntry::Embedded(inner) => {
assert_eq!(inner.relationship, "comments");
assert_eq!(
inner.fields,
vec![
SelectEntry::Field("id".to_string()),
SelectEntry::Field("body".to_string()),
]
);
},
_ => panic!("Expected nested Embedded"),
}
},
_ => panic!("Expected Embedded"),
}
}
#[test]
fn parse_select_entries_rename_syntax() {
let entries = parse_select_entries("id,author:fk_user(id,name)").unwrap();
assert_eq!(entries.len(), 2);
match &entries[1] {
SelectEntry::Embedded(spec) => {
assert_eq!(spec.relationship, "fk_user");
assert_eq!(spec.rename, Some("author".to_string()));
assert_eq!(
spec.fields,
vec![
SelectEntry::Field("id".to_string()),
SelectEntry::Field("name".to_string()),
]
);
},
_ => panic!("Expected Embedded"),
}
}
#[test]
fn parse_select_entries_count_only() {
let entries = parse_select_entries("id,posts.count").unwrap();
assert_eq!(
entries,
vec![
SelectEntry::Field("id".to_string()),
SelectEntry::Count("posts".to_string()),
]
);
}
#[test]
fn parse_select_entries_unbalanced_parens() {
let err = parse_select_entries("id,posts(id,title").unwrap_err();
assert!(err.to_string().contains("Unbalanced parentheses"));
}
#[test]
fn parse_select_entries_invalid_dot_suffix() {
let err = parse_select_entries("id,posts.foo").unwrap_err();
assert!(err.to_string().contains("Unsupported dot-suffix"));
}
#[test]
fn parse_select_entries_multibyte_field_does_not_panic() {
let entries = parse_select_entries("é,id").expect("multi-byte select must not panic");
assert_eq!(entries.len(), 2);
assert!(matches!(&entries[0], SelectEntry::Field(f) if f == "é"));
assert!(matches!(&entries[1], SelectEntry::Field(f) if f == "id"));
}
#[test]
fn parse_select_entries_multibyte_inside_embedding() {
let entries = parse_select_entries("posts(café,id)").expect("must not panic");
let SelectEntry::Embedded(spec) = &entries[0] else {
panic!("expected embedded spec, got {:?}", entries[0]);
};
assert_eq!(spec.relationship, "posts");
assert_eq!(spec.fields.len(), 2);
assert!(matches!(&spec.fields[0], SelectEntry::Field(f) if f == "café"));
}
#[test]
fn parse_select_entries_multibyte_count_suffix() {
let entries = parse_select_entries("café.count").expect("must not panic");
assert!(matches!(&entries[0], SelectEntry::Count(n) if n == "café"));
}
#[test]
fn parse_select_entries_excessive_nesting_is_rejected_not_overflow() {
let deep = format!("{}{}", "a(".repeat(40), ")".repeat(40));
let err = parse_select_entries(&deep).expect_err("deep nesting must be rejected");
assert!(
err.to_string().contains("nesting depth exceeds maximum"),
"expected depth-limit error, got: {err}"
);
}
#[test]
fn embedding_depth_within_limit() {
let spec = EmbeddedSpec {
relationship: "posts".to_string(),
rename: None,
fields: vec![SelectEntry::Field("id".to_string())],
};
assert!(validate_embedding_depth(&spec, 1, 3).is_ok());
}
#[test]
fn embedding_depth_exceeds_limit() {
let inner = EmbeddedSpec {
relationship: "comments".to_string(),
rename: None,
fields: vec![SelectEntry::Field("id".to_string())],
};
let outer = EmbeddedSpec {
relationship: "posts".to_string(),
rename: None,
fields: vec![SelectEntry::Embedded(inner)],
};
let err = validate_embedding_depth(&outer, 1, 1).unwrap_err();
assert!(err.to_string().contains("exceeds maximum"));
}
fn user_type_with_relationships() -> TypeDefinition {
let mut td = user_type_def();
td.relationships = vec![Relationship {
name: "posts".to_string(),
target_type: "Post".to_string(),
foreign_key: "fk_user".to_string(),
referenced_key: "pk_user".to_string(),
cardinality: Cardinality::OneToMany,
}];
td
}
#[test]
fn extract_with_valid_embedding() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("select", "id,name,posts(id,title)")]);
let params = result.unwrap();
assert_eq!(params.embeddings.len(), 1);
assert_eq!(params.embeddings[0].relationship, "posts");
}
#[test]
fn extract_with_invalid_relationship() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def(); let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("select", "id,comments(id,body)")]).unwrap_err();
assert!(err.to_string().contains("has no relationship 'comments'"));
assert!(err.to_string().contains("Available: none"));
}
#[test]
fn extract_with_embedding_filter() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(
&[],
&[
("select", "id,posts(id,title)"),
("posts.status", "published"),
],
);
let params = result.unwrap();
assert_eq!(params.embedding_filters.len(), 1);
let posts_filter = params.embedding_filters.get("posts").unwrap();
assert_eq!(posts_filter, &serde_json::json!({"status": {"eq": "published"}}),);
}
#[test]
fn extract_count_only_embedding() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("select", "id,posts.count")]);
let params = result.unwrap();
assert_eq!(params.embedding_counts, vec!["posts"]);
}
#[test]
fn extract_embedding_depth_exceeded() {
let mut config = test_config();
config.max_embedding_depth = 1;
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("select", "id,posts(id,comments(id,body))")]).unwrap_err();
assert!(err.to_string().contains("exceeds maximum"));
}
fn article_type_def() -> TypeDefinition {
TypeDefinition::new("Article", "v_article")
.with_field(FieldDefinition::new("id", FieldType::Uuid))
.with_field(FieldDefinition::new("title", FieldType::String))
.with_field(FieldDefinition::new("body", FieldType::String))
.with_field(FieldDefinition::new("status", FieldType::String))
}
fn article_list_query_def() -> QueryDefinition {
QueryDefinition {
name: "articles".to_string(),
return_type: "Article".to_string(),
returns_list: true,
auto_params: AutoParams::all(),
arguments: vec![
ArgumentDefinition::optional("where", FieldType::Json),
ArgumentDefinition::optional("orderBy", FieldType::Json),
ArgumentDefinition::optional("limit", FieldType::Int),
ArgumentDefinition::optional("offset", FieldType::Int),
],
..default_query_def()
}
}
#[test]
fn search_param_parsed() {
let config = test_config();
let qd = article_list_query_def();
let td = article_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("search", "rust async")]).unwrap();
assert_eq!(result.search_query, Some("rust async".to_string()));
}
#[test]
fn search_combined_with_filters() {
let config = test_config();
let qd = article_list_query_def();
let td = article_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("search", "rust"), ("status[eq]", "published")]).unwrap();
assert_eq!(result.search_query, Some("rust".to_string()));
assert_eq!(
result.where_clause,
Some(serde_json::json!({ "status": { "eq": "published" } }))
);
}
#[test]
fn search_on_resource_without_searchable_fields_fails() {
let config = test_config();
let qd = list_query_def();
let td = TypeDefinition::new("Counter", "v_counter")
.with_field(FieldDefinition::new("id", FieldType::Uuid))
.with_field(FieldDefinition::new("value", FieldType::Int));
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("search", "hello")]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Full-text search not available"), "got: {msg}");
assert!(msg.contains("No searchable fields"), "got: {msg}");
}
#[test]
fn search_with_explicit_sort_preserves_sort() {
let config = test_config();
let qd = article_list_query_def();
let td = article_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("search", "rust"), ("sort", "title")]).unwrap();
assert_eq!(result.search_query, Some("rust".to_string()));
assert!(result.order_by.is_some());
}
#[test]
fn search_on_single_resource_fails() {
let config = test_config();
let qd = single_query_def();
let td = user_type_def();
let ext = RestParamExtractor::new(&config, &qd, Some(&td));
let err = ext
.extract(&[("id", "550e8400-e29b-41d4-a716-446655440000")], &[("search", "x")])
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Full-text search not available"), "got: {msg}");
}
#[test]
fn logical_or_two_conditions() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("or", "(name[eq]=Alice,name[eq]=Bob)")]).unwrap();
assert_eq!(
result.where_clause,
Some(serde_json::json!({
"_or": [
{ "name": { "eq": "Alice" } },
{ "name": { "eq": "Bob" } }
]
}))
);
}
#[test]
fn logical_and_explicit() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("and", "(age[gte]=18,age[lte]=65)")]).unwrap();
assert_eq!(
result.where_clause,
Some(serde_json::json!({
"_and": [
{ "age": { "gte": 18 } },
{ "age": { "lte": 65 } }
]
}))
);
}
#[test]
fn logical_not() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext.extract(&[], &[("not", "(active[eq]=false)")]).unwrap();
assert_eq!(
result.where_clause,
Some(serde_json::json!({
"_not": [
{ "active": { "eq": false } }
]
}))
);
}
#[test]
fn logical_nested_or_and() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext
.extract(&[], &[("or", "(and=(age[gte]=18,active[eq]=true),name[eq]=admin)")])
.unwrap();
let wc = result.where_clause.unwrap();
assert!(wc.get("_or").is_some(), "expected _or in {wc}");
let or_arr = wc["_or"].as_array().unwrap();
assert_eq!(or_arr.len(), 2);
assert!(or_arr[0].get("_and").is_some(), "expected _and in {}", or_arr[0]);
}
#[test]
fn logical_combined_with_regular_filters() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let result = ext
.extract(
&[],
&[
("active[eq]", "true"),
("or", "(name[eq]=Alice,name[eq]=Bob)"),
],
)
.unwrap();
let wc = result.where_clause.unwrap();
assert!(wc.get("_and").is_some(), "expected _and wrapper in {wc}");
}
#[test]
fn logical_depth_exceeded() {
let config = test_config();
let qd = list_query_def();
let ext = RestParamExtractor::new(&config, &qd, None);
let mut inner = "name[eq]=x".to_string();
for _ in 0..65 {
inner = format!("or=({inner})");
}
let input = format!("({inner})");
let err = ext.extract(&[], &[("or", &input)]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nesting depth") || msg.contains("depth"), "got: {msg}");
}
#[test]
fn filter_json_with_logical_operators() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"_or":[{"name":{"eq":"Alice"}},{"name":{"eq":"Bob"}}]}"#;
let result = ext.extract(&[], &[("filter", filter)]).unwrap();
let wc = result.where_clause.unwrap();
assert!(wc.get("_or").is_some(), "expected _or in {wc}");
}
#[test]
fn filter_json_with_nested_logical_validates_fields() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let filter = r#"{"_or":[{"bogus":{"eq":"x"}}]}"#;
let err = ext.extract(&[], &[("filter", filter)]).unwrap_err();
assert!(err.to_string().contains("Unknown field 'bogus'"));
}
#[test]
fn logical_invalid_syntax() {
let config = test_config();
let qd = list_query_def();
let td = user_type_def();
let ext = extractor_list(&config, &qd, &td);
let err = ext.extract(&[], &[("or", "not-parenthetical")]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("must be enclosed in parentheses") || msg.contains("syntax"),
"got: {msg}"
);
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(2048))]
#[test]
fn parse_select_entries_never_panics(input in ".*") {
let _ = parse_select_entries(&input);
}
}
#[test]
fn every_advertised_rest_operator_parses_in_the_where_builder() {
for op in BRACKET_OPERATORS {
assert!(
fraiseql_core::db::WhereOperator::from_str(op).is_ok(),
"BRACKET_OPERATORS advertises {op:?} in ?field[op]= syntax, but the WHERE parser \
rejects it (#828)"
);
}
for op in OPERATOR_REGISTRY.keys() {
assert!(
fraiseql_core::db::WhereOperator::from_str(op).is_ok(),
"the ?filter= error message recommends {op:?}, but the WHERE parser rejects it (#828)"
);
}
}
#[test]
fn null_check_operands_coerce_to_boolean_not_to_the_field_type() {
let config = test_config();
let query_def = list_query_def();
let type_def =
user_type_def().with_field(FieldDefinition::new("createdAt", FieldType::DateTime));
let extractor = extractor_list(&config, &query_def, &type_def);
let params = extractor
.extract(&[], &[("createdAt[is_null]", "false")])
.expect("is_null=false is a valid filter");
let where_clause = params.where_clause.expect("filter produces a where clause");
assert_eq!(
where_clause["createdAt"]["is_null"],
serde_json::Value::Bool(false),
"operand must be the boolean false, not a DateTime-coerced string: {where_clause}"
);
let err = extractor
.extract(&[], &[("createdAt[is_null]", "yesterday")])
.expect_err("a non-boolean null-check operand must be refused, not defaulted");
assert!(
format!("{err}").contains("boolean"),
"the error must name the expected type, got: {err}"
);
}
#[test]
fn the_openapi_bracket_operator_list_matches_the_validator() {
let advertised = crate::routes::rest::openapi::format::bracket_operators_desc();
let listed: Vec<&str> = advertised.split(", ").collect();
assert_eq!(
listed, BRACKET_OPERATORS,
"the OpenAPI description and the request validator must name one set"
);
}
#[test]
fn a_dotted_parameter_naming_no_relationship_is_refused() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let undotted = ext.extract(&[], &[("nonsense", "x")]).unwrap_err();
assert!(
undotted.to_string().contains("nonsense"),
"the control: an unknown parameter is refused by name: {undotted}"
);
let dotted = ext
.extract(&[], &[("nonsense.field", "x")])
.expect_err("a dot must not exempt a parameter from the unknown-parameter rule");
assert!(
dotted.to_string().contains("nonsense"),
"the refusal names the relationship the type does not have: {dotted}"
);
}
#[test]
fn a_dotted_bracket_parameter_naming_no_relationship_is_refused() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let err = ext
.extract(&[], &[("nonsense.field[eq]", "x")])
.expect_err("the bracket spelling is exempt by the same mechanism");
assert!(err.to_string().contains("nonsense"), "the refusal names it: {err}");
}
#[test]
fn a_dotted_parameter_is_ignored_under_lenient_handling() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td).with_lenient_handling(true);
let params = ext
.extract(&[], &[("nonsense.field", "x")])
.expect("lenient handling ignores an unknown parameter rather than refusing it");
assert!(
params.embedding_filters.is_empty(),
"an ignored parameter must not survive as a filter: {:?}",
params.embedding_filters
);
}
#[test]
fn a_filter_on_an_unselected_relationship_is_accepted_by_the_extractor() {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let ext = extractor_list(&config, &qd, &td);
let params = ext
.extract(&[], &[("posts.status", "published")])
.expect("the extractor produces the filter; the representation decides what to do");
assert_eq!(
params.embedding_filters.len(),
1,
"an extractor-level refusal would reach the export path too, which needs #1275's \
diagnosis rather than this one's"
);
let ok = ext
.extract(
&[],
&[
("select", "id,posts(id,title)"),
("posts.status", "published"),
],
)
.expect("a filter on a selected embed is the supported request");
assert_eq!(ok.embedding_filters.len(), 1);
}
fn extract_embed_page(pairs: &[(&str, &str)]) -> Result<ExtractedParams, FraiseQLError> {
let config = test_config();
let qd = list_query_def();
let td = user_type_with_relationships();
let mut query = vec![("select", "id,posts(id,comments(id))")];
query.extend_from_slice(pairs);
extractor_list(&config, &qd, &td).extract(&[], &query)
}
#[test]
fn an_embed_limit_is_a_page_not_a_filter() {
let params = extract_embed_page(&[("posts.limit", "20")]).unwrap();
assert_eq!(params.embedding_pages, BTreeMap::from([("posts".to_string(), 20)]));
assert!(params.embedding_filters.is_empty(), "{:?}", params.embedding_filters);
}
#[test]
fn a_nested_embed_limit_is_keyed_by_its_path() {
let params = extract_embed_page(&[("posts.comments.limit", "5")]).unwrap();
assert_eq!(params.embedding_pages, BTreeMap::from([("posts.comments".to_string(), 5)]));
}
#[test]
fn an_embed_limit_above_the_ceiling_is_refused_not_clamped() {
let err = extract_embed_page(&[("posts.limit", "101")]).unwrap_err();
assert!(
err.to_string()
.contains("`posts.limit` 101 exceeds the maximum page size of 100"),
"{err}"
);
let params = extract_embed_page(&[("posts.limit", "100")]).unwrap();
assert_eq!(params.embedding_pages.get("posts"), Some(&100));
}
#[test]
fn an_embed_limit_of_zero_is_refused() {
let err = extract_embed_page(&[("posts.limit", "0")]).unwrap_err();
assert!(err.to_string().contains("Expected a positive integer"), "{err}");
assert!(err.to_string().contains("`posts.count`"), "{err}");
}
#[test]
fn offset_order_and_sort_are_reserved_at_the_level() {
for name in ["offset", "order", "sort"] {
let key = format!("posts.{name}");
let err = extract_embed_page(&[(key.as_str(), "1")]).unwrap_err();
assert!(err.to_string().contains("reserved"), "{key}: {err}");
}
}
#[test]
fn the_bracket_form_still_filters_a_field_named_limit() {
let params = extract_embed_page(&[("posts.limit[eq]", "5")]).unwrap();
assert!(params.embedding_pages.is_empty());
assert_eq!(
params.embedding_filters.get("posts"),
Some(&serde_json::json!({"limit": {"eq": "5"}}))
);
}