use std::sync::Arc;
use std::time::Duration;
use arrow::array::{Array, RecordBatch, StringArray};
use datafusion::error::Result as DFResult;
use datafusion::physical_plan::SendableRecordBatchStream;
use futures::StreamExt;
use super::ResolvedSubscription;
use super::cache::MemoryFeedCache;
use super::config::{FeedSubscription, RssConfig, inline_config};
use super::engine::{CACHE_MAX_BYTES, RssEngine};
use super::fetch::FeedFetcher;
pub(crate) const RSS2_MINIMAL: &str = concat!(
r#"<rss version="2.0"><channel>"#,
r#"<title>Minimal Feed</title>"#,
r#"<link>https://feed.example/</link>"#,
r#"<description>A minimal feed.</description>"#,
r#"<item><guid>https://feed.example/1</guid><title>First post</title>"#,
r#"<link>https://feed.example/1</link></item>"#,
r#"</channel></rss>"#,
);
fn utf8_column<'a>(batch: &'a RecordBatch, name: &str) -> &'a StringArray {
let index = batch
.schema()
.index_of(name)
.unwrap_or_else(|e| panic!("batch has no column {name:?}: {e}"));
batch
.column(index)
.as_any()
.downcast_ref::<StringArray>()
.unwrap_or_else(|| panic!("column {name:?} is not Utf8"))
}
pub(crate) fn str_col(batch: &RecordBatch, name: &str) -> Vec<String> {
let column = utf8_column(batch, name);
(0..column.len())
.map(|row| {
assert!(
column.is_valid(row),
"column {name:?} row {row} is NULL but the schema declares it non-nullable"
);
column.value(row).to_string()
})
.collect()
}
pub(crate) fn str_opt_col(batch: &RecordBatch, name: &str) -> Vec<Option<String>> {
let column = utf8_column(batch, name);
(0..column.len())
.map(|row| {
if column.is_valid(row) {
Some(column.value(row).to_string())
} else {
None
}
})
.collect()
}
pub(crate) fn feed_urls(server: &MockFeedServer, feeds: &[(&str, &str)]) -> Vec<(String, String)> {
feeds
.iter()
.map(|(name, path)| ((*name).to_string(), format!("{}{path}", server.url())))
.collect()
}
pub(crate) fn test_engine(
feeds: &[(String, String)],
tune: impl FnOnce(&mut RssConfig),
) -> RssEngine {
let subscriptions: Vec<ResolvedSubscription> = feeds
.iter()
.map(|(name, url)| ResolvedSubscription {
name: name.clone(),
url: url.clone(),
})
.collect();
let mut config = inline_config(
subscriptions
.iter()
.map(|sub| FeedSubscription {
url: sub.url.clone(),
name: Some(sub.name.clone()),
})
.collect(),
);
config.request_timeout_seconds = 5;
tune(&mut config);
let fetcher = FeedFetcher::new(
None,
Duration::from_secs(config.request_timeout_seconds),
config.max_response_bytes,
config.user_agent.clone(),
)
.expect("build the test fetcher");
let cache = Arc::new(MemoryFeedCache::new(
CACHE_MAX_BYTES,
subscriptions.len() + 8,
));
RssEngine::with_parts(
"rss_test".to_string(),
subscriptions,
&config,
fetcher,
cache,
)
}
pub(crate) async fn collect_stream(
stream: DFResult<SendableRecordBatchStream>,
) -> Vec<RecordBatch> {
let mut stream = stream.expect("execute returned a stream");
let mut batches = Vec::new();
while let Some(item) = stream.next().await {
batches.push(item.expect("an rss partition must never yield an error"));
}
batches
}
pub(crate) fn total_rows(batches: &[RecordBatch]) -> usize {
batches.iter().map(RecordBatch::num_rows).sum()
}
pub(crate) use crate::util::mock_http::{
MockHttpServer as MockFeedServer, MockResponse, RecordedRequest,
};
pub(crate) trait MockResponseExt {
fn xml(body: &str) -> Self;
}
impl MockResponseExt for MockResponse {
fn xml(body: &str) -> Self {
Self::new(200, body.as_bytes().to_vec()).with_header("content-type", "application/xml")
}
}