From a1ba7f8245c8932d48679f16d226ae743ba75d56 Mon Sep 17 00:00:00 2001 From: ivscheianu Date: Wed, 29 Jul 2026 08:54:15 +0300 Subject: [PATCH 1/2] fix(jni): align CommitBuilder.storageFormat() parsing with LanceFileVersion::from_str The JNI `parse_storage_format` function (used by `CommitBuilder.storageFormat()`) had a hand-rolled match that accepted only a subset of format strings ("v2.1", "v2_1", etc.) while the fragment creation path (`extract_write_params`) uses `LanceFileVersion::from_str` which also accepts the canonical numeric forms ("2.1", "2.2"). This inconsistency causes `CommitBuilder.storageFormat("2.1")` to fail with "Unknown storage format: 2.1" even though the same string works correctly in `WriteParams.dataStorageVersion`. Replace the custom match with `name.parse::()` and extend `FromStr` to also accept the prefixed aliases ("v2_0", "v2.0", etc.) so no previously accepted values are rejected. --- java/lance-jni/src/transaction.rs | 81 ++++++++++++++++--- .../main/java/org/lance/CommitBuilder.java | 5 +- rust/lance-encoding/src/version.rs | 13 ++- rust/lance-file/src/version.rs | 16 +++- 4 files changed, 92 insertions(+), 23 deletions(-) diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index fba2358d337..215242c73cd 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -595,18 +595,8 @@ pub(crate) fn convert_to_java_schema<'local>( } fn parse_storage_format(name: &str) -> Result { - match name.to_lowercase().as_str() { - "legacy" => Ok(LanceFileVersion::Legacy), - "v2_0" | "v2.0" => Ok(LanceFileVersion::V2_0), - "stable" => Ok(LanceFileVersion::Stable), - "v2_1" | "v2.1" => Ok(LanceFileVersion::V2_1), - "next" => Ok(LanceFileVersion::Next), - "v2_2" | "v2.2" => Ok(LanceFileVersion::V2_2), - _ => Err(Error::input_error(format!( - "Unknown storage format: {}", - name - ))), - } + name.parse::() + .map_err(|_| Error::input_error(format!("Unknown storage format: {}", name))) } /// Translate the Java `commitTimeoutNanos` sentinel into an @@ -1836,4 +1826,71 @@ mod tests { HashMap::from([("new_schema_k".to_string(), "new_schema_v".to_string())]) ); } + + #[test] + fn test_parse_storage_format_canonical_forms() { + let cases = [ + ("2.0", LanceFileVersion::V2_0), + ("2.1", LanceFileVersion::V2_1), + ("2.2", LanceFileVersion::V2_2), + ("2.3", LanceFileVersion::V2_3), + ("0.1", LanceFileVersion::Legacy), + ("legacy", LanceFileVersion::Legacy), + ("stable", LanceFileVersion::Stable), + ("next", LanceFileVersion::Next), + ]; + for (input, expected) in cases { + assert_eq!( + parse_storage_format(input).unwrap(), + expected, + "parse_storage_format({:?}) failed", + input + ); + } + } + + #[test] + fn test_parse_storage_format_prefixed_aliases() { + let cases = [ + ("v2_0", LanceFileVersion::V2_0), + ("v2.0", LanceFileVersion::V2_0), + ("v2_1", LanceFileVersion::V2_1), + ("v2.1", LanceFileVersion::V2_1), + ("v2_2", LanceFileVersion::V2_2), + ("v2.2", LanceFileVersion::V2_2), + ("v2_3", LanceFileVersion::V2_3), + ("v2.3", LanceFileVersion::V2_3), + ]; + for (input, expected) in cases { + assert_eq!( + parse_storage_format(input).unwrap(), + expected, + "parse_storage_format({:?}) failed", + input + ); + } + } + + #[test] + fn test_parse_storage_format_case_insensitive() { + assert_eq!( + parse_storage_format("LEGACY").unwrap(), + LanceFileVersion::Legacy + ); + assert_eq!( + parse_storage_format("Stable").unwrap(), + LanceFileVersion::Stable + ); + assert_eq!( + parse_storage_format("V2_1").unwrap(), + LanceFileVersion::V2_1 + ); + } + + #[test] + fn test_parse_storage_format_rejects_invalid() { + assert!(parse_storage_format("v3.0").is_err()); + assert!(parse_storage_format("").is_err()); + assert!(parse_storage_format("foo").is_err()); + } } diff --git a/java/src/main/java/org/lance/CommitBuilder.java b/java/src/main/java/org/lance/CommitBuilder.java index 62861b7ef73..fb591509f1f 100644 --- a/java/src/main/java/org/lance/CommitBuilder.java +++ b/java/src/main/java/org/lance/CommitBuilder.java @@ -200,8 +200,9 @@ public CommitBuilder useStableRowIds(boolean useStableRowIds) { * Set the storage format to use for the dataset. * *

This is only needed when creating a new empty table. If any data files are passed, the - * storage format will be inferred from the data files. Valid values: "legacy", "v2_0", "stable", - * "v2_1", "next", "v2_2". + * storage format will be inferred from the data files. Valid values include the canonical numeric + * forms ("2.0", "2.1", "2.2", "2.3"), prefixed aliases ("v2_0", "v2.0", "v2_1", "v2.1", etc.), + * and selectors ("legacy", "stable", "next"). Parsing is case-insensitive. * * @param storageFormat the storage format name * @return this builder instance diff --git a/rust/lance-encoding/src/version.rs b/rust/lance-encoding/src/version.rs index c69e826d7bc..bc6aed70086 100644 --- a/rust/lance-encoding/src/version.rs +++ b/rust/lance-encoding/src/version.rs @@ -101,16 +101,13 @@ impl FromStr for LanceFileVersion { fn from_str(value: &str) -> Result { match value.to_lowercase().as_str() { - LEGACY_FORMAT_VERSION => Ok(Self::Legacy), - V2_FORMAT_2_0 => Ok(Self::V2_0), - V2_FORMAT_2_1 => Ok(Self::V2_1), - V2_FORMAT_2_2 => Ok(Self::V2_2), - V2_FORMAT_2_3 => Ok(Self::V2_3), + LEGACY_FORMAT_VERSION | "legacy" => Ok(Self::Legacy), + V2_FORMAT_2_0 | "v2_0" | "v2.0" | "0.3" => Ok(Self::V2_0), + V2_FORMAT_2_1 | "v2_1" | "v2.1" => Ok(Self::V2_1), + V2_FORMAT_2_2 | "v2_2" | "v2.2" => Ok(Self::V2_2), + V2_FORMAT_2_3 | "v2_3" | "v2.3" => Ok(Self::V2_3), "stable" => Ok(Self::Stable), - "legacy" => Ok(Self::Legacy), "next" => Ok(Self::Next), - // Version 0.3 is an alias of 2.0 - "0.3" => Ok(Self::V2_0), _ => Err(Error::invalid_input_source( format!("Unknown Lance storage version: {}", value).into(), )), diff --git a/rust/lance-file/src/version.rs b/rust/lance-file/src/version.rs index 16f4a7e4b5d..d51ce79707a 100644 --- a/rust/lance-file/src/version.rs +++ b/rust/lance-file/src/version.rs @@ -207,16 +207,30 @@ mod tests { ("0.1", LanceFileVersion::Legacy), ("legacy", LanceFileVersion::Legacy), ("2.0", LanceFileVersion::V2_0), + ("v2_0", LanceFileVersion::V2_0), + ("v2.0", LanceFileVersion::V2_0), ("0.3", LanceFileVersion::V2_0), ("2.1", LanceFileVersion::V2_1), + ("v2_1", LanceFileVersion::V2_1), + ("v2.1", LanceFileVersion::V2_1), ("stable", LanceFileVersion::Stable), ("2.2", LanceFileVersion::V2_2), + ("v2_2", LanceFileVersion::V2_2), + ("v2.2", LanceFileVersion::V2_2), ("next", LanceFileVersion::Next), ("2.3", LanceFileVersion::V2_3), + ("v2_3", LanceFileVersion::V2_3), + ("v2.3", LanceFileVersion::V2_3), ]; for (value, expected) in cases { - assert_eq!(LanceFileVersion::from_str(value).unwrap(), expected); + assert_eq!( + LanceFileVersion::from_str(value).unwrap(), + expected, + "from_str({:?}) should return {:?}", + value, + expected + ); } } From 78be6375bb8de3fb008318279b168a5a00f1e516 Mon Sep 17 00:00:00 2001 From: Daniel Rammer Date: Fri, 31 Jul 2026 14:58:09 -0500 Subject: [PATCH 2/2] fix(jni): keep storage format aliases in the JNI instead of core The "v"-prefixed storage format spellings ("v2_1", "v2.1") were never a Lance concept. They were introduced in #5978, where `parse_storage_format` hand-rolled its match by walking the `LanceFileVersion` variant identifiers (`V2_1` -> "v2_1") rather than delegating to `FromStr`. That is why the JNI accepted the identifiers and rejected the canonical "2.1" that `Display` emits and every other binding accepts. Rather than teach core's `FromStr` the aliases, keep them where they came from. `parse_storage_format` now translates the alias set to its canonical form, warns that the spelling is deprecated, and delegates to `FromStr`, so core, Python, and `extract_write_params` keep a single unchanged contract. The alias set is frozen at what shipped in the `CommitBuilder.storageFormat` Javadoc from 3.0.0 ("v2_0", "v2_1", "v2_2"); it deliberately does not extend to 2.3 or later, which are reachable only by their canonical name. This reverts the `lance-encoding` and `lance-file` changes from the previous commit on this branch, leaving the fix entirely inside the Java binding. Tests: `parse_storage_format` unit tests for canonical forms, deprecated aliases, case-insensitivity, invalid input, and the frozen alias set, plus `CommitBuilderStorageFormatTest` covering the end-to-end Java path. That Java test also pins a constraint downstream callers hit: `storageFormat` is validated against the existing dataset for any non-overwrite operation, so a delete that adds no data files still fails when the format disagrees. Co-Authored-By: Claude Opus 5 (1M context) --- java/lance-jni/src/transaction.rs | 48 +++++- .../main/java/org/lance/CommitBuilder.java | 11 +- .../lance/CommitBuilderStorageFormatTest.java | 151 ++++++++++++++++++ rust/lance-encoding/src/version.rs | 13 +- rust/lance-file/src/version.rs | 16 +- 5 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index 215242c73cd..79309a5d401 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -26,7 +26,7 @@ use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; use lance::table::format::{Fragment, IndexMetadata}; use lance_core::datatypes::Field; use lance_core::datatypes::Schema as LanceSchema; -use lance_file::version::LanceFileVersion; +use lance_file::version::{LanceFileVersion, V2_FORMAT_2_0, V2_FORMAT_2_1, V2_FORMAT_2_2}; use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, StorageOptionsProvider}; use lance_table::io::commit::CommitHandler; use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; @@ -594,8 +594,37 @@ pub(crate) fn convert_to_java_schema<'local>( .l()?) } +/// Parse a `CommitBuilder.storageFormat` string into a [`LanceFileVersion`]. +/// +/// The canonical spellings ("2.1", "stable", ...) are the ones every other Lance +/// binding accepts and the ones [`LanceFileVersion`]'s `Display` emits. +/// +/// The `v`-prefixed spellings are a Java-only accident: this function originally +/// hand-rolled its match by walking the `LanceFileVersion` variant identifiers +/// (`V2_1` -> `"v2_1"`) instead of delegating to `FromStr`, so it accepted those +/// identifiers and rejected the canonical "2.1". They were documented on +/// `CommitBuilder.storageFormat` and shipped from 3.0.0, so they are translated +/// here for compatibility. The set is deliberately frozen to what shipped — +/// newer versions are reachable only by their canonical name. fn parse_storage_format(name: &str) -> Result { - name.parse::() + let requested = name.to_lowercase(); + let canonical = match requested.as_str() { + "v2_0" | "v2.0" => V2_FORMAT_2_0, + "v2_1" | "v2.1" => V2_FORMAT_2_1, + "v2_2" | "v2.2" => V2_FORMAT_2_2, + _ => requested.as_str(), + }; + + if canonical != requested { + log::warn!( + "Storage format \"{}\" is deprecated and will be removed in a future release; use \"{}\" instead", + name, + canonical + ); + } + + canonical + .parse::() .map_err(|_| Error::input_error(format!("Unknown storage format: {}", name))) } @@ -1849,8 +1878,10 @@ mod tests { } } + /// The `v`-prefixed spellings shipped in the `CommitBuilder.storageFormat` + /// Javadoc and must keep working for existing Java callers. #[test] - fn test_parse_storage_format_prefixed_aliases() { + fn test_parse_storage_format_deprecated_aliases() { let cases = [ ("v2_0", LanceFileVersion::V2_0), ("v2.0", LanceFileVersion::V2_0), @@ -1858,8 +1889,6 @@ mod tests { ("v2.1", LanceFileVersion::V2_1), ("v2_2", LanceFileVersion::V2_2), ("v2.2", LanceFileVersion::V2_2), - ("v2_3", LanceFileVersion::V2_3), - ("v2.3", LanceFileVersion::V2_3), ]; for (input, expected) in cases { assert_eq!( @@ -1871,6 +1900,15 @@ mod tests { } } + /// The alias set is frozen to what shipped, so versions added after the + /// aliases were deprecated are reachable only by their canonical name. + #[test] + fn test_parse_storage_format_does_not_extend_aliases_to_new_versions() { + assert!(parse_storage_format("v2_3").is_err()); + assert!(parse_storage_format("v2.3").is_err()); + assert_eq!(parse_storage_format("2.3").unwrap(), LanceFileVersion::V2_3); + } + #[test] fn test_parse_storage_format_case_insensitive() { assert_eq!( diff --git a/java/src/main/java/org/lance/CommitBuilder.java b/java/src/main/java/org/lance/CommitBuilder.java index fb591509f1f..fdb95828942 100644 --- a/java/src/main/java/org/lance/CommitBuilder.java +++ b/java/src/main/java/org/lance/CommitBuilder.java @@ -200,9 +200,14 @@ public CommitBuilder useStableRowIds(boolean useStableRowIds) { * Set the storage format to use for the dataset. * *

This is only needed when creating a new empty table. If any data files are passed, the - * storage format will be inferred from the data files. Valid values include the canonical numeric - * forms ("2.0", "2.1", "2.2", "2.3"), prefixed aliases ("v2_0", "v2.0", "v2_1", "v2.1", etc.), - * and selectors ("legacy", "stable", "next"). Parsing is case-insensitive. + * storage format will be inferred from the data files. Valid values are the numeric versions + * ("0.1", "2.0", "2.1", "2.2", "2.3") and the release selectors ("legacy", "stable", "next"), + * matching {@link WriteParams.Builder#withDataStorageVersion(String)}. Parsing is + * case-insensitive. + * + *

The {@code v}-prefixed spellings ("v2_0", "v2.0", "v2_1", "v2.1", "v2_2", "v2.2") are + * deprecated. They were accepted only by this method, never by the rest of Lance, and will be + * removed in a future release — use the numeric version instead ("v2_1" becomes "2.1"). * * @param storageFormat the storage format name * @return this builder instance diff --git a/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java b/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java new file mode 100644 index 00000000000..b500369cbb9 --- /dev/null +++ b/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java @@ -0,0 +1,151 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.lance; + +import org.lance.operation.Append; +import org.lance.operation.Delete; +import org.lance.operation.OperationTestBase; + +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CommitBuilderStorageFormatTest extends OperationTestBase { + + /** + * Append to a freshly created (2.1) dataset with the given storage format and return the + * committed dataset's format version. + */ + private String commitWithStorageFormat(String datasetPath, String storageFormat) + throws Exception { + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + try (Dataset committed = + new CommitBuilder(dataset).storageFormat(storageFormat).execute(txn)) { + assertEquals(2, committed.version()); + return committed.getLanceFileFormatVersion(); + } + } + } + } + + /** + * The numeric versions are what {@link Dataset#getLanceFileFormatVersion()} returns and what + * {@link WriteParams.Builder#withDataStorageVersion(String)} accepts, so they must work here too + * — a caller that encodes fragments as "2.1" has to be able to commit them as "2.1". + */ + @Test + void testCanonicalVersionAccepted(@TempDir Path tempDir) throws Exception { + assertEquals( + LanceConstants.FILE_FORMAT_VERSION_2_1, + commitWithStorageFormat( + tempDir.resolve("canonical").toString(), LanceConstants.FILE_FORMAT_VERSION_2_1)); + } + + /** The "v"-prefixed spelling shipped in this method's Javadoc and stays accepted. */ + @Test + void testDeprecatedAliasAccepted(@TempDir Path tempDir) throws Exception { + assertEquals( + LanceConstants.FILE_FORMAT_VERSION_2_1, + commitWithStorageFormat(tempDir.resolve("alias").toString(), "v2_1")); + } + + /** + * A delete adds no data files, so nothing about it depends on the storage format — but {@link + * CommitBuilder#storageFormat(String)} is still validated against the existing dataset for any + * operation other than overwrite. A caller that forwards a configured format on every commit hits + * this on row-level operations against a table written in a different version, so the failure is + * a mismatch error rather than anything to do with parsing. + */ + @Test + void testMismatchedFormatRejectedOnRowLevelOperation(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("mismatch").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + dataset = new CommitBuilder(dataset).execute(appendTxn); + } + assertEquals(LanceConstants.FILE_FORMAT_VERSION_2_1, dataset.getLanceFileFormatVersion()); + + List fragmentIds = + dataset.getFragments().stream() + .map(f -> Long.valueOf(f.getId())) + .collect(Collectors.toList()); + + // "2.2" parses fine, so a failure here is the mismatch guard and not the parser. + try (Transaction deleteTxn = deleteAll(fragmentIds)) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> + new CommitBuilder(dataset) + .storageFormat(LanceConstants.FILE_FORMAT_VERSION_2_2) + .execute(deleteTxn)); + assertTrue(error.getMessage().contains("Storage format mismatch"), error.getMessage()); + } + + // The same delete succeeds when the format agrees with the dataset. + try (Transaction deleteTxn = deleteAll(fragmentIds)) { + try (Dataset deleted = + new CommitBuilder(dataset) + .storageFormat(LanceConstants.FILE_FORMAT_VERSION_2_1) + .execute(deleteTxn)) { + assertEquals(0, deleted.countRows()); + } + } + } + } + + private Transaction deleteAll(List fragmentIds) { + return new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Delete.builder().deletedFragmentIds(fragmentIds).predicate("1=1").build()) + .build(); + } + + @Test + void testUnknownFormatRejected(@TempDir Path tempDir) { + assertThrows( + IllegalArgumentException.class, + () -> commitWithStorageFormat(tempDir.resolve("bogus").toString(), "bogus")); + // The alias set is frozen at what shipped, so it does not extend to newer versions. + assertThrows( + IllegalArgumentException.class, + () -> commitWithStorageFormat(tempDir.resolve("v23").toString(), "v2_3")); + } +} diff --git a/rust/lance-encoding/src/version.rs b/rust/lance-encoding/src/version.rs index bc6aed70086..c69e826d7bc 100644 --- a/rust/lance-encoding/src/version.rs +++ b/rust/lance-encoding/src/version.rs @@ -101,13 +101,16 @@ impl FromStr for LanceFileVersion { fn from_str(value: &str) -> Result { match value.to_lowercase().as_str() { - LEGACY_FORMAT_VERSION | "legacy" => Ok(Self::Legacy), - V2_FORMAT_2_0 | "v2_0" | "v2.0" | "0.3" => Ok(Self::V2_0), - V2_FORMAT_2_1 | "v2_1" | "v2.1" => Ok(Self::V2_1), - V2_FORMAT_2_2 | "v2_2" | "v2.2" => Ok(Self::V2_2), - V2_FORMAT_2_3 | "v2_3" | "v2.3" => Ok(Self::V2_3), + LEGACY_FORMAT_VERSION => Ok(Self::Legacy), + V2_FORMAT_2_0 => Ok(Self::V2_0), + V2_FORMAT_2_1 => Ok(Self::V2_1), + V2_FORMAT_2_2 => Ok(Self::V2_2), + V2_FORMAT_2_3 => Ok(Self::V2_3), "stable" => Ok(Self::Stable), + "legacy" => Ok(Self::Legacy), "next" => Ok(Self::Next), + // Version 0.3 is an alias of 2.0 + "0.3" => Ok(Self::V2_0), _ => Err(Error::invalid_input_source( format!("Unknown Lance storage version: {}", value).into(), )), diff --git a/rust/lance-file/src/version.rs b/rust/lance-file/src/version.rs index d51ce79707a..16f4a7e4b5d 100644 --- a/rust/lance-file/src/version.rs +++ b/rust/lance-file/src/version.rs @@ -207,30 +207,16 @@ mod tests { ("0.1", LanceFileVersion::Legacy), ("legacy", LanceFileVersion::Legacy), ("2.0", LanceFileVersion::V2_0), - ("v2_0", LanceFileVersion::V2_0), - ("v2.0", LanceFileVersion::V2_0), ("0.3", LanceFileVersion::V2_0), ("2.1", LanceFileVersion::V2_1), - ("v2_1", LanceFileVersion::V2_1), - ("v2.1", LanceFileVersion::V2_1), ("stable", LanceFileVersion::Stable), ("2.2", LanceFileVersion::V2_2), - ("v2_2", LanceFileVersion::V2_2), - ("v2.2", LanceFileVersion::V2_2), ("next", LanceFileVersion::Next), ("2.3", LanceFileVersion::V2_3), - ("v2_3", LanceFileVersion::V2_3), - ("v2.3", LanceFileVersion::V2_3), ]; for (value, expected) in cases { - assert_eq!( - LanceFileVersion::from_str(value).unwrap(), - expected, - "from_str({:?}) should return {:?}", - value, - expected - ); + assert_eq!(LanceFileVersion::from_str(value).unwrap(), expected); } }