Skip to content

Commit 79e94b4

Browse files
committed
Centralise path normalisation in vortex-file module
Existing logic was duplicated between jni and duckdb with slight differences. JNI logic didn't always handle local paths. Signed-off-by: "robert" <robert@spiraldb.com> Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent 4959939 commit 79e94b4

9 files changed

Lines changed: 269 additions & 283 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎java/vortex-spark/src/test/java/dev/vortex/spark/VortexDataSourceWriteTest.java‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,45 @@ public void testWriteAndReadVortexFiles() throws IOException {
129129
verifyDataContent(originalDf, readDf);
130130
}
131131

132+
@Test
133+
@DisplayName("Write and read Vortex files with a bare path (no URI scheme)")
134+
public void testWriteAndReadWithBarePath() throws IOException {
135+
int numRows = 50;
136+
Dataset<Row> originalDf = createTestDataFrame(numRows);
137+
138+
// A bare filesystem path, without a file:// scheme.
139+
String barePath = tempDir.resolve("bare_path_output").toString();
140+
originalDf
141+
.write()
142+
.format("vortex")
143+
.option("path", barePath)
144+
.mode(SaveMode.Overwrite)
145+
.save();
146+
147+
assertFalse(findVortexFiles(tempDir.resolve("bare_path_output")).isEmpty(), "Write should create files");
148+
149+
// Reading a bare directory path exercises schema inference via file listing.
150+
Dataset<Row> readDf =
151+
spark.read().format("vortex").option("path", barePath).load();
152+
153+
assertSchemaEquals(originalDf.schema(), readDf.schema());
154+
assertEquals(numRows, readDf.count(), "Read DataFrame should have same number of rows as original");
155+
verifyDataContent(originalDf, readDf);
156+
157+
// Overwriting a bare path exercises file listing and deletion of the existing files.
158+
Dataset<Row> replacementDf = createTestDataFrame(25);
159+
replacementDf
160+
.write()
161+
.format("vortex")
162+
.option("path", barePath)
163+
.mode(SaveMode.Overwrite)
164+
.save();
165+
166+
Dataset<Row> reread =
167+
spark.read().format("vortex").option("path", barePath).load();
168+
assertEquals(25, reread.count(), "Should have data from second write after overwrite");
169+
}
170+
132171
@Test
133172
@DisplayName("Write empty DataFrame as Vortex")
134173
public void testWriteEmptyDataFrame() throws IOException {

‎vortex-duckdb/src/multi_file.rs‎

Lines changed: 2 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
use std::path::Path;
5-
use std::path::absolute;
64
use std::sync::Arc;
75

86
use itertools::Itertools;
@@ -14,6 +12,7 @@ use vortex::error::VortexResult;
1412
use vortex::error::vortex_bail;
1513
use vortex::error::vortex_err;
1614
use vortex::file::multi::MultiFileDataSource;
15+
use vortex::file::multi::parse_uri_or_path;
1716
use vortex::io::compat::Compat;
1817
use vortex::io::filesystem::FileSystemRef;
1918
use vortex::io::object_store::ObjectStoreFileSystem;
@@ -26,38 +25,6 @@ use crate::SESSION;
2625
use crate::duckdb::BindInputRef;
2726
use crate::duckdb::ExtractedValue;
2827

29-
/// Parse a glob string into a [`Url`].
30-
///
31-
/// Accepts full URLs (e.g. `s3://bucket/prefix/*.vortex`, `file:///data/*.vortex`) as well as
32-
/// bare file paths. For bare paths, the path is made absolute (without requiring it to exist)
33-
/// so that relative paths such as `./data/*.vortex` or `../data/*.vortex` are resolved correctly.
34-
fn parse_glob_url(glob_url_str: &str) -> VortexResult<Url> {
35-
Url::parse(glob_url_str).or_else(|_| {
36-
let path = absolute(Path::new(glob_url_str))
37-
.map_err(|e| vortex_err!("Failed making {glob_url_str} absolute: {e}"))?;
38-
// `absolute()` does not normalize `..` components, so `/a/b/../c` stays as-is.
39-
// Normalizing manually avoids `..` being percent-encoded in the resulting URL.
40-
let path = normalize_path(path);
41-
Url::from_file_path(path).map_err(|_| vortex_err!("Neither URL nor path: {glob_url_str}"))
42-
})
43-
}
44-
45-
/// Normalize a path by resolving `.` and `..` components without accessing the filesystem.
46-
fn normalize_path(path: std::path::PathBuf) -> std::path::PathBuf {
47-
use std::path::Component;
48-
let mut normalized = std::path::PathBuf::new();
49-
for component in path.components() {
50-
match component {
51-
Component::CurDir => {}
52-
Component::ParentDir => {
53-
normalized.pop();
54-
}
55-
c => normalized.push(c),
56-
}
57-
}
58-
normalized
59-
}
60-
6128
fn resolve_filesystem(base_url: &Url) -> VortexResult<FileSystemRef> {
6229
let object_store: Arc<dyn ObjectStore> = match base_url.scheme() {
6330
"file" => Arc::new(LocalFileSystem::new()),
@@ -104,7 +71,7 @@ pub fn bind_multi_file_scan(input: &BindInputRef) -> VortexResult<MultiLayoutDat
10471
// Parse each glob URL and resolve its filesystem.
10572
let mut glob_urls: Vec<Url> = Vec::with_capacity(glob_strings.len());
10673
for glob_str in &glob_strings {
107-
glob_urls.push(parse_glob_url(glob_str)?);
74+
glob_urls.push(parse_uri_or_path(glob_str)?);
10875
}
10976

11077
// Cache filesystems by base URL to avoid resolving the same filesystem multiple times.
@@ -134,146 +101,3 @@ pub fn bind_multi_file_scan(input: &BindInputRef) -> VortexResult<MultiLayoutDat
134101
builder.build().await
135102
})
136103
}
137-
138-
#[cfg(test)]
139-
mod tests {
140-
use rstest::rstest;
141-
142-
use super::*;
143-
144-
#[test]
145-
fn test_parse_glob_url_s3() -> VortexResult<()> {
146-
let url = parse_glob_url("s3://my-bucket/prefix/*.vortex")?;
147-
assert_eq!(url.scheme(), "s3");
148-
assert_eq!(url.host_str(), Some("my-bucket"));
149-
assert_eq!(url.path(), "/prefix/*.vortex");
150-
Ok(())
151-
}
152-
153-
#[test]
154-
fn test_parse_glob_url_file_scheme() -> VortexResult<()> {
155-
let url = parse_glob_url("file:///absolute/path/data.vortex")?;
156-
assert_eq!(url.scheme(), "file");
157-
assert_eq!(url.path(), "/absolute/path/data.vortex");
158-
Ok(())
159-
}
160-
161-
#[test]
162-
fn test_parse_glob_url_absolute_glob_path() -> VortexResult<()> {
163-
let tmpdir = tempfile::tempdir()?;
164-
let glob = format!("{}/*.vortex", tmpdir.path().display());
165-
let url = parse_glob_url(&glob)?;
166-
assert_eq!(url.scheme(), "file");
167-
assert!(url.path().ends_with("/*.vortex"));
168-
Ok(())
169-
}
170-
171-
#[test]
172-
fn test_parse_glob_url_absolute_existing_path() -> VortexResult<()> {
173-
let tmpfile = tempfile::NamedTempFile::new()?;
174-
let canonical = std::fs::canonicalize(tmpfile.path())?;
175-
let path_str = canonical
176-
.to_str()
177-
.ok_or_else(|| vortex_err!("canonical path is not valid UTF-8"))?;
178-
let url = parse_glob_url(path_str)?;
179-
assert_eq!(url.scheme(), "file");
180-
assert_eq!(url.path(), path_str);
181-
Ok(())
182-
}
183-
184-
#[test]
185-
fn test_parse_glob_url_relative_path() -> VortexResult<()> {
186-
// Create a tempfile in the current working directory so we can refer to it
187-
// by a relative name (just the filename, without any directory component).
188-
let tmpfile = tempfile::NamedTempFile::new_in(".")?;
189-
let filename = tmpfile
190-
.path()
191-
.file_name()
192-
.ok_or_else(|| vortex_err!("temp file missing file name"))?
193-
.to_str()
194-
.ok_or_else(|| vortex_err!("temp file name is not valid UTF-8"))?;
195-
196-
let url = parse_glob_url(filename)?;
197-
assert_eq!(url.scheme(), "file");
198-
// The relative name must have been resolved to an absolute path.
199-
assert!(url.path().ends_with(filename));
200-
assert!(url.path().starts_with('/'));
201-
Ok(())
202-
}
203-
204-
#[test]
205-
fn test_parse_glob_url_relative_glob_path() -> VortexResult<()> {
206-
// A relative path with a glob character (e.g. `./data/*.vortex`) must also resolve
207-
// correctly.
208-
let tmpdir = tempfile::tempdir_in(".")?;
209-
let dir_name = tmpdir
210-
.path()
211-
.file_name()
212-
.ok_or_else(|| vortex_err!("temp dir missing file name"))?
213-
.to_str()
214-
.ok_or_else(|| vortex_err!("temp dir name is not valid UTF-8"))?;
215-
let glob = format!("./{dir_name}/*.vortex");
216-
let url = parse_glob_url(&glob)?;
217-
assert_eq!(url.scheme(), "file");
218-
assert!(url.path().starts_with('/'));
219-
assert!(url.path().ends_with("/*.vortex"));
220-
Ok(())
221-
}
222-
223-
#[test]
224-
fn test_parse_glob_url_nonexistent_path() -> VortexResult<()> {
225-
// absolute() does not require the path to exist, so a non-existent path succeeds.
226-
let url = parse_glob_url("/nonexistent/path/file.vortex")?;
227-
assert_eq!(url.scheme(), "file");
228-
assert_eq!(url.path(), "/nonexistent/path/file.vortex");
229-
Ok(())
230-
}
231-
232-
#[test]
233-
fn test_parse_glob_url_parent_relative_path() -> VortexResult<()> {
234-
// A path starting with `..` must be resolved to an absolute path without
235-
// percent-encoding the `..` component in the resulting URL.
236-
let tmpfile = tempfile::NamedTempFile::new_in("..")?;
237-
let filename = tmpfile
238-
.path()
239-
.file_name()
240-
.ok_or_else(|| vortex_err!("temp file missing file name"))?
241-
.to_str()
242-
.ok_or_else(|| vortex_err!("temp file name is not valid UTF-8"))?;
243-
let relative = format!("../{filename}");
244-
245-
let url = parse_glob_url(&relative)?;
246-
assert_eq!(url.scheme(), "file");
247-
// The resolved path must be absolute and must not contain encoded dots.
248-
assert!(url.path().starts_with('/'));
249-
assert!(
250-
!url.path().contains("%2E"),
251-
"path must not contain percent-encoded dots"
252-
);
253-
assert!(url.path().ends_with(filename));
254-
Ok(())
255-
}
256-
257-
// Use absolute paths so the expected result is cwd-independent.
258-
#[rstest]
259-
#[case("/a/./b", "/a/b")]
260-
#[case("/a/b/./c", "/a/b/c")]
261-
#[case("/a/../b", "/b")]
262-
#[case("/a/b/../c", "/a/c")]
263-
#[case("/a/b/../../c", "/c")]
264-
#[case("/a/./b/.././c", "/a/c")]
265-
#[case("/a/b/../..", "/")]
266-
fn test_parse_glob_url_dot_normalization(
267-
#[case] input: &str,
268-
#[case] expected_path: &str,
269-
) -> VortexResult<()> {
270-
let url = parse_glob_url(input)?;
271-
assert_eq!(url.scheme(), "file");
272-
assert_eq!(
273-
url.path(),
274-
expected_path,
275-
"input {input:?} should normalize to {expected_path:?}"
276-
);
277-
Ok(())
278-
}
279-
}

‎vortex-file/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ parking_lot = { workspace = true }
3030
pin-project-lite = { workspace = true }
3131
tokio = { workspace = true, features = ["rt"], optional = true }
3232
tracing = { workspace = true }
33+
url = { workspace = true }
3334
vortex-alp = { workspace = true }
3435
vortex-array = { workspace = true }
3536
vortex-btrblocks = { workspace = true }

‎vortex-file/src/multi/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Builder for constructing a [`MultiLayoutDataSource`] from multiple Vortex files.
55
66
mod session;
7+
mod uri;
78

89
use std::sync::Arc;
910

@@ -14,6 +15,7 @@ use futures::stream;
1415
pub use session::MultiFileSession;
1516
use session::MultiFileSessionExt;
1617
use tracing::debug;
18+
pub use uri::parse_uri_or_path;
1719
use vortex_error::VortexError;
1820
use vortex_error::VortexResult;
1921
use vortex_error::vortex_bail;

0 commit comments

Comments
 (0)