diff --git a/native/spark-expr/src/url_funcs/parse_url.rs b/native/spark-expr/src/url_funcs/parse_url.rs index c60ed1247a8..b4ff94302c0 100644 --- a/native/spark-expr/src/url_funcs/parse_url.rs +++ b/native/spark-expr/src/url_funcs/parse_url.rs @@ -181,11 +181,16 @@ fn parse_url_component<'a>( fragment, } = decompose_uri(value); - if scheme.is_none() { - if value.contains("://") { - return Err(invalid_url_err(value)); - } - return Ok(None); + // A value without a scheme is a relative reference; a leading ':' is an empty + // scheme name, which java.net.URI rejects ("Expected scheme name at index 0"). + if scheme.is_none() && value.starts_with(':') { + return Err(invalid_url_err(value)); + } + + // java.net.URI rejects an empty authority ("//", "http://") unless a path, + // query or fragment follows it ("Expected authority"). + if authority == Some("") && path.is_empty() && query.is_none() && fragment.is_none() { + return Err(invalid_url_err(value)); } // java.net.URI rejects unbalanced brackets in authority @@ -795,4 +800,52 @@ mod tests { let result = parse_url_component("http://[::1/path", "HOST", None); assert!(result.is_err()); } + + #[test] + fn test_relative_reference() -> Result<()> { + // Expected values are from Spark 4.1.1 (java.net.URI). + for (url, part, key, expected) in [ + ("/shop?ref=abc&id=42#top", "PATH", None, Some("/shop")), + ("/shop?ref=abc&id=42#top", "QUERY", Some("id"), Some("42")), + ("/shop?ref=abc&id=42#top", "REF", None, Some("top")), + ("/shop?ref=abc&id=42#top", "HOST", None, None), + ("?id=7", "FILE", None, Some("?id=7")), + ("notaurl", "PATH", None, Some("notaurl")), + ("a/b:c?id=1", "PATH", None, Some("a/b:c")), + ( + "//user:pw@example.com:8080/p", + "HOST", + None, + Some("example.com"), + ), + ( + "//user:pw@example.com:8080/p", + "USERINFO", + None, + Some("user:pw"), + ), + ("///p?id=3", "AUTHORITY", None, None), + ("//?id=4", "QUERY", None, Some("id=4")), + ("/r?to=http://x.com/a&id=1", "QUERY", Some("id"), Some("1")), + ] { + assert_eq!( + parse_url_component(url, part, key)?, + expected, + "{url} {part}" + ); + } + Ok(()) + } + + #[test] + fn test_invalid_relative_reference() { + for url in [":", ":foo", "://missing-scheme", "//", "http://"] { + for part in ["HOST", "PATH", "PROTOCOL", "FILE"] { + assert!( + parse_url_component(url, part, None).is_err(), + "{url} {part}" + ); + } + } + } } diff --git a/spark/src/test/resources/sql-tests/expressions/url/parse_url.sql b/spark/src/test/resources/sql-tests/expressions/url/parse_url.sql index fc87e3d77c2..fe6a42871e5 100644 --- a/spark/src/test/resources/sql-tests/expressions/url/parse_url.sql +++ b/spark/src/test/resources/sql-tests/expressions/url/parse_url.sql @@ -170,3 +170,33 @@ SELECT parse_url('http://host:abc/', 'AUTHORITY') -- unbalanced IPv6 bracket is invalid query SELECT parse_url('http://[::1/path', 'AUTHORITY') + +-- relative references (no scheme) are parsed like java.net.URI +statement +CREATE TABLE test_parse_url_relative(url string) USING parquet + +statement +INSERT INTO test_parse_url_relative VALUES + ('/'), + ('notaurl'), + ('a/b:c?id=1'), + ('?id=7'), + ('#frag'), + ('/p#a?id=1'), + ('/shop/item/123?ref=abc&id=42#top'), + ('/r?to=http://x.com/a&id=1'), + ('//example.com'), + ('//user:pw@example.com:8080/p?id=1#f'), + ('///p?id=3'), + ('//#f'), + ('//?id=4'), + (':foo'), + ('//'), + ('http://'), + ('http://example.com/r?to=https://y.com&id=9') + +query +SELECT url, parse_url(url, 'HOST'), parse_url(url, 'PATH'), parse_url(url, 'QUERY'), + parse_url(url, 'REF'), parse_url(url, 'PROTOCOL'), parse_url(url, 'FILE'), + parse_url(url, 'AUTHORITY'), parse_url(url, 'USERINFO'), parse_url(url, 'QUERY', 'id') +FROM test_parse_url_relative diff --git a/spark/src/test/resources/sql-tests/expressions/url/parse_url_ansi.sql b/spark/src/test/resources/sql-tests/expressions/url/parse_url_ansi.sql index b3a0ed779da..a8edbcb4f5d 100644 --- a/spark/src/test/resources/sql-tests/expressions/url/parse_url_ansi.sql +++ b/spark/src/test/resources/sql-tests/expressions/url/parse_url_ansi.sql @@ -55,3 +55,17 @@ SELECT parse_url('', 'HOST') -- unbalanced IPv6 bracket throws in ANSI mode query expect_error(http://[::1/path) SELECT parse_url('http://[::1/path', 'AUTHORITY') + +-- relative references do not throw in ANSI mode +query +SELECT parse_url('/shop?ref=abc&id=42', 'QUERY', 'id') + +query +SELECT parse_url('//example.com/p', 'HOST') + +-- a leading ':' or an empty authority with nothing after it throws in ANSI mode +query expect_error(:foo) +SELECT parse_url(':foo', 'PATH') + +query expect_error(http://) +SELECT parse_url('http://', 'PROTOCOL')