From a30bb41fc1521c8465520b149531e470a0c5689e Mon Sep 17 00:00:00 2001 From: Georgij Tsarin <68424751+crystarm@users.noreply.github.com> Date: Fri, 8 May 2026 20:45:32 +0300 Subject: [PATCH] feat ydb: add support for Decimal type --- ydb/include/userver/ydb/io/decimal64.hpp | 162 ++++++++ ydb/include/userver/ydb/io/primitives.hpp | 49 +++ .../userver/ydb/io/supported_types.hpp | 2 + ydb/include/userver/ydb/types.hpp | 45 ++- ydb/src/ydb/io/primitives.cpp | 76 ++++ ydb/tests/decimal_test.cpp | 368 ++++++++++++++++++ 6 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 ydb/include/userver/ydb/io/decimal64.hpp create mode 100644 ydb/tests/decimal_test.cpp diff --git a/ydb/include/userver/ydb/io/decimal64.hpp b/ydb/include/userver/ydb/io/decimal64.hpp new file mode 100644 index 000000000000..658c73cb3a0c --- /dev/null +++ b/ydb/include/userver/ydb/io/decimal64.hpp @@ -0,0 +1,162 @@ +#pragma once + +/// @file userver/ydb/io/decimal64.hpp +/// @brief YDB serialization support for `userver::decimal64::Decimal` +/// +/// `decimal64::Decimal` is mapped to the YDB type +/// `Decimal(22, Prec)`. The precision is fixed at `22`, which matches the +/// most common "money-like" YDB schema `Decimal(22, 9)` and is sufficient to +/// hold any value representable by `decimal64::Decimal` (its mantissa fits in +/// `int64_t`, i.e. up to 19 significant digits). +/// +/// Schemas that use a different precision (e.g. `Decimal(35, 18)`) should use +/// `ydb::Decimal` instead, which carries `precision` and `scale` at runtime. +/// +/// On read, `decimal64::Decimal::FromStringPermissive` is used, so +/// values stored with a YDB scale larger than `Prec` are rounded according +/// to `RoundPolicy` instead of being rejected. + +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace ydb { + +namespace impl { + +inline bool IsOptionalDecimal64(const NYdb::TValueParser& parser) { + return parser.GetKind() == NYdb::TTypeParser::ETypeKind::Optional; +} + +template +decimal64::Decimal ParseDecimal64Value(const NYdb::TValueParser& parser) { + // YDB stores decimals with a fixed scale, which may differ from `Prec`. + // `FromStringPermissive` tolerates trailing zeros and rounds extra + // fractional digits according to `RoundPolicy`, which is what we want. + return decimal64::Decimal::FromStringPermissive(parser.GetDecimal().ToString()); +} + +template +void WriteDecimal64Value( + NYdb::TValueBuilderBase& builder, + const decimal64::Decimal& value +) { + builder.Decimal(NYdb::TDecimalValue( + impl::ToString(decimal64::ToString(value)), + Decimal::kDefaultPrecision, + static_cast(Prec) + )); +} + +inline NYdb::TType MakeDecimal64Type(std::uint8_t scale) { + NYdb::TTypeBuilder builder; + builder.Decimal(NYdb::TDecimalType{Decimal::kDefaultPrecision, scale}); + return builder.Build(); +} + +inline NYdb::TType MakeOptionalDecimal64Type(std::uint8_t scale) { + NYdb::TTypeBuilder builder; + builder.BeginOptional(); + builder.Decimal(NYdb::TDecimalType{Decimal::kDefaultPrecision, scale}); + builder.EndOptional(); + return builder.Build(); +} + +} // namespace impl + +template +struct ValueTraits> { + static_assert( + Prec >= 0 && Prec <= Decimal::kDefaultPrecision, + "decimal64::Decimal must have 0 <= Prec <= ydb::Decimal::kDefaultPrecision (22) " + "to map to YDB Decimal(22, Prec); use ydb::Decimal for non-money-like schemas" + ); + + using Type = decimal64::Decimal; + + static Type Parse(NYdb::TValueParser& parser, const ParseContext& /*context*/) { + const bool is_optional = impl::IsOptionalDecimal64(parser); + + if (is_optional) { + parser.OpenOptional(); + } + + // Will throw exception if value is null. + auto value = impl::ParseDecimal64Value(parser); + + if (is_optional) { + parser.CloseOptional(); + } + + return value; + } + + template + static void Write(NYdb::TValueBuilderBase& builder, const Type& value) { + impl::WriteDecimal64Value(builder, value); + } + + static NYdb::TType MakeType() { return impl::MakeDecimal64Type(static_cast(Prec)); } +}; + +template +struct ValueTraits>> { + static_assert( + Prec >= 0 && Prec <= Decimal::kDefaultPrecision, + "decimal64::Decimal must have 0 <= Prec <= ydb::Decimal::kDefaultPrecision (22) " + "to map to YDB Decimal(22, Prec); use ydb::Decimal for non-money-like schemas" + ); + + using Type = decimal64::Decimal; + + static std::optional Parse(NYdb::TValueParser& parser, const ParseContext& context) { + const bool is_optional = impl::IsOptionalDecimal64(parser); + if (is_optional) { + parser.OpenOptional(); + + if (parser.IsNull()) { + parser.CloseOptional(); + return {}; + } + } else { + LOG_WARNING() + << "Trying to parse " << context.column_name << " as " + << compiler::GetTypeName>() << " while actual type is not Optional"; + } + + auto value = impl::ParseDecimal64Value(parser); + if (is_optional) { + parser.CloseOptional(); + } + + return value; + } + + template + static void Write(NYdb::TValueBuilderBase& builder, const std::optional& value) { + if (value) { + builder.BeginOptional(); + impl::WriteDecimal64Value(builder, *value); + builder.EndOptional(); + } else { + builder.EmptyOptional(impl::MakeDecimal64Type(static_cast(Prec))); + } + } + + static NYdb::TType MakeType() { return impl::MakeOptionalDecimal64Type(static_cast(Prec)); } +}; + +} // namespace ydb + +USERVER_NAMESPACE_END diff --git a/ydb/include/userver/ydb/io/primitives.hpp b/ydb/include/userver/ydb/io/primitives.hpp index 8880682facf0..f80c56797d77 100644 --- a/ydb/include/userver/ydb/io/primitives.hpp +++ b/ydb/include/userver/ydb/io/primitives.hpp @@ -50,6 +50,23 @@ struct PrimitiveTraits { static NYdb::TType MakeType(); }; +template +struct DecimalTraits { + static typename DecimalLikeTrait::Type Parse(NYdb::TValueParser& parser, const ParseContext& context); + + static void Write( + NYdb::TValueBuilderBase& builder, + const typename DecimalLikeTrait::Type& value + ); + + static void Write( + NYdb::TValueBuilderBase& builder, + const typename DecimalLikeTrait::Type& value + ); + + static NYdb::TType MakeType(); +}; + struct BoolTrait { using Type = bool; static Type Parse(const NYdb::TValueParser& value_parser); @@ -162,12 +179,44 @@ struct JsonDocumentTrait { static void Write(NYdb::TValueBuilderBase& builder, const Type& value); }; +struct DecimalTrait { + using Type = Decimal; + static Type Parse(const NYdb::TValueParser& value_parser); + template + static void Write(NYdb::TValueBuilderBase& builder, const Type& value); +}; + template <> struct ValueTraits> : OptionalPrimitiveTraits {}; template <> struct ValueTraits : PrimitiveTraits {}; +template <> +struct ValueTraits : DecimalTraits {}; + +// `std::optional` is supported on read only. Writing a NULL +// `ydb::Decimal` would require knowing the YDB column's precision and scale, +// which an empty optional does not carry. For nullable Decimal columns whose +// schema is `Decimal(22, Prec)`, use `std::optional>` +// instead -- its precision/scale are encoded in the C++ type. +template <> +struct ValueTraits> { + static std::optional Parse(NYdb::TValueParser& parser, const ParseContext& context); + + template + static void Write(NYdb::TValueBuilderBase& /*builder*/, const std::optional& /*value*/) { + static_assert( + sizeof(Builder) == 0, + "Writing std::optional is not supported: an empty optional carries no " + "precision/scale, and YDB Decimal type requires them. Use std::optional> " + "or write a non-optional ydb::Decimal{value, precision, scale}." + ); + } + + static NYdb::TType MakeType() = delete; +}; + template <> struct ValueTraits> : OptionalPrimitiveTraits {}; diff --git a/ydb/include/userver/ydb/io/supported_types.hpp b/ydb/include/userver/ydb/io/supported_types.hpp index e92339d2f97e..8aa18a84e760 100644 --- a/ydb/include/userver/ydb/io/supported_types.hpp +++ b/ydb/include/userver/ydb/io/supported_types.hpp @@ -17,12 +17,14 @@ /// * ValueType::Utf8, ydb::Utf8 /// * ValueType::Timestamp, std::chrono::system_clock::time_point /// * ValueType::Uuid, boost::uuids::uuid +/// * ValueType::Decimal, ydb::Decimal, decimal64::Decimal /// /// Available composite types: /// * Optional, std::optional for primitive types, List and Struct /// * List, std::vector and non-map containers /// * Struct, @ref ydb::kStructMemberNames "C++ structs" +#include #include #include #include diff --git a/ydb/include/userver/ydb/types.hpp b/ydb/include/userver/ydb/types.hpp index 7266650d4e13..7bcaea1b0a74 100644 --- a/ydb/include/userver/ydb/types.hpp +++ b/ydb/include/userver/ydb/types.hpp @@ -27,6 +27,7 @@ namespace ydb { * Uint64 | std::uint64_t * Float | N/A * Double | double + * Decimal | ydb::Decimal * Date | N/A * Datetime | N/A * Timestamp | std::chrono::system_clock::time_point @@ -52,6 +53,47 @@ using Utf8 = utils::StrongTypedef; class JsonDocumentTag {}; using JsonDocument = utils::StrongTypedef; +// A YDB Decimal value as a (value, precision, scale) triple. +// +// In YDB, `precision` and `scale` are part of the column type, not of the +// value, and must be provided when writing. `ydb::Decimal` is a thin transport +// wrapper around `NYdb::TDecimalValue` that preserves them as-is; it is NOT +// an arithmetic type. For numeric operations, parsing from numbers, rounding +// etc. use `decimal64::Decimal` (see +// `userver/ydb/io/decimal64.hpp`), whose `ValueTraits` are also provided by +// this library. +// +// `value` stores the decimal in the same string form that the YDB SDK +// produces and accepts (e.g. `"123.456789"`). YDB returns decimals in a +// canonical form with trailing zeros stripped, so a value written as +// `"7.500000000"` will be read back as `"7.5"`. Consequently `operator==` +// performs a strict string comparison and is NOT a numeric equality check. +// +// The default `precision = 22`, `scale = 9` matches the most common +// "money-like" YDB schema `Decimal(22, 9)`. They are exposed as +// `kDefaultPrecision` / `kDefaultScale` for convenience but should be +// overridden whenever the column uses a different type, e.g. +// `ydb::Decimal{"123.456789012345678", 35, 18}`. +struct Decimal { + static constexpr std::uint8_t kDefaultPrecision = 22; + static constexpr std::uint8_t kDefaultScale = 9; + + std::string value; + std::uint8_t precision{kDefaultPrecision}; + std::uint8_t scale{kDefaultScale}; + + Decimal() = default; + + Decimal(std::string value, std::uint8_t precision, std::uint8_t scale) + : value(std::move(value)), precision(precision), scale(scale) {} + + friend bool operator==(const Decimal& lhs, const Decimal& rhs) { + return lhs.precision == rhs.precision && lhs.scale == rhs.scale && lhs.value == rhs.value; + } + + friend bool operator!=(const Decimal& lhs, const Decimal& rhs) { return !(lhs == rhs); } +}; + using InsertColumnValue = std::variant< std::string, bool, @@ -78,7 +120,8 @@ using InsertColumnValue = std::variant< std::optional, std::optional, std::optional, - std::optional>; + std::optional, + Decimal>; struct InsertColumn { std::string name; diff --git a/ydb/src/ydb/io/primitives.cpp b/ydb/src/ydb/io/primitives.cpp index af6d65c1b16e..a39615d5efdf 100644 --- a/ydb/src/ydb/io/primitives.cpp +++ b/ydb/src/ydb/io/primitives.cpp @@ -145,6 +145,46 @@ NYdb::TType PrimitiveTraits::MakeType() { return builder.Build(); } +template +typename DecimalLikeTrait::Type DecimalTraits< + DecimalLikeTrait>::Parse(NYdb::TValueParser& parser, const ParseContext& /*context*/) { + const bool is_optional = IsOptional(parser); + + if (is_optional) { + parser.OpenOptional(); + } + + auto value = DecimalLikeTrait::Parse(parser); + if (is_optional) { + parser.CloseOptional(); + } + + return value; +} + +template +void DecimalTraits::Write( + NYdb::TValueBuilderBase& builder, + const typename DecimalLikeTrait::Type& value +) { + DecimalLikeTrait::Write(builder, value); +} + +template +void DecimalTraits::Write( + NYdb::TValueBuilderBase& builder, + const typename DecimalLikeTrait::Type& value +) { + DecimalLikeTrait::Write(builder, value); +} + +template +NYdb::TType DecimalTraits::MakeType() { + NYdb::TTypeBuilder builder; + builder.Decimal(NYdb::TDecimalType{Decimal::kDefaultPrecision, Decimal::kDefaultScale}); + return builder.Build(); +} + template struct OptionalPrimitiveTraits; template struct PrimitiveTraits; @@ -334,6 +374,42 @@ void JsonDocumentTrait::Write(NYdb::TValueBuilderBase& builder, const T builder.JsonDocument(formats::json::ToString(value.GetUnderlying())); } +template struct DecimalTraits; + +DecimalTrait::Type DecimalTrait::Parse(const NYdb::TValueParser& value_parser) { + const auto decimal = value_parser.GetDecimal(); + return Decimal{decimal.ToString(), decimal.DecimalType_.Precision, decimal.DecimalType_.Scale}; +} + +std::optional +ValueTraits>::Parse(NYdb::TValueParser& parser, const ParseContext& context) { + const bool is_optional = IsOptional(parser); + if (is_optional) { + parser.OpenOptional(); + + if (parser.IsNull()) { + parser.CloseOptional(); + return {}; + } + } else { + LOG_WARNING() << "Trying to parse " << context.column_name << " as " + << compiler::GetTypeName>() + << " while actual type is not Optional"; + } + + auto value = DecimalTrait::Parse(parser); + if (is_optional) { + parser.CloseOptional(); + } + + return value; +} + +template +void DecimalTrait::Write(NYdb::TValueBuilderBase& builder, const Type& value) { + builder.Decimal(NYdb::TDecimalValue(impl::ToString(value.value), value.precision, value.scale)); +} + } // namespace ydb USERVER_NAMESPACE_END diff --git a/ydb/tests/decimal_test.cpp b/ydb/tests/decimal_test.cpp new file mode 100644 index 000000000000..2fb73d139b68 --- /dev/null +++ b/ydb/tests/decimal_test.cpp @@ -0,0 +1,368 @@ +#include "test_utils.hpp" + +#include + +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace { + +class TDecimalYdbTestCase : public ydb::ClientFixtureBase { +protected: + TDecimalYdbTestCase() { InitializeTable(); } + +private: + void InitializeTable() { + DoCreateTable( + "decimal_test", + NYdb::NTable::TTableBuilder() + .AddNullableColumn("key", NYdb::EPrimitiveType::String) + .AddNullableColumn("value_decimal", NYdb::TDecimalType{/*precision=*/22, /*scale=*/9}) + .SetPrimaryKeyColumn("key") + .Build() + ); + + const ydb::Query fill_query{ + R"( + --!syntax_v1 + UPSERT INTO decimal_test ( + key, value_decimal + ) VALUES ( + "key", CAST("123.456789" AS Decimal(22, 9)) + ), ( + "key_null", null + ); + )", + ydb::Query::Name{"FillTable/decimal_test"}, + }; + + GetTableClient().ExecuteDataQuery(fill_query); + } +}; + +// The test fixture table uses Decimal(22, 9) To verify that `ydb::Decimal` +// correctly handles non-default precision/scale at the serialization level, +// this fixture uses prepared parameters declared as Decimal(35, 18). +using TDecimalWideYdbTestCase = ydb::ClientFixtureBase; + +template +void AssertNullableColumn(ydb::Row& r, const std::string& column, T exp) { + auto value = r.Get>(column); + ASSERT_TRUE(value); + ASSERT_EQ(value.value(), exp); +} + +template +void AssertNullColumn(ydb::Row& r, const std::string& key) { + auto value = r.Get>(key); + ASSERT_FALSE(value); +} + +} // namespace + +UTEST_F(TDecimalYdbTestCase, ResponseValueType) { + const ydb::Query query{R"( + DECLARE $search_key AS String; + + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = $search_key; + )"}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$search_key", std::string{"key"})); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key"}); + AssertNullableColumn(row, "value_decimal", ydb::Decimal{"123.456789", 22, 9}); + } +} + +UTEST_F(TDecimalYdbTestCase, PreparedRequestType) { + const ydb::Query query{R"( + DECLARE $search_value AS Decimal(22, 9); + + SELECT key + FROM decimal_test + WHERE value_decimal = $search_value + LIMIT 1; + )"}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$search_value", ydb::Decimal{"123.456789", 22, 9})); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key"}); + } +} + +UTEST_F(TDecimalYdbTestCase, ResponseValueTypeDecimal64) { + using Money = decimal64::Decimal<9>; + + const ydb::Query query{R"( + DECLARE $search_key AS String; + + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = $search_key; + )"}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$search_key", std::string{"key"})); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key"}); + AssertNullableColumn(row, "value_decimal", Money{"123.456789"}); + } +} + +UTEST_F(TDecimalYdbTestCase, ResponseNullValueTypeDecimal64) { + using Money = decimal64::Decimal<9>; + + const ydb::Query query{R"( + DECLARE $search_key AS String; + + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = $search_key; + )"}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$search_key", std::string{"key_null"})); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", "key_null"); + AssertNullColumn(row, "value_decimal"); + } +} + +UTEST_F(TDecimalYdbTestCase, PreparedRequestTypeDecimal64) { + using Money = decimal64::Decimal<9>; + + const ydb::Query query{R"( + DECLARE $search_value AS Decimal(22, 9); + + SELECT key + FROM decimal_test + WHERE value_decimal = $search_value + LIMIT 1; + )"}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$search_value", Money{"123.456789"})); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key"}); + } +} + +UTEST_F(TDecimalYdbTestCase, PreparedUpsertTypeDecimal64) { + using Money = decimal64::Decimal<9>; + + const ydb::Query upsert_query{R"( + --!syntax_v1 + DECLARE $search_key AS String; + DECLARE $data_decimal AS Decimal(22, 9); + + UPSERT INTO decimal_test ( + key, + value_decimal + ) VALUES ( + $search_key, + $data_decimal + ); + )"}; + + auto upsert_builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(upsert_builder.Add("$search_key", std::string{"key_new_decimal64"})); + UASSERT_NO_THROW(upsert_builder.Add("$data_decimal", Money{"-987.654321"})); + + UASSERT_NO_THROW( + GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, upsert_query, std::move(upsert_builder)) + ); + + auto result = GetTableClient().ExecuteDataQuery(ydb::Query{R"( + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = "key_new_decimal64"; + )"}); + + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key_new_decimal64"}); + AssertNullableColumn(row, "value_decimal", Money{"-987.654321"}); + } +} + +UTEST_F(TDecimalYdbTestCase, PreparedUpsertNullTypeDecimal64) { + using Money = decimal64::Decimal<9>; + + const ydb::Query upsert_query{R"( + --!syntax_v1 + DECLARE $search_key AS String; + DECLARE $data_decimal AS Decimal(22, 9)?; + + UPSERT INTO decimal_test ( + key, + value_decimal + ) VALUES ( + $search_key, + $data_decimal + ); + )"}; + + auto upsert_builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(upsert_builder.Add("$search_key", std::string{"key_new_decimal64_null"})); + UASSERT_NO_THROW(upsert_builder.Add("$data_decimal", std::optional{})); + + UASSERT_NO_THROW( + GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, upsert_query, std::move(upsert_builder)) + ); + + auto result = GetTableClient().ExecuteDataQuery(ydb::Query{R"( + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = "key_new_decimal64_null"; + )"}); + + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", "key_new_decimal64_null"); + AssertNullColumn(row, "value_decimal"); + } +} + +UTEST_F(TDecimalYdbTestCase, InsertRowUpsertType) { + const ydb::Query query{R"( + --!syntax_v1 + DECLARE $items AS List< + Struct< + 'key': String, + 'value_decimal': Decimal(22, 9) + > + >; + + UPSERT INTO decimal_test + SELECT * FROM AS_TABLE($items); + )"}; + + auto builder = GetTableClient().GetBuilder(); + auto row = ydb::InsertRow{ + ydb::InsertColumn{"key", std::string{"key_new_insertrow"}}, + ydb::InsertColumn{"value_decimal", ydb::Decimal{"42.000000001", 22, 9}}, + }; + std::vector rows{row}; + UASSERT_NO_THROW(builder.Add("$items", rows)); + + UASSERT_NO_THROW(GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder))); + + auto result = GetTableClient().ExecuteDataQuery(ydb::Query{R"( + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = "key_new_insertrow"; + )"}); + + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key_new_insertrow"}); + AssertNullableColumn(row, "value_decimal", ydb::Decimal{"42.000000001", 22, 9}); + } +} + +UTEST_F(TDecimalYdbTestCase, PreparedUpsertType) { + const ydb::Query upsert_query{R"( + --!syntax_v1 + DECLARE $search_key AS String; + DECLARE $data_decimal AS Decimal(22, 9); + + UPSERT INTO decimal_test ( + key, + value_decimal + ) VALUES ( + $search_key, + $data_decimal + ); + )"}; + + auto upsert_builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(upsert_builder.Add("$search_key", std::string{"key_new_decimal"})); + UASSERT_NO_THROW(upsert_builder.Add("$data_decimal", ydb::Decimal{"-987.654321", 22, 9})); + + UASSERT_NO_THROW( + GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, upsert_query, std::move(upsert_builder)) + ); + + auto result = GetTableClient().ExecuteDataQuery(ydb::Query{R"( + SELECT + key, + value_decimal + FROM decimal_test + WHERE key = "key_new_decimal"; + )"}); + + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "key", std::string{"key_new_decimal"}); + AssertNullableColumn(row, "value_decimal", ydb::Decimal{"-987.654321", 22, 9}); + } +} + +// Verifies that `ydb::Decimal` correctly serializes non-default precision/scale. +// The test fixture table uses Decimal(22, 9), so this test uses a prepared +// parameter declared as `Decimal(35, 18)` and selects it back without storing it +// in the fixture table. This checks that precision/scale round-trip through the SDK. +UTEST_F(TDecimalWideYdbTestCase, RuntimePrecisionScale) { + const ydb::Query query{R"( + DECLARE $v AS Decimal(35, 18); + + SELECT $v AS value_decimal; + )"}; + + const ydb::Decimal input{"-12345678901234567.123456789012345678", 35, 18}; + + auto builder = GetTableClient().GetBuilder(); + UASSERT_NO_THROW(builder.Add("$v", input)); + + auto result = GetTableClient().ExecuteDataQuery(ydb::OperationSettings{}, query, std::move(builder)); + auto cursor = result.GetSingleCursor(); + ASSERT_EQ(cursor.size(), 1); + for (auto row : cursor) { + AssertNullableColumn(row, "value_decimal", input); + } +} + +USERVER_NAMESPACE_END