use datafusion::datasource::listing::ListingTableUrl;
use datafusion::error::{DataFusionError, Result};
use glob::Pattern;
use url::Url;
const GLOB_START_CHARS: [char; 3] = ['*', '?', '['];
pub fn looks_like_location(name: &str) -> bool {
name.contains(['/', '\\', '.']) || name.contains(GLOB_START_CHARS) || name.starts_with('~')
}
pub fn expand_tilde(location: &str) -> String {
if location == "~" || location.starts_with("~/") {
if let Some(home) = std::env::var_os("HOME").and_then(|h| h.into_string().ok()) {
if !home.is_empty() {
return format!("{home}{}", &location[1..]);
}
}
}
location.to_string()
}
pub fn table_url(location: &str) -> Result<ListingTableUrl> {
let location = expand_tilde(location);
#[cfg(target_arch = "wasm32")]
let location = browser::resolve(&location)?;
if let Some((base, pattern)) = split_url_glob(&location) {
let url = Url::parse(base)
.map_err(|e| DataFusionError::Plan(format!("'{location}' is not a valid URL: {e}")))?;
let glob = Pattern::new(pattern).map_err(|e| {
DataFusionError::Plan(format!(
"'{pattern}' in '{location}' is not a valid glob: {e}"
))
})?;
return ListingTableUrl::try_new(url, Some(glob));
}
ListingTableUrl::parse(&location)
.map_err(|e| DataFusionError::Plan(format!("'{location}' is not a valid path or URL: {e}")))
}
pub fn split_url_glob(location: &str) -> Option<(&str, &str)> {
let scheme_end = location.find("://")?;
let scheme = &location[..scheme_end];
if scheme.len() < 2
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|| matches!(scheme, "http" | "https")
{
return None;
}
let authority_start = scheme_end + 3;
let path_start = authority_start + location[authority_start..].find('/')?;
let path = &location[path_start..];
let glob_at = path.find(GLOB_START_CHARS)?;
let split = path_start + path[..glob_at].rfind('/')? + 1;
Some((&location[..split], &location[split..]))
}
#[cfg(target_arch = "wasm32")]
pub mod browser {
use std::sync::RwLock;
use datafusion::error::{DataFusionError, Result};
use url::Url;
static BASE_URL: RwLock<Option<Url>> = RwLock::new(None);
pub fn set_base_url(base: Option<Url>) {
*BASE_URL.write().unwrap_or_else(|e| e.into_inner()) = base;
}
pub fn base_url() -> Option<Url> {
BASE_URL.read().unwrap_or_else(|e| e.into_inner()).clone()
}
pub(crate) fn resolve(location: &str) -> Result<String> {
if matches!(Url::parse(location), Ok(url) if url.scheme().len() > 1) {
return Ok(location.to_string());
}
let base = base_url().ok_or_else(|| {
DataFusionError::Plan(format!(
"'{location}' is a relative path, and the browser has no current directory; \
use an http:// or https:// URL"
))
})?;
base.join(location).map(String::from).map_err(|e| {
DataFusionError::Plan(format!("'{location}' is not a valid path or URL: {e}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locations_and_table_names() {
assert!(looks_like_location("edges.csv"));
assert!(looks_like_location("data/edges"));
assert!(looks_like_location("s3://bucket/x"));
assert!(looks_like_location("*.parquet"));
assert!(looks_like_location("~/edges"));
assert!(!looks_like_location("edges"));
assert!(!looks_like_location("my_table"));
}
#[test]
fn splits_url_globs() {
assert_eq!(
split_url_glob("s3://bucket/data/*.parquet"),
Some(("s3://bucket/data/", "*.parquet"))
);
assert_eq!(
split_url_glob("gs://bucket/a/b/part-?.csv"),
Some(("gs://bucket/a/b/", "part-?.csv"))
);
assert_eq!(
split_url_glob("s3://bucket/year=*/x.parquet"),
Some(("s3://bucket/", "year=*/x.parquet"))
);
assert_eq!(
split_url_glob("file:///tmp/x/[ab].csv"),
Some(("file:///tmp/x/", "[ab].csv"))
);
assert_eq!(split_url_glob("s3://bucket/data/x.parquet"), None);
assert_eq!(split_url_glob("https://host/x.csv?y=1"), None);
assert_eq!(split_url_glob("data/*.csv"), None);
assert_eq!(split_url_glob("C://data/*.csv"), None);
}
#[test]
fn remote_glob_url_lists_its_prefix() {
let url = table_url("s3://bucket/data/*.parquet").unwrap();
assert_eq!(url.scheme(), "s3");
assert_eq!(url.prefix().as_ref(), "data");
assert!(url.is_collection());
assert_eq!(url.get_glob().as_ref().unwrap().as_str(), "*.parquet");
}
}