Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions cli/src/main/java/io/github/dfa1/vortex/cli/ExportCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ static int run(String[] args) {
}
Path outputPath = (args.length == 3 && !toStdout)
? Path.of(args[2])
: deriveOutputPath(inputPath);
: inputPath.resolveSibling(FileName.of(inputPath).withFormat(FileFormat.CSV));
try {
if (!toStdout && outputPath.getFileName().toString().endsWith(".parquet")) {
if (!toStdout && FileName.of(outputPath).is(FileFormat.PARQUET)) {
return runParquet(inputPath, outputPath);
}
return runCsv(inputPath, outputPath, toStdout);
Expand All @@ -53,7 +53,7 @@ static int run(String[] args) {
/// Handles an `http(s)://` source: Parquet output only (an explicit `out.parquet` path is
/// required — CSV export and stdout streaming from a remote source aren't supported yet).
private static int runRemote(String target, String[] args, boolean toStdout) {
if (toStdout || args.length != 3 || !args[2].endsWith(".parquet")) {
if (toStdout || args.length != 3 || !new FileName(args[2]).is(FileFormat.PARQUET)) {
System.err.println("usage: export <url> out.parquet (CSV/stdout export from a URL isn't supported yet)");
return ExitStatus.USAGE_ERROR;
}
Expand Down Expand Up @@ -102,16 +102,6 @@ private static int runParquet(Path inputPath, Path outputPath) throws IOExceptio
return ExitStatus.OK;
}

/// Defaults to `.csv` — a Parquet destination must be named explicitly (`out.parquet`),
/// matching [ExportCommand#run]'s extension-on-the-output-path dispatch.
private static Path deriveOutputPath(Path inputPath) {
String name = inputPath.getFileName().toString();
if (name.endsWith(".vortex")) {
name = name.substring(0, name.length() - 7);
}
return inputPath.resolveSibling(name + ".csv");
}

private static void printResult(Path inputPath, Path outputPath) throws IOException {
long inputBytes = Files.size(inputPath);
long outputBytes = Files.size(outputPath);
Expand Down
40 changes: 40 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/FileFormat.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.github.dfa1.vortex.cli;

import java.util.Optional;

/// The file formats `import`/`export` recognize by extension. The single source of truth for
/// what an extension means — no other file in this module spells out `.csv`/`.parquet`/`.vortex`
/// or their lengths as string literals.
enum FileFormat {

CSV(".csv"),
PARQUET(".parquet"),
VORTEX(".vortex");

private final String extension;

FileFormat(String extension) {
this.extension = extension;
}

String extension() {
return extension;
}

boolean matches(String fileName) {
return fileName.endsWith(extension);
}

/// Resolves `fileName`'s format from its extension.
///
/// @param fileName a file name or URL path, e.g. `"data.parquet"`
/// @return the matching format, or empty if none of `.csv`/`.parquet`/`.vortex` matches
static Optional<FileFormat> of(String fileName) {
for (FileFormat format : values()) {
if (format.matches(fileName)) {
return Optional.of(format);
}
}
return Optional.empty();
}
}
35 changes: 35 additions & 0 deletions cli/src/main/java/io/github/dfa1/vortex/cli/FileName.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.github.dfa1.vortex.cli;

import java.nio.file.Path;

/// A file or URL path's last segment, typed around its [FileFormat]. Centralizes the
/// extension parsing/swapping `import`/`export` both need — replacing a scattered set of
/// `endsWith(".xxx")` checks and hand-counted `substring(0, name.length() - N)` suffix strips.
///
/// @param value the raw name, e.g. `"data.parquet"` or a URL's last `/`-segment
record FileName(String value) {

/// The name of `path`'s final component, e.g. `FileName.of(Path.of("a/data.csv"))` is
/// `FileName("data.csv")`.
static FileName of(Path path) {
return new FileName(path.getFileName().toString());
}

/// Whether this name ends in `format`'s extension.
boolean is(FileFormat format) {
return format.matches(value);
}

/// Swaps this name's extension for `target`'s, stripping any *different* known extension
/// first — `"data.csv".withFormat(VORTEX)` and `"data.parquet".withFormat(VORTEX)` both give
/// `"data.vortex"`. A name with no known extension, or one already in `target`'s format, is
/// never stripped — only appended to — so the result always differs from `value`: a caller
/// deriving a default output name from an input name can never get back the input's own name.
String withFormat(FileFormat target) {
String stem = FileFormat.of(value)
.filter(current -> current != target)
.map(current -> value.substring(0, value.length() - current.extension().length()))
.orElse(value);
return stem + target.extension();
}
}
65 changes: 34 additions & 31 deletions cli/src/main/java/io/github/dfa1/vortex/cli/ImportCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

import java.io.IOException;
import java.net.URI;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.List;

Expand Down Expand Up @@ -46,12 +49,12 @@ static int run(String[] args) {
System.err.println("file not found: " + inputPath);
return ExitStatus.FILE_NOT_FOUND;
}
String name = inputPath.getFileName().toString();
FileName name = FileName.of(inputPath);
Path outputPath = parsedArgs.outputTarget() != null
? Path.of(parsedArgs.outputTarget())
: inputPath.resolveSibling(vortexName(name));
if (name.endsWith(".parquet")) {
if (isParquetTarget(outputPath)) {
: inputPath.resolveSibling(name.withFormat(FileFormat.VORTEX));
if (name.is(FileFormat.PARQUET)) {
if (FileName.of(outputPath).is(FileFormat.PARQUET)) {
System.err.println("import always converts Parquet to Vortex; "
+ "a Parquet source cannot import to a .parquet output");
return ExitStatus.USAGE_ERROR;
Expand All @@ -71,21 +74,22 @@ static int run(String[] args) {
/// nothing else is supported from a URL). The output target may independently be `.vortex`
/// or `.parquet` — see [#runCsv] / [#runRemoteCsv] for the CSV-to-Parquet chain.
private static int runRemote(String url, String outputTarget, Character delimiter) throws IOException {
if (url.endsWith(".parquet")) {
Path vortexPath = outputTarget != null
? Path.of(outputTarget)
: Path.of(vortexName(lastPathSegment(url, "output.parquet")));
if (isParquetTarget(vortexPath)) {
FileName source = new FileName(url);
if (source.is(FileFormat.PARQUET)) {
Path vortexPath = Path.of(outputTarget != null
? outputTarget
: lastPathSegment(url, "output.parquet").withFormat(FileFormat.VORTEX));
if (FileName.of(vortexPath).is(FileFormat.PARQUET)) {
System.err.println("import always converts Parquet to Vortex; "
+ "a Parquet source cannot import to a .parquet output");
return ExitStatus.USAGE_ERROR;
}
return runRemoteParquet(url, vortexPath);
}
if (url.endsWith(".csv")) {
Path outputPath = outputTarget != null
? Path.of(outputTarget)
: Path.of(vortexName(lastPathSegment(url, "output.csv")));
if (source.is(FileFormat.CSV)) {
Path outputPath = Path.of(outputTarget != null
? outputTarget
: lastPathSegment(url, "output.csv").withFormat(FileFormat.VORTEX));
return runRemoteCsv(url, outputPath, delimiter);
}
System.err.println("only Parquet or CSV import is supported from a URL");
Expand All @@ -106,7 +110,7 @@ private static int runRemoteParquet(String parquetUrl, Path vortexPath) throws I
/// progress/result print, so the result line reports only the output size.
private static int runRemoteCsv(String csvUrl, Path outputPath, Character delimiter) throws IOException {
ImportOptions options = csvOptions(delimiter);
if (isParquetTarget(outputPath)) {
if (FileName.of(outputPath).is(FileFormat.PARQUET)) {
chainCsvToParquet(tempVortex -> CsvImporter.importCsv(URI.create(csvUrl), tempVortex, options),
outputPath);
} else {
Expand Down Expand Up @@ -150,7 +154,7 @@ private static ParsedArgs parseArgs(String[] args) {
/// always the hub, Parquet is never a direct CSV-import target.
private static int runCsv(Path csvPath, Path outputPath, Character delimiter) throws IOException {
ImportOptions options = csvOptions(delimiter);
if (isParquetTarget(outputPath)) {
if (FileName.of(outputPath).is(FileFormat.PARQUET)) {
chainCsvToParquet(tempVortex -> CsvImporter.importCsv(csvPath, tempVortex, options), outputPath);
ProgressBar.clear();
// cascading depth doesn't apply to a Parquet destination — suppressed via 0.
Expand Down Expand Up @@ -187,7 +191,7 @@ private interface CsvToVortex {
/// Imports CSV to a temp Vortex file via `importer`, exports that to `parquetOut`, then
/// discards the temp file — the CSV-to-Parquet chain shared by [#runCsv] and [#runRemoteCsv].
private static void chainCsvToParquet(CsvToVortex importer, Path parquetOut) throws IOException {
Path tempVortex = Files.createTempFile("vortex-cli-import-", ".vortex");
Path tempVortex = createTempVortex();
try {
importer.importTo(tempVortex);
ParquetExporter.exportParquet(tempVortex, parquetOut);
Expand All @@ -196,8 +200,18 @@ private static void chainCsvToParquet(CsvToVortex importer, Path parquetOut) thr
}
}

private static boolean isParquetTarget(Path path) {
return path.getFileName().toString().endsWith(".parquet");
/// Creates the CSV-to-Parquet chain's scratch file, owner-only readable/writable on POSIX
/// systems (`rw-------`) — the system temp directory is commonly world-writable, so a
/// predictable or loosely-permissioned temp name is a symlink/race target for another local
/// user. `FileAttribute`-based permissions aren't supported on Windows, whose per-user temp
/// directory doesn't share this exposure, so this falls back to the plain overload there.
private static Path createTempVortex() throws IOException {
String suffix = FileFormat.VORTEX.extension();
if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
FileAttribute<?> ownerOnly = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"));
return Files.createTempFile("vortex-cli-import-", suffix, ownerOnly);
}
return Files.createTempFile("vortex-cli-import-", suffix);
}

private static void printResult(Path inputPath, Path vortexPath, int cascadingDepth) throws IOException {
Expand Down Expand Up @@ -232,24 +246,13 @@ private static void renderProgress(long done, long total) {
}
}

/// Strips a known `.csv`/`.parquet` suffix from `inputFileName` and appends `.vortex`.
private static String vortexName(String inputFileName) {
String name = inputFileName;
if (name.endsWith(".csv")) {
name = name.substring(0, name.length() - 4);
} else if (name.endsWith(".parquet")) {
name = name.substring(0, name.length() - 8);
}
return name + ".vortex";
}

/// The last `/`-separated segment of a URL's path, used as the file name a downloaded
/// source is named after (mirroring what a browser would save the URL as). Falls back to
/// `fallback` when the path has no final segment (e.g. `https://host` with no path).
private static String lastPathSegment(String url, String fallback) {
private static FileName lastPathSegment(String url, String fallback) {
String path = URI.create(url).getPath();
int slash = path.lastIndexOf('/');
String name = slash < 0 ? path : path.substring(slash + 1);
return name.isEmpty() ? fallback : name;
return new FileName(name.isEmpty() ? fallback : name);
}
}
93 changes: 93 additions & 0 deletions cli/src/test/java/io/github/dfa1/vortex/cli/FileNameTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.github.dfa1.vortex.cli;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.nio.file.Path;

import static org.assertj.core.api.Assertions.assertThat;

class FileNameTest {

@ParameterizedTest
@CsvSource({
"data.csv, CSV, true",
"data.csv, PARQUET, false",
"data.parquet, PARQUET, true",
"data.vortex, VORTEX, true",
})
void is_matchesKnownExtension(String name, FileFormat format, boolean expected) {
// Given
FileName fileName = new FileName(name);

// When
boolean result = fileName.is(format);

// Then
assertThat(result).isEqualTo(expected);
}

@ParameterizedTest
@CsvSource({
"data.csv, VORTEX, data.vortex",
"data.parquet, VORTEX, data.vortex",
"data.vortex, CSV, data.csv",
})
void withFormat_swapsDifferentKnownExtension(String name, FileFormat target, String expected) {
// Given
FileName fileName = new FileName(name);

// When
String result = fileName.withFormat(target);

// Then — the stem is kept, only the trailing known extension changes
assertThat(result).isEqualTo(expected);
}

@Test
void withFormat_noKnownExtension_appendsTarget() {
// Given — a name that matches none of CSV/PARQUET/VORTEX (e.g. a .tsv file)
FileName name = new FileName("data.tsv");

// When
String result = name.withFormat(FileFormat.VORTEX);

// Then — nothing is stripped, the target extension is just appended
assertThat(result).isEqualTo("data.tsv.vortex");
}

@ParameterizedTest
@CsvSource({
"data.csv, CSV",
"data.parquet, PARQUET",
"data.vortex, VORTEX",
})
void withFormat_alreadyTargetFormat_appendsRatherThanReturningSameName(String name, FileFormat target) {
// Given — a name already in the target format. `ImportCommand`/`ExportCommand` derive a
// default output name this way, e.g. `withFormat(VORTEX)` on an import source that
// happens to already be named "data.vortex" — the caller must never get its own input
// name back, or it would open that path for writing while still reading it as the
// source (regression: an earlier version of #withFormat stripped a known extension
// whenever the name had one, even when it equaled the target, so "data.vortex" mapped
// straight back to itself instead of "data.vortex.vortex").
FileName fileName = new FileName(name);

// When
String result = fileName.withFormat(target);

// Then
assertThat(result).isNotEqualTo(name);
assertThat(result).isEqualTo(name + target.extension());
}

@Test
void of_takesPathsFinalComponent() {
// Given / When
FileName name = FileName.of(Path.of("a", "b", "data.parquet"));

// Then
assertThat(name.value()).isEqualTo("data.parquet");
assertThat(name.is(FileFormat.PARQUET)).isTrue();
}
}
Loading
Loading