diff --git a/conanfile.py b/conanfile.py index a6840562..17d303b6 100644 --- a/conanfile.py +++ b/conanfile.py @@ -43,7 +43,7 @@ def configure(self): self.options.rm_safe("fPIC") def requirements(self): - self.requires("sparrow/1.0.0") + self.requires("sparrow/1.2.0", options={"json_reader": True}) self.requires(f"flatbuffers/{self._flatbuffers_version}") self.requires("lz4/1.9.4") self.requires("zstd/1.5.7") diff --git a/include/sparrow_ipc/deserialize_decimal_array.hpp b/include/sparrow_ipc/deserialize_decimal_array.hpp new file mode 100644 index 00000000..7773c04b --- /dev/null +++ b/include/sparrow_ipc/deserialize_decimal_array.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include + +#include +#include + +#include "Message_generated.h" +#include "sparrow_ipc/arrow_interface/arrow_array.hpp" +#include "sparrow_ipc/arrow_interface/arrow_schema.hpp" +#include "sparrow_ipc/deserialize_utils.hpp" + +namespace sparrow_ipc +{ + template + [[nodiscard]] sparrow::decimal_array deserialize_non_owning_decimal( + const org::apache::arrow::flatbuf::RecordBatch& record_batch, + std::span body, + std::string_view name, + const std::optional>& metadata, + bool nullable, + size_t& buffer_index, + int32_t scale, + int32_t precision + ) + { + constexpr std::size_t sizeof_decimal = sizeof(typename T::integer_type); + std::string format_str = "d:" + std::to_string(precision) + "," + std::to_string(scale); + if constexpr (sizeof_decimal != 16) // We don't need to specify the size for 128-bit + // decimals + { + format_str += "," + std::to_string(sizeof_decimal * 8); + } + + // Set up flags based on nullable + std::optional> flags; + if (nullable) + { + flags = std::unordered_set{sparrow::ArrowFlag::NULLABLE}; + } + + ArrowSchema schema = make_non_owning_arrow_schema( + format_str, + name.data(), + metadata, + flags, + 0, + nullptr, + nullptr + ); + + const auto compression = record_batch.compression(); + std::vector buffers; + + auto validity_buffer_span = utils::get_buffer(record_batch, body, buffer_index); + auto data_buffer_span = utils::get_buffer(record_batch, body, buffer_index); + + if (compression) + { + buffers.push_back(utils::get_decompressed_buffer(validity_buffer_span, compression)); + buffers.push_back(utils::get_decompressed_buffer(data_buffer_span, compression)); + } + else + { + buffers.emplace_back(validity_buffer_span); + buffers.emplace_back(data_buffer_span); + } + + const auto [bitmap_ptr, null_count] = utils::get_bitmap_pointer_and_null_count( + validity_buffer_span, + record_batch.length() + ); + + ArrowArray array = make_arrow_array( + record_batch.length(), + null_count, + 0, + 0, + nullptr, + nullptr, + std::move(buffers) + ); + sparrow::arrow_proxy ap{std::move(array), std::move(schema)}; + return sparrow::decimal_array(std::move(ap)); + } +} \ No newline at end of file diff --git a/include/sparrow_ipc/utils.hpp b/include/sparrow_ipc/utils.hpp index 63f1fb89..8e567132 100644 --- a/include/sparrow_ipc/utils.hpp +++ b/include/sparrow_ipc/utils.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include @@ -13,6 +14,39 @@ namespace sparrow_ipc::utils // Aligns a value to the next multiple of 8, as required by the Arrow IPC format for message bodies SPARROW_IPC_API size_t align_to_8(const size_t n); + /** + * @brief Extracts words after ':' separated by ',' from a string. + * + * This function finds the position of ':' in the input string and then + * splits the remaining part by ',' to extract individual words. + * + * @param str Input string to parse (e.g., "prefix:word1,word2,word3") + * @return std::vector Vector of string views containing the extracted words + * Returns an empty vector if ':' is not found or if there are no words after it + * + * @example + * extract_words_after_colon("d:128,10") returns {"128", "10"} + * extract_words_after_colon("w:256") returns {"256"} + * extract_words_after_colon("no_colon") returns {} + */ + SPARROW_IPC_API std::vector extract_words_after_colon(std::string_view str); + + /** + * @brief Parse a string_view to int32_t using std::from_chars. + * + * This function converts a string view to a 32-bit integer using std::from_chars + * for efficient parsing. + * + * @param str The string view to parse + * @return std::optional The parsed integer value, or std::nullopt if parsing fails + * + * @example + * parse_to_int32("123") returns std::optional(123) + * parse_to_int32("abc") returns std::nullopt + * parse_to_int32("") returns std::nullopt + */ + SPARROW_IPC_API std::optional parse_to_int32(std::string_view str); + /** * @brief Checks if all record batches in a collection have consistent structure. * @@ -63,5 +97,24 @@ namespace sparrow_ipc::utils // Parse the format string // The format string is expected to be "w:size", "+w:size", "d:precision,scale", etc std::optional parse_format(std::string_view format_str, std::string_view sep); + + /** + * @brief Parse decimal format strings. + * + * This function parses decimal format strings which can be in two formats: + * - "d:precision,scale" (e.g., "d:19,10") + * - "d:precision,scale,bitWidth" (e.g., "d:19,10,128") + * + * @param format_str The format string to parse + * @return std::optional>> + * A tuple containing (precision, scale, optional bitWidth), or std::nullopt if parsing fails + * + * @example + * parse_decimal_format("d:19,10") returns std::optional{std::tuple{19, 10, std::nullopt}} + * parse_decimal_format("d:19,10,128") returns std::optional{std::tuple{19, 10, std::optional{128}}} + * parse_decimal_format("invalid") returns std::nullopt + */ + SPARROW_IPC_API std::optional>> parse_decimal_format(std::string_view format_str); + // size_t calculate_output_serialized_size(const sparrow::record_batch& record_batch); } diff --git a/src/deserialize.cpp b/src/deserialize.cpp index 92063de1..f5b29fc5 100644 --- a/src/deserialize.cpp +++ b/src/deserialize.cpp @@ -2,6 +2,7 @@ #include +#include "sparrow_ipc/deserialize_decimal_array.hpp" #include "sparrow_ipc/deserialize_fixedsizebinary_array.hpp" #include "sparrow_ipc/deserialize_primitive_array.hpp" #include "sparrow_ipc/deserialize_variable_size_binary_array.hpp" @@ -205,6 +206,73 @@ namespace sparrow_ipc ) ); break; + case org::apache::arrow::flatbuf::Type::Decimal: + { + const auto decimal_field = field->type_as_Decimal(); + const auto scale = decimal_field->scale(); + const auto precision = decimal_field->precision(); + if (decimal_field->bitWidth() == 32) + { + arrays.emplace_back( + deserialize_non_owning_decimal>( + record_batch, + encapsulated_message.body(), + name, + metadata, + nullable, + buffer_index, + scale, + precision + ) + ); + } + else if (decimal_field->bitWidth() == 64) + { + arrays.emplace_back( + deserialize_non_owning_decimal>( + record_batch, + encapsulated_message.body(), + name, + metadata, + nullable, + buffer_index, + scale, + precision + ) + ); + } + else if (decimal_field->bitWidth() == 128) + { + arrays.emplace_back( + deserialize_non_owning_decimal>( + record_batch, + encapsulated_message.body(), + name, + metadata, + nullable, + buffer_index, + scale, + precision + ) + ); + } + else if (decimal_field->bitWidth() == 256) + { + arrays.emplace_back( + deserialize_non_owning_decimal>( + record_batch, + encapsulated_message.body(), + name, + metadata, + nullable, + buffer_index, + scale, + precision + ) + ); + } + break; + } default: throw std::runtime_error("Unsupported type."); } diff --git a/src/flatbuffer_utils.cpp b/src/flatbuffer_utils.cpp index 4c4e79a7..8b715d4d 100644 --- a/src/flatbuffer_utils.cpp +++ b/src/flatbuffer_utils.cpp @@ -370,7 +370,7 @@ namespace sparrow_ipc } // Creates a Flatbuffers Decimal type from a format string - // The format string is expected to be in the format "d:precision,scale" + // The format string is expected to be in the format "d:precision,scale" or "d:precision,scale,bitWidth" std::pair> get_flatbuffer_decimal_type( flatbuffers::FlatBufferBuilder& builder, std::string_view format_str, @@ -378,29 +378,26 @@ namespace sparrow_ipc ) { // Decimal requires precision and scale. We need to parse the format_str. - // Format: "d:precision,scale" - const auto scale = utils::parse_format(format_str, ","); - if (!scale.has_value()) + // Format: "d:precision,scale" or "d:precision,scale,bitWidth" + const auto parsed = utils::parse_decimal_format(format_str); + if (!parsed.has_value()) { throw std::runtime_error( "Failed to parse Decimal " + std::to_string(bitWidth) - + " scale from format string: " + std::string(format_str) - ); - } - const size_t comma_pos = format_str.find(','); - const auto precision = utils::parse_format(format_str.substr(0, comma_pos), ":"); - if (!precision.has_value()) - { - throw std::runtime_error( - "Failed to parse Decimal " + std::to_string(bitWidth) - + " precision from format string: " + std::string(format_str) + + " format string: " + std::string(format_str) ); } + + const auto& [precision, scale, parsed_bitwidth] = parsed.value(); + + // Use the bitWidth from the format string if provided, otherwise use the parameter + const int32_t actual_bitwidth = parsed_bitwidth.value_or(bitWidth); + const auto decimal_type = org::apache::arrow::flatbuf::CreateDecimal( builder, - precision.value(), - scale.value(), - bitWidth + precision, + scale, + actual_bitwidth ); return {org::apache::arrow::flatbuf::Type::Decimal, decimal_type.Union()}; } diff --git a/src/utils.cpp b/src/utils.cpp index 73db1369..8de95941 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -1,9 +1,14 @@ #include "sparrow_ipc/utils.hpp" #include +#include +#include namespace sparrow_ipc::utils { + + // Parse the format string + // The format string is expected to be "w:size", "+w:size", "d:precision,scale", etc std::optional parse_format(std::string_view format_str, std::string_view sep) { // Find the position of the delimiter @@ -29,8 +34,138 @@ namespace sparrow_ipc::utils return substr_size; } + std::optional>> parse_decimal_format(std::string_view format_str) + { + // Format can be "d:precision,scale" or "d:precision,scale,bitWidth" + // First, find the colon + const auto colon_pos = format_str.find(':'); + if (colon_pos == std::string_view::npos) + { + return std::nullopt; + } + + // Extract the part after the colon + std::string_view params_str(format_str.data() + colon_pos + 1, format_str.size() - colon_pos - 1); + + // Find the first comma (between precision and scale) + const auto first_comma_pos = params_str.find(','); + if (first_comma_pos == std::string_view::npos) + { + return std::nullopt; + } + + // Parse precision + std::string_view precision_str(params_str.data(), first_comma_pos); + int32_t precision = 0; + auto [ptr1, ec1] = std::from_chars( + precision_str.data(), + precision_str.data() + precision_str.size(), + precision + ); + if (ec1 != std::errc() || ptr1 != precision_str.data() + precision_str.size()) + { + return std::nullopt; + } + + // Find the second comma (between scale and bitWidth, if present) + const auto remaining_str = params_str.substr(first_comma_pos + 1); + const auto second_comma_pos = remaining_str.find(','); + + std::string_view scale_str; + std::optional bit_width; + + if (second_comma_pos == std::string_view::npos) + { + // Format is "d:precision,scale" + scale_str = remaining_str; + } + else + { + // Format is "d:precision,scale,bitWidth" + scale_str = std::string_view(remaining_str.data(), second_comma_pos); + std::string_view bitwidth_str(remaining_str.data() + second_comma_pos + 1, + remaining_str.size() - second_comma_pos - 1); + + // Parse bitWidth + int32_t bw = 0; + auto [ptr3, ec3] = std::from_chars( + bitwidth_str.data(), + bitwidth_str.data() + bitwidth_str.size(), + bw + ); + if (ec3 != std::errc() || ptr3 != bitwidth_str.data() + bitwidth_str.size()) + { + return std::nullopt; + } + bit_width = bw; + } + + // Parse scale + int32_t scale = 0; + auto [ptr2, ec2] = std::from_chars( + scale_str.data(), + scale_str.data() + scale_str.size(), + scale + ); + if (ec2 != std::errc() || ptr2 != scale_str.data() + scale_str.size()) + { + return std::nullopt; + } + + return std::make_tuple(precision, scale, bit_width); + } + size_t align_to_8(const size_t n) { return (n + 7) & -8; } + + std::vector extract_words_after_colon(std::string_view str) + { + std::vector result; + + // Find the position of ':' + const auto colon_pos = str.find(':'); + if (colon_pos == std::string_view::npos) + { + return result; // Return empty vector if ':' not found + } + + // Get the substring after ':' + std::string_view remaining = str.substr(colon_pos + 1); + + // If nothing after ':', return empty vector + if (remaining.empty()) + { + return result; + } + + // Split by ',' + size_t start = 0; + size_t comma_pos = remaining.find(','); + + while (comma_pos != std::string_view::npos) + { + result.push_back(remaining.substr(start, comma_pos - start)); + start = comma_pos + 1; + comma_pos = remaining.find(',', start); + } + + // Add the last word (or the only word if no comma was found) + result.push_back(remaining.substr(start)); + + return result; + } + + std::optional parse_to_int32(std::string_view str) + { + int32_t value = 0; + const auto [ptr, ec] = std::from_chars(str.data(), str.data() + str.size(), value); + + if (ec != std::errc() || ptr != str.data() + str.size()) + { + return std::nullopt; + } + return value; + } } diff --git a/tests/test_de_serialization_with_files.cpp b/tests/test_de_serialization_with_files.cpp index 8cb74e8f..96a1f308 100644 --- a/tests/test_de_serialization_with_files.cpp +++ b/tests/test_de_serialization_with_files.cpp @@ -22,8 +22,9 @@ const std::filesystem::path arrow_testing_data_dir = ARROW_TESTING_DATA_DIR; const std::filesystem::path tests_resources_files_path = arrow_testing_data_dir / "data" / "arrow-ipc-stream" / "integration" / "cpp-21.0.0"; -const std::filesystem::path tests_resources_files_path_with_compression = arrow_testing_data_dir / "data" / "arrow-ipc-stream" - / "integration" / "2.0.0-compression"; +const std::filesystem::path tests_resources_files_path_with_compression = arrow_testing_data_dir / "data" + / "arrow-ipc-stream" / "integration" + / "2.0.0-compression"; const std::vector files_paths_to_test = { tests_resources_files_path / "generated_primitive", @@ -33,16 +34,20 @@ const std::vector files_paths_to_test = { tests_resources_files_path / "generated_large_binary", tests_resources_files_path / "generated_binary_zerolength", tests_resources_files_path / "generated_binary_no_batches", + tests_resources_files_path / "generated_decimal32", + tests_resources_files_path / "generated_decimal64", + tests_resources_files_path / "generated_decimal", + tests_resources_files_path / "generated_decimal256", }; const std::vector files_paths_to_test_with_lz4_compression = { tests_resources_files_path_with_compression / "generated_lz4", - tests_resources_files_path_with_compression/ "generated_uncompressible_lz4", + tests_resources_files_path_with_compression / "generated_uncompressible_lz4", }; const std::vector files_paths_to_test_with_zstd_compression = { tests_resources_files_path_with_compression / "generated_zstd", - tests_resources_files_path_with_compression/ "generated_uncompressible_zstd", + tests_resources_files_path_with_compression / "generated_uncompressible_zstd", }; size_t get_number_of_batches(const std::filesystem::path& json_path) @@ -93,15 +98,27 @@ void compare_record_batches( } } -struct Lz4CompressionParams { +struct Lz4CompressionParams +{ static constexpr sparrow_ipc::CompressionType compression_type = sparrow_ipc::CompressionType::LZ4_FRAME; - static const std::vector& files() { return files_paths_to_test_with_lz4_compression; } + + static const std::vector& files() + { + return files_paths_to_test_with_lz4_compression; + } + static constexpr const char* name = "LZ4"; }; -struct ZstdCompressionParams { +struct ZstdCompressionParams +{ static constexpr sparrow_ipc::CompressionType compression_type = sparrow_ipc::CompressionType::ZSTD; - static const std::vector& files() { return files_paths_to_test_with_zstd_compression; } + + static const std::vector& files() + { + return files_paths_to_test_with_zstd_compression; + } + static constexpr const char* name = "ZSTD"; }; @@ -203,13 +220,19 @@ TEST_SUITE("Integration tests") } } - TEST_CASE_TEMPLATE("Compare record_batch serialization with stream file using compression", T, Lz4CompressionParams, ZstdCompressionParams) + TEST_CASE_TEMPLATE( + "Compare record_batch serialization with stream file using compression", + T, + Lz4CompressionParams, + ZstdCompressionParams + ) { for (const auto& file_path : T::files()) { std::filesystem::path json_path = file_path; json_path.replace_extension(".json"); - const std::string test_name = "Testing " + std::string(T::name) + " compression with " + file_path.filename().string(); + const std::string test_name = "Testing " + std::string(T::name) + " compression with " + + file_path.filename().string(); SUBCASE(test_name.c_str()) { // Load the JSON file @@ -254,7 +277,12 @@ TEST_SUITE("Integration tests") } } - TEST_CASE_TEMPLATE("Round trip of classic test files serialization/deserialization using compression", T, Lz4CompressionParams, ZstdCompressionParams) + TEST_CASE_TEMPLATE( + "Round trip of classic test files serialization/deserialization using compression", + T, + Lz4CompressionParams, + ZstdCompressionParams + ) { for (const auto& file_path : files_paths_to_test) { diff --git a/tests/test_utils.cpp b/tests/test_utils.cpp index 0619d68a..bd54c850 100644 --- a/tests/test_utils.cpp +++ b/tests/test_utils.cpp @@ -15,4 +15,244 @@ namespace sparrow_ipc CHECK_EQ(utils::align_to_8(15), 16); CHECK_EQ(utils::align_to_8(16), 16); } + + TEST_CASE("extract_words_after_colon") + { + SUBCASE("Basic case with multiple words") + { + auto result = utils::extract_words_after_colon("d:128,10"); + REQUIRE_EQ(result.size(), 2); + CHECK_EQ(result[0], "128"); + CHECK_EQ(result[1], "10"); + } + + SUBCASE("Single word after colon") + { + auto result = utils::extract_words_after_colon("w:256"); + REQUIRE_EQ(result.size(), 1); + CHECK_EQ(result[0], "256"); + } + + SUBCASE("Three words") + { + auto result = utils::extract_words_after_colon("d:10,5,128"); + REQUIRE_EQ(result.size(), 3); + CHECK_EQ(result[0], "10"); + CHECK_EQ(result[1], "5"); + CHECK_EQ(result[2], "128"); + } + + SUBCASE("No colon in string") + { + auto result = utils::extract_words_after_colon("no_colon"); + CHECK_EQ(result.size(), 0); + } + + SUBCASE("Colon at end") + { + auto result = utils::extract_words_after_colon("prefix:"); + CHECK_EQ(result.size(), 0); + } + + SUBCASE("Empty string") + { + auto result = utils::extract_words_after_colon(""); + CHECK_EQ(result.size(), 0); + } + + SUBCASE("Only colon and comma") + { + auto result = utils::extract_words_after_colon(":,"); + REQUIRE_EQ(result.size(), 2); + CHECK_EQ(result[0], ""); + CHECK_EQ(result[1], ""); + } + + SUBCASE("Complex prefix") + { + auto result = utils::extract_words_after_colon("prefix:word1,word2,word3"); + REQUIRE_EQ(result.size(), 3); + CHECK_EQ(result[0], "word1"); + CHECK_EQ(result[1], "word2"); + CHECK_EQ(result[2], "word3"); + } + } + + TEST_CASE("parse_to_int32") + { + SUBCASE("Valid positive integer") + { + auto result = utils::parse_to_int32("123"); + REQUIRE(result.has_value()); + CHECK_EQ(result.value(), 123); + } + + SUBCASE("Valid negative integer") + { + auto result = utils::parse_to_int32("-456"); + REQUIRE(result.has_value()); + CHECK_EQ(result.value(), -456); + } + + SUBCASE("Zero") + { + auto result = utils::parse_to_int32("0"); + REQUIRE(result.has_value()); + CHECK_EQ(result.value(), 0); + } + + SUBCASE("Large valid number") + { + auto result = utils::parse_to_int32("2147483647"); // INT32_MAX + REQUIRE(result.has_value()); + CHECK_EQ(result.value(), 2147483647); + } + + SUBCASE("Invalid - not a number") + { + auto result = utils::parse_to_int32("abc"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - empty string") + { + auto result = utils::parse_to_int32(""); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - partial number with text") + { + auto result = utils::parse_to_int32("123abc"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - text with number") + { + auto result = utils::parse_to_int32("abc123"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - just a sign") + { + auto result = utils::parse_to_int32("-"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Valid with leading zeros") + { + auto result = utils::parse_to_int32("00123"); + REQUIRE(result.has_value()); + CHECK_EQ(result.value(), 123); + } + } + + TEST_CASE("parse_decimal_format") + { + SUBCASE("Basic format: d:19,10") + { + auto result = utils::parse_decimal_format("d:19,10"); + REQUIRE(result.has_value()); + const auto& [precision, scale, bitwidth] = result.value(); + CHECK_EQ(precision, 19); + CHECK_EQ(scale, 10); + CHECK_FALSE(bitwidth.has_value()); + } + + SUBCASE("Extended format: d:19,10,128") + { + auto result = utils::parse_decimal_format("d:19,10,128"); + REQUIRE(result.has_value()); + const auto& [precision, scale, bitwidth] = result.value(); + CHECK_EQ(precision, 19); + CHECK_EQ(scale, 10); + REQUIRE(bitwidth.has_value()); + CHECK_EQ(bitwidth.value(), 128); + } + + SUBCASE("Extended format: d:38,6,256") + { + auto result = utils::parse_decimal_format("d:38,6,256"); + REQUIRE(result.has_value()); + const auto& [precision, scale, bitwidth] = result.value(); + CHECK_EQ(precision, 38); + CHECK_EQ(scale, 6); + REQUIRE(bitwidth.has_value()); + CHECK_EQ(bitwidth.value(), 256); + } + + SUBCASE("Basic format with zero scale: d:10,0") + { + auto result = utils::parse_decimal_format("d:10,0"); + REQUIRE(result.has_value()); + const auto& [precision, scale, bitwidth] = result.value(); + CHECK_EQ(precision, 10); + CHECK_EQ(scale, 0); + CHECK_FALSE(bitwidth.has_value()); + } + + SUBCASE("Extended format with zero scale: d:10,0,64") + { + auto result = utils::parse_decimal_format("d:10,0,64"); + REQUIRE(result.has_value()); + const auto& [precision, scale, bitwidth] = result.value(); + CHECK_EQ(precision, 10); + CHECK_EQ(scale, 0); + REQUIRE(bitwidth.has_value()); + CHECK_EQ(bitwidth.value(), 64); + } + + SUBCASE("Invalid - no colon") + { + auto result = utils::parse_decimal_format("d19,10"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - no comma") + { + auto result = utils::parse_decimal_format("d:19"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - missing precision") + { + auto result = utils::parse_decimal_format("d:,10"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - missing scale") + { + auto result = utils::parse_decimal_format("d:19,"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - missing bitwidth when provided") + { + auto result = utils::parse_decimal_format("d:19,10,"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - non-numeric precision") + { + auto result = utils::parse_decimal_format("d:abc,10"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - non-numeric scale") + { + auto result = utils::parse_decimal_format("d:19,abc"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - non-numeric bitwidth") + { + auto result = utils::parse_decimal_format("d:19,10,abc"); + CHECK_FALSE(result.has_value()); + } + + SUBCASE("Invalid - empty string") + { + auto result = utils::parse_decimal_format(""); + CHECK_FALSE(result.has_value()); + } + } }