#![cfg(test)] // Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::common::{RustFSTestEnvironment, init_logging}; use aws_sdk_s3::Client; use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::types::{ CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType, OutputSerialization, RequestProgress, }; use bytes::Bytes; use std::error::Error; use std::time::Duration; const BUCKET: &str = "test-sql-bucket"; const CSV_OBJECT: &str = "test-data.csv"; const JSON_OBJECT: &str = "test-data.json"; const JSON_DOCUMENT_OBJECT: &str = "nested-data.json"; const JSON_ROOT_ARRAY_OBJECT: &str = "root-array.json"; const JSON_ROOT_SCALAR_ARRAY_OBJECT: &str = "root-scalars.json"; const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); type TestResult = Result>; async fn create_test_environment() -> TestResult<(RustFSTestEnvironment, Client)> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let client = env.create_s3_client(); Ok((env, client)) } async fn setup_test_bucket(client: &Client) -> TestResult<()> { client.create_bucket().bucket(BUCKET).send().await?; Ok(()) } async fn upload_test_csv(client: &Client) -> TestResult<()> { let csv_data = "name,age,city\nAlice,30,New York\nBob,25,Los Angeles\nCharlie,35,Chicago\nDiana,28,Boston"; client .put_object() .bucket(BUCKET) .key(CSV_OBJECT) .body(Bytes::from(csv_data.as_bytes()).into()) .send() .await?; Ok(()) } async fn upload_test_json(client: &Client) -> TestResult<()> { let json_data = r#"{"name":"Alice","age":30,"city":"New York"} {"name":"Bob","age":25,"city":"Los Angeles"} {"name":"Charlie","age":35,"city":"Chicago"} {"name":"Diana","age":28,"city":"Boston"}"#; client .put_object() .bucket(BUCKET) .key(JSON_OBJECT) .body(Bytes::from(json_data.as_bytes()).into()) .send() .await?; Ok(()) } async fn upload_nested_json_document(client: &Client) -> TestResult<()> { let json_data = r#"{"departments":[{"employees":[{"name":"Alice","active":true},{"name":"Bob","active":false}]},{"employees":[{"name":"Charlie","active":true}]}]}"#; client .put_object() .bucket(BUCKET) .key(JSON_DOCUMENT_OBJECT) .body(Bytes::from_static(json_data.as_bytes()).into()) .send() .await?; client .put_object() .bucket(BUCKET) .key(JSON_ROOT_ARRAY_OBJECT) .body(Bytes::from_static(br#"[{"name":"Alice"},{"name":"Bob"}]"#).into()) .send() .await?; client .put_object() .bucket(BUCKET) .key(JSON_ROOT_SCALAR_ARRAY_OBJECT) .body(Bytes::from_static(b"[1,2]").into()) .send() .await?; Ok(()) } async fn select_json_document(client: &Client, key: &str, expression: &str) -> TestResult { let response = client .select_object_content() .bucket(BUCKET) .key(key) .expression(expression) .expression_type(ExpressionType::Sql) .input_serialization( InputSerialization::builder() .json(JsonInput::builder().set_type(Some(JsonType::Document)).build()) .build(), ) .output_serialization(OutputSerialization::builder().json(JsonOutput::builder().build()).build()) .send() .await?; process_select_response(response).await } fn csv_select_request( client: &Client, key: &str, ) -> aws_sdk_s3::operation::select_object_content::builders::SelectObjectContentFluentBuilder { client .select_object_content() .bucket(BUCKET) .key(key) .expression("SELECT * FROM S3Object") .expression_type(ExpressionType::Sql) .input_serialization( InputSerialization::builder() .csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build()) .build(), ) .output_serialization(OutputSerialization::builder().csv(CsvOutput::builder().build()).build()) } async fn process_select_response( mut event_stream: aws_sdk_s3::operation::select_object_content::SelectObjectContentOutput, ) -> TestResult { tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move { let mut total_data = Vec::new(); let mut saw_end = false; while let Some(event) = event_stream.payload.recv().await? { match event { aws_sdk_s3::types::SelectObjectContentEventStream::Records(records_event) => { if let Some(payload) = records_event.payload { total_data.extend_from_slice(payload.as_ref()); } } aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => { saw_end = true; break; } _ => {} } } if !saw_end { return Err("Select response ended without an End event".into()); } Ok(String::from_utf8(total_data)?) }) .await .map_err(|_| -> Box { "Select response timed out".into() })? } async fn assert_input_byte_stats( client: &Client, object: &str, body: &[u8], expression: &str, input_serialization: InputSerialization, output_serialization: OutputSerialization, progress_enabled: bool, ) -> TestResult<()> { client .put_object() .bucket(BUCKET) .key(object) .body(Bytes::copy_from_slice(body).into()) .send() .await?; let mut request = client .select_object_content() .bucket(BUCKET) .key(object) .expression(expression) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization); if progress_enabled { request = request.request_progress(RequestProgress::builder().enabled(true).build()); } let response = request.send().await?; let mut payload = response.payload; let mut records_len = 0_u64; let mut last_progress: Option = None; let mut stats = None; let mut saw_end = false; tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async { // The AWS SDK validates both event-stream CRCs before yielding an event. while let Some(event) = payload.recv().await? { assert!(!saw_end, "Select emitted an event after End"); match event { aws_sdk_s3::types::SelectObjectContentEventStream::Records(records) => { assert!(stats.is_none(), "Select emitted Records after Stats"); if let Some(bytes) = records.payload { records_len = records_len.saturating_add(u64::try_from(bytes.as_ref().len())?); } } aws_sdk_s3::types::SelectObjectContentEventStream::Progress(event) => { assert!(stats.is_none(), "Select emitted Progress after Stats"); let details = event.details.ok_or("Progress event did not contain details")?; if let Some(previous) = last_progress.as_ref() { assert!(details.bytes_scanned() >= previous.bytes_scanned()); assert!(details.bytes_processed() >= previous.bytes_processed()); assert!(details.bytes_returned() >= previous.bytes_returned()); } last_progress = Some(details); } aws_sdk_s3::types::SelectObjectContentEventStream::Stats(event) => { assert!(stats.is_none(), "Select emitted more than one Stats event"); stats = event.details; } aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => { assert!(stats.is_some(), "Select emitted End before Stats"); saw_end = true; } _ => assert!(stats.is_none(), "Select emitted a non-terminal event after Stats"), } } Ok::<(), Box>(()) }) .await .map_err(|_| -> Box { "Select response timed out".into() })??; let stats = stats.ok_or("Select response ended without a Stats event")?; let input_len = i64::try_from(body.len())?; assert_eq!(stats.bytes_scanned(), Some(input_len)); assert_eq!(stats.bytes_processed(), Some(input_len)); assert_eq!(stats.bytes_returned(), Some(i64::try_from(records_len)?)); if progress_enabled { if let Some(progress) = last_progress { assert!(stats.bytes_scanned() >= progress.bytes_scanned()); assert!(stats.bytes_processed() >= progress.bytes_processed()); assert!(stats.bytes_returned() >= progress.bytes_returned()); } } else { assert!(last_progress.is_none(), "disabled request progress emitted a Progress event"); } assert!(saw_end, "Select response ended without an End event"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_http_event_order_crc_and_input_byte_stats() -> TestResult<()> { const CSV_BODY: &[u8] = b"name,age\nAlice,30\nBob,25\n"; const JSON_LINES_BODY: &[u8] = b"{\"name\":\"Alice\"}\n{\"name\":\"Bob\"}\n"; const JSON_DOCUMENT_BODY: &[u8] = b"[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]"; let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; assert_input_byte_stats( &client, "input-metrics.csv", CSV_BODY, "SELECT name FROM S3Object", InputSerialization::builder() .csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build()) .build(), OutputSerialization::builder().csv(CsvOutput::builder().build()).build(), true, ) .await?; assert_input_byte_stats( &client, "input-metrics.jsonl", JSON_LINES_BODY, "SELECT name FROM S3Object", InputSerialization::builder() .json(JsonInput::builder().set_type(Some(JsonType::Lines)).build()) .build(), OutputSerialization::builder().json(JsonOutput::builder().build()).build(), true, ) .await?; assert_input_byte_stats( &client, "input-metrics.json", JSON_DOCUMENT_BODY, "SELECT name FROM S3Object", InputSerialization::builder() .json(JsonInput::builder().set_type(Some(JsonType::Document)).build()) .build(), OutputSerialization::builder().json(JsonOutput::builder().build()).build(), true, ) .await?; assert_input_byte_stats( &client, "input-metrics-without-progress.csv", CSV_BODY, "SELECT name FROM S3Object", InputSerialization::builder() .csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build()) .build(), OutputSerialization::builder().csv(CsvOutput::builder().build()).build(), false, ) .await?; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_http_disconnect_releases_query() -> TestResult<()> { const OBJECT: &str = "disconnect.csv"; const ROWS: usize = 16 * 1024; const RELEASE_BACKOFF: Duration = Duration::from_millis(25); init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server_with_env(vec![], &[("RUSTFS_S3SELECT_MAX_CONCURRENT_QUERIES", "1")]) .await?; let client = env.create_s3_client(); setup_test_bucket(&client).await?; let row = format!("{}\n", "x".repeat(1023)); let mut body = Vec::with_capacity("value\n".len() + ROWS * row.len()); body.extend_from_slice(b"value\n"); for _ in 0..ROWS { body.extend_from_slice(row.as_bytes()); } client .put_object() .bucket(BUCKET) .key(OBJECT) .body(Bytes::from(body).into()) .send() .await?; // Leaving this response body unread fills the bounded HTTP/event channels before the query can finish. let first = csv_select_request(&client, OBJECT).send().await?; let saturated = csv_select_request(&client, OBJECT) .send() .await .expect_err("the first HTTP stream should retain the only query permit"); assert_eq!(saturated.as_service_error().and_then(ProvideErrorMetadata::code), Some("SlowDown")); drop(first); let second = tokio::time::timeout(Duration::from_secs(5), async { loop { match csv_select_request(&client, OBJECT).send().await { Ok(response) => return Ok::<_, Box>(response), Err(error) if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown") => { tokio::time::sleep(RELEASE_BACKOFF).await; } Err(error) => return Err(format!("unexpected Select error after disconnect: {error}").into()), } } }) .await .map_err(|_| -> Box { "disconnected Select did not release its query permit".into() })??; drop(second); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_csv_basic() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_csv(&client).await?; // Construct SelectObjectContent request - basic query let sql = "SELECT * FROM S3Object WHERE age > 28"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); let response = client .select_object_content() .bucket(BUCKET) .key(CSV_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await?; let result_str = process_select_response(response).await?; println!("CSV Select result: {result_str}"); // Verify results contain records with age > 28 assert!(result_str.contains("Alice,30,New York")); assert!(result_str.contains("Charlie,35,Chicago")); assert!(!result_str.contains("Bob,25,Los Angeles")); assert!(!result_str.contains("Diana,28,Boston")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_csv_aggregation() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_csv(&client).await?; // Construct aggregation query - use simpler approach let sql = "SELECT name, age FROM S3Object WHERE age >= 25"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); let response = client .select_object_content() .bucket(BUCKET) .key(CSV_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await?; let result_str = process_select_response(response).await?; println!("CSV Aggregation result: {result_str}"); // Verify query results - should include records with age >= 25 assert!(result_str.contains("Alice")); assert!(result_str.contains("Bob")); assert!(result_str.contains("Charlie")); assert!(result_str.contains("Diana")); assert!(result_str.contains("30")); assert!(result_str.contains("25")); assert!(result_str.contains("35")); assert!(result_str.contains("28")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_json_basic() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_json(&client).await?; // Construct JSON query let sql = "SELECT s.name, s.age FROM S3Object s WHERE s.age > 28"; let json_input = JsonInput::builder().set_type(Some(JsonType::Lines)).build(); let input_serialization = InputSerialization::builder().json(json_input).build(); let json_output = JsonOutput::builder().build(); let output_serialization = OutputSerialization::builder().json(json_output).build(); let response = client .select_object_content() .bucket(BUCKET) .key(JSON_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await?; let result_str = process_select_response(response).await?; println!("JSON Select result: {result_str}"); // Verify JSON query results assert!(result_str.contains("Alice")); assert!(result_str.contains("Charlie")); assert!(result_str.contains("30")); assert!(result_str.contains("35")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_nested_json_source_path() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_nested_json_document(&client).await?; let result = select_json_document( &client, JSON_DOCUMENT_OBJECT, "SELECT e.name FROM S3Object[*].departments[*].employees[*] AS e WHERE e.active = true", ) .await?; let names: Vec = result .lines() .filter(|line| !line.trim().is_empty()) .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["name"].as_str().ok_or("missing name field")?.to_string()) }) .collect::>()?; assert_eq!(names, vec!["Alice", "Charlie"]); let terminal_scalars = select_json_document( &client, JSON_DOCUMENT_OBJECT, "SELECT NAME FROM S3Object[*].DEPARTMENTS[*].employees[*].NAME", ) .await?; let scalar_names: Vec = terminal_scalars .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["name"].as_str().ok_or("missing scalar name field")?.to_string()) }) .collect::>()?; assert_eq!(scalar_names, vec!["Alice", "Bob", "Charlie"]); let aliased_scalars = select_json_document( &client, JSON_DOCUMENT_OBJECT, "SELECT v FROM S3Object[*].departments[*].employees[*].name AS v", ) .await?; let aliased_names: Vec = aliased_scalars .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["v"].as_str().ok_or("missing aliased scalar field")?.to_string()) }) .collect::>()?; assert_eq!(aliased_names, vec!["Alice", "Bob", "Charlie"]); let root_array = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][*] AS c").await?; let root_names: Vec = root_array .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["name"].as_str().ok_or("missing root-array name field")?.to_string()) }) .collect::>()?; assert_eq!(root_names, vec!["Alice", "Bob"]); let root_index = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][0] AS c").await?; let root_index_value: serde_json::Value = serde_json::from_str(root_index.trim())?; assert_eq!(root_index_value["name"], "Alice"); let root_scalars = select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT V FROM S3Object AS V").await?; let scalar_values: Vec = root_scalars .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["v"].as_i64().ok_or("missing root scalar value")?) }) .collect::>()?; assert_eq!(scalar_values, vec![1, 2]); let implicit_root_scalars = select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT S3Object FROM S3Object").await?; let implicit_scalar_values: Vec = implicit_root_scalars .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["s3object"].as_i64().ok_or("missing implicit root scalar value")?) }) .collect::>()?; assert_eq!(implicit_scalar_values, vec![1, 2]); let quoted_root_scalars = select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT \"S3Object\" FROM \"S3Object\"").await?; let quoted_scalar_values: Vec = quoted_root_scalars .lines() .map(|line| -> TestResult { let value: serde_json::Value = serde_json::from_str(line)?; Ok(value["S3Object"].as_i64().ok_or("missing quoted root scalar value")?) }) .collect::>()?; assert_eq!(quoted_scalar_values, vec![1, 2]); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_csv_limit() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_csv(&client).await?; // Test LIMIT clause let sql = "SELECT * FROM S3Object LIMIT 2"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); let response = client .select_object_content() .bucket(BUCKET) .key(CSV_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await?; let result_str = process_select_response(response).await?; println!("CSV Limit result: {result_str}"); // Verify only first 2 records are returned assert_eq!( result_str.lines().filter(|line| !line.trim().is_empty()).count(), 2, "Should return exactly 2 records" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_csv_order_by() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_csv(&client).await?; // Test ORDER BY clause let sql = "SELECT name, age FROM S3Object ORDER BY age DESC LIMIT 2"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); let response = client .select_object_content() .bucket(BUCKET) .key(CSV_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await?; let result_str = process_select_response(response).await?; println!("CSV Order By result: {result_str}"); // Verify ordered by age descending assert_eq!( result_str.lines().filter(|line| !line.trim().is_empty()).count(), 2, "Should return exactly 2 records" ); // Check if contains highest age records assert!(result_str.contains("Charlie,35")); assert!(result_str.contains("Alice,30")); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_error_handling() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; upload_test_csv(&client).await?; // Test invalid SQL query let sql = "SELECT * FROM S3Object WHERE invalid_column > 10"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); // This query should fail because invalid_column doesn't exist let error = client .select_object_content() .bucket(BUCKET) .key(CSV_OBJECT) .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await .expect_err("a query referencing an unknown column must fail"); assert_eq!( error.as_service_error().and_then(ProvideErrorMetadata::code), Some("EvaluatorBindingDoesNotExist") ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_select_object_content_nonexistent_object() -> TestResult<()> { let (_env, client) = create_test_environment().await?; setup_test_bucket(&client).await?; // Test query on nonexistent object let sql = "SELECT * FROM S3Object"; let csv_input = CsvInput::builder().file_header_info(FileHeaderInfo::Use).build(); let input_serialization = InputSerialization::builder().csv(csv_input).build(); let csv_output = CsvOutput::builder().build(); let output_serialization = OutputSerialization::builder().csv(csv_output).build(); let error = client .select_object_content() .bucket(BUCKET) .key("nonexistent.csv") .expression(sql) .expression_type(ExpressionType::Sql) .input_serialization(input_serialization) .output_serialization(output_serialization) .send() .await .expect_err("selecting a missing object must fail"); assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey")); Ok(()) }