diff --git a/google/cloud/spanner/CMakeLists.txt b/google/cloud/spanner/CMakeLists.txt index 1df7f5c2c4756..39c9b305fefed 100644 --- a/google/cloud/spanner/CMakeLists.txt +++ b/google/cloud/spanner/CMakeLists.txt @@ -211,6 +211,8 @@ add_library( transaction.cc transaction.h update_instance_request_builder.h + uuid.cc + uuid.h value.cc value.h version.cc @@ -502,6 +504,7 @@ function (spanner_client_define_tests) timestamp_test.cc transaction_test.cc update_instance_request_builder_test.cc + uuid_test.cc value_test.cc) # Export the list of unit tests to a .bzl file so we do not need to maintain diff --git a/google/cloud/spanner/google_cloud_cpp_spanner.bzl b/google/cloud/spanner/google_cloud_cpp_spanner.bzl index 6e6e882317f5e..82d829d0680fb 100644 --- a/google/cloud/spanner/google_cloud_cpp_spanner.bzl +++ b/google/cloud/spanner/google_cloud_cpp_spanner.bzl @@ -119,6 +119,7 @@ google_cloud_cpp_spanner_hdrs = [ "tracing_options.h", "transaction.h", "update_instance_request_builder.h", + "uuid.h", "value.h", "version.h", "version_info.h", @@ -198,6 +199,7 @@ google_cloud_cpp_spanner_srcs = [ "sql_statement.cc", "timestamp.cc", "transaction.cc", + "uuid.cc", "value.cc", "version.cc", ] diff --git a/google/cloud/spanner/spanner_client_unit_tests.bzl b/google/cloud/spanner/spanner_client_unit_tests.bzl index b23ab1fa07817..ad40b1f607946 100644 --- a/google/cloud/spanner/spanner_client_unit_tests.bzl +++ b/google/cloud/spanner/spanner_client_unit_tests.bzl @@ -69,5 +69,6 @@ spanner_client_unit_tests = [ "timestamp_test.cc", "transaction_test.cc", "update_instance_request_builder_test.cc", + "uuid_test.cc", "value_test.cc", ] diff --git a/google/cloud/spanner/uuid.cc b/google/cloud/spanner/uuid.cc new file mode 100644 index 0000000000000..f14769667bfaa --- /dev/null +++ b/google/cloud/spanner/uuid.cc @@ -0,0 +1,150 @@ +// Copyright 2025 Google LLC +// +// 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 +// +// https://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. + +#include "google/cloud/spanner/uuid.h" +#include "google/cloud/internal/make_status.h" +#include "absl/strings/str_format.h" +#include "absl/strings/strip.h" +#include + +namespace google { +namespace cloud { +namespace spanner { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +// Helper function to parse a single hexadecimal block of a UUID. +// A hexadecimal block is a 16-digit hexadecimal number, which is represented +// as 8 bytes. +StatusOr ParseHexBlock(absl::string_view& str, + absl::string_view original_str) { + constexpr int kUuidNumberOfHexDigits = 32; + constexpr int kMaxUuidBlockLength = 16; + static auto const* char_to_hex = new std::unordered_map( + {{'0', 0x00}, {'1', 0x01}, {'2', 0x02}, {'3', 0x03}, {'4', 0x04}, + {'5', 0x05}, {'6', 0x06}, {'7', 0x07}, {'8', 0x08}, {'9', 0x09}, + {'a', 0x0a}, {'b', 0x0b}, {'c', 0x0c}, {'d', 0x0d}, {'e', 0x0e}, + {'f', 0x0f}, {'A', 0x0a}, {'B', 0x0b}, {'C', 0x0c}, {'D', 0x0d}, + {'E', 0x0e}, {'F', 0x0f}}); + std::uint64_t block = 0; + for (int j = 0; j < kMaxUuidBlockLength; ++j) { + absl::ConsumePrefix(&str, "-"); + if (str.empty()) { + return internal::InvalidArgumentError( + absl::StrFormat("UUID must contain %d hexadecimal digits: %s", + kUuidNumberOfHexDigits, original_str), + GCP_ERROR_INFO()); + } + auto it = char_to_hex->find(str[0]); + if (it == char_to_hex->end()) { + if (str[0] == '-') { + return internal::InvalidArgumentError( + absl::StrFormat("UUID cannot contain consecutive hyphens: %s", + original_str), + GCP_ERROR_INFO()); + } + + return internal::InvalidArgumentError( + absl::StrFormat("UUID contains invalid character (%c): %s", str[0], + original_str), + GCP_ERROR_INFO()); + } + block = (block << 4) + it->second; + str.remove_prefix(1); + } + return block; +} +} // namespace + +Uuid::Uuid(absl::uint128 value) : uuid_(value) {} + +Uuid::Uuid(std::uint64_t high_bits, std::uint64_t low_bits) + : Uuid(absl::MakeUint128(high_bits, low_bits)) {} + +std::pair Uuid::As64BitPair() const { + return std::make_pair(Uint128High64(uuid_), Uint128Low64(uuid_)); +} + +// TODO(#15043): Refactor to handle all 128 bits at once instead of splitting +// into a pair of unsigned 64-bit integers. +Uuid::operator std::string() const { + constexpr int kUuidStringLen = 36; + constexpr int kChunkLength[] = {8, 4, 4, 4, 12}; + auto to_hex = [](std::uint64_t v, int start_index, int end_index, char* out) { + static constexpr char kHexChar[] = {'0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; + for (int i = start_index; i >= end_index; --i) { + *out++ = kHexChar[(v >> (i * 4)) & 0xf]; + } + return start_index - end_index + 1; + }; + + std::string output; + output.resize(kUuidStringLen); + char* target = const_cast(output.data()); + char* const last = &((output)[output.size()]); + auto bits = Uint128High64(uuid_); + int start = 16; + for (auto length : kChunkLength) { + int end = start - length; + target += to_hex(bits, start - 1, end, target); + // Only hyphens write to valid addresses. + if (target < last) *(target++) = '-'; + if (end == 0) { + start = 16; + bits = Uint128Low64(uuid_); + } else { + start = end; + } + } + return output; +} + +StatusOr MakeUuid(absl::string_view str) { + absl::string_view original_str = str; + // Check and remove optional braces + if (absl::ConsumePrefix(&str, "{")) { + if (!absl::ConsumeSuffix(&str, "}")) { + return internal::InvalidArgumentError( + absl::StrFormat("UUID missing closing '}': %s", original_str), + GCP_ERROR_INFO()); + } + } + + // Check for leading hyphen after stripping any surrounding braces. + if (absl::StartsWith(str, "-")) { + return internal::InvalidArgumentError( + absl::StrFormat("UUID cannot begin with '-': %s", original_str), + GCP_ERROR_INFO()); + } + + // TODO(#15043): Refactor to parse all the bits at once. + auto high_bits = ParseHexBlock(str, original_str); + if (!high_bits.ok()) return std::move(high_bits).status(); + auto low_bits = ParseHexBlock(str, original_str); + if (!low_bits.ok()) return std::move(low_bits).status(); + + if (!str.empty()) { + return internal::InvalidArgumentError( + absl::StrFormat("Extra characters found after parsing UUID: %s", str), + GCP_ERROR_INFO()); + } + + return Uuid(absl::MakeUint128(*high_bits, *low_bits)); +} + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace spanner +} // namespace cloud +} // namespace google diff --git a/google/cloud/spanner/uuid.h b/google/cloud/spanner/uuid.h new file mode 100644 index 0000000000000..59d6c0013228b --- /dev/null +++ b/google/cloud/spanner/uuid.h @@ -0,0 +1,120 @@ +// Copyright 2025 Google LLC +// +// 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 +// +// https://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. + +#ifndef GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_SPANNER_UUID_H +#define GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_SPANNER_UUID_H + +#include "google/cloud/spanner/version.h" +#include "google/cloud/status_or.h" +#include "absl/numeric/int128.h" +#include "absl/strings/string_view.h" +#include +#include +#include +#include + +namespace google { +namespace cloud { +namespace spanner { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN + +/** + * A representation of the Spanner UUID type: A fixed size 16 byte value + * that can be represented as a 32-digit hexadecimal string. + * + * @see https://cloud.google.com/spanner/docs/data-types#uuid_type + */ +class Uuid { + public: + /// Default construction yields a zero value UUID. + Uuid() = default; + + /// Construct a UUID from one unsigned 128-bit integer. + explicit Uuid(absl::uint128 value); + + /// Construct a UUID from two unsigned 64-bit pieces. + Uuid(std::uint64_t high_bits, std::uint64_t low_bits); + + /// @name Regular value type, supporting copy, assign, move. + ///@{ + Uuid(Uuid&&) = default; + Uuid& operator=(Uuid&&) = default; + Uuid(Uuid const&) = default; + Uuid& operator=(Uuid const&) = default; + ///@} + + /// @name Relational operators + /// + ///@{ + friend bool operator==(Uuid const& lhs, Uuid const& rhs) { + return lhs.uuid_ == rhs.uuid_; + } + friend bool operator!=(Uuid const& lhs, Uuid const& rhs) { + return !(lhs == rhs); + } + friend bool operator<(Uuid const& lhs, Uuid const& rhs) { + return lhs.uuid_ < rhs.uuid_; + } + friend bool operator<=(Uuid const& lhs, Uuid const& rhs) { + return !(rhs < lhs); + } + friend bool operator>=(Uuid const& lhs, Uuid const& rhs) { + return !(lhs < rhs); + } + friend bool operator>(Uuid const& lhs, Uuid const& rhs) { return rhs < lhs; } + ///@} + + /// @name Returns a pair of unsigned 64-bit integers representing the UUID. + std::pair As64BitPair() const; + + /// @name Conversion to unsigned 128-bit integer representation. + explicit operator absl::uint128() const { return uuid_; } + + /// @name Conversion to a lower case string formatted as: + /// [8 hex-digits]-[4 hex-digits]-[4 hex-digits]-[4 hex-digits]-[12 + /// hex-digits] + /// Example: 0b6ed04c-a16d-fc46-5281-7f9978c13738 + explicit operator std::string() const; + + /// @name Output streaming + friend std::ostream& operator<<(std::ostream& os, Uuid uuid) { + return os << std::string(uuid); + } + + private: + absl::uint128 uuid_ = 0; +}; + +/** + * Parses a textual representation a `Uuid` from a string of hexadecimal digits. + * Returns an error if unable to parse the given input. + * + * Acceptable input strings must consist of 32 hexadecimal digits: [0-9a-fA-F]. + * Optional curly braces are allowed around the entire sequence of digits as are + * hyphens between any pair of hexadecimal digits. + * + * Example acceptable inputs: + * - {0b6ed04c-a16d-fc46-5281-7f9978c13738} + * - 0b6ed04ca16dfc4652817f9978c13738 + * - 7Bf8-A7b8-1917-1919-2625-F208-c582-4254 + * - {DECAFBAD-DEAD-FADE-CAFE-FEEDFACEBEEF} + */ +StatusOr MakeUuid(absl::string_view s); + +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace spanner +} // namespace cloud +} // namespace google + +#endif // GOOGLE_CLOUD_CPP_GOOGLE_CLOUD_SPANNER_UUID_H diff --git a/google/cloud/spanner/uuid_test.cc b/google/cloud/spanner/uuid_test.cc new file mode 100644 index 0000000000000..d2513134c448e --- /dev/null +++ b/google/cloud/spanner/uuid_test.cc @@ -0,0 +1,194 @@ +// Copyright 2025 Google LLC +// +// 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 +// +// https://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. + +#include "google/cloud/spanner/uuid.h" +#include "google/cloud/testing_util/status_matchers.h" +#include + +namespace google { +namespace cloud { +namespace spanner { +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +using ::google::cloud::testing_util::IsOkAndHolds; +using ::google::cloud::testing_util::StatusIs; +using ::testing::Eq; +using ::testing::HasSubstr; + +TEST(UuidTest, DefaultConstruction) { + Uuid uuid1; + EXPECT_THAT(static_cast(uuid1), Eq(0)); + Uuid uuid2{}; + EXPECT_THAT(static_cast(uuid2), Eq(0)); +} + +TEST(UuidTest, ConstructedFromUint128) { + absl::uint128 expected_value = + absl::MakeUint128(0x0b6ed04ca16dfc46, 0x52817f9978c13738); + Uuid uuid{expected_value}; + EXPECT_THAT(static_cast(uuid), Eq(expected_value)); +} + +TEST(UuidTest, ConstructedFromUint64Pieces) { + std::uint64_t high = 0x7bf8a7b819171919; + std::uint64_t low = 0x2625f208c5824254; + + absl::uint128 expected_value = absl::MakeUint128(high, low); + Uuid uuid{high, low}; + EXPECT_THAT(static_cast(uuid), Eq(expected_value)); +} + +TEST(UuidTest, RelationalOperations) { + Uuid uuid1{absl::MakeUint128(0x0b6ed04ca16dfc46, 0x52817f9978c13738)}; + Uuid uuid2{absl::MakeUint128(0x7bf8a7b819171919, 0x2625f208c5824254)}; + + EXPECT_TRUE(uuid1 == uuid1); + EXPECT_FALSE(uuid1 != uuid1); + EXPECT_TRUE(uuid1 != uuid2); + + EXPECT_TRUE(uuid1 < uuid2); + EXPECT_FALSE(uuid2 < uuid2); + EXPECT_FALSE(uuid2 < uuid1); + EXPECT_TRUE(uuid2 >= uuid1); + EXPECT_TRUE(uuid2 > uuid1); + EXPECT_TRUE(uuid1 <= uuid2); +} + +TEST(UuidTest, ConversionToString) { + Uuid uuid0; + Uuid uuid1{absl::MakeUint128(0x0b6ed04ca16dfc46, 0x52817f9978c13738)}; + Uuid uuid2{absl::MakeUint128(0x7bf8a7b819171919, 0x2625f208c5824254)}; + + EXPECT_THAT(static_cast(uuid0), + Eq("00000000-0000-0000-0000-000000000000")); + EXPECT_THAT(static_cast(uuid1), + Eq("0b6ed04c-a16d-fc46-5281-7f9978c13738")); + EXPECT_THAT(static_cast(uuid2), + Eq("7bf8a7b8-1917-1919-2625-f208c5824254")); +} + +TEST(UuidTest, ostream) { + Uuid uuid1{absl::MakeUint128(0x0b6ed04ca16dfc46, 0x52817f9978c13738)}; + std::stringstream ss; + ss << uuid1; + EXPECT_THAT(ss.str(), Eq("0b6ed04c-a16d-fc46-5281-7f9978c13738")); +} + +struct MakeUuidTestParam { + std::string name; + std::string input; + uint64_t expected_high_bits; + uint64_t expected_low_bits; + absl::optional error; +}; +class MakeUuidTest : public ::testing::TestWithParam {}; + +TEST_P(MakeUuidTest, MakeUuidFromString) { + Uuid expected_uuid{absl::MakeUint128(GetParam().expected_high_bits, + GetParam().expected_low_bits)}; + StatusOr actual_uuid = MakeUuid(GetParam().input); + if (GetParam().error.has_value()) { + EXPECT_THAT(actual_uuid.status(), + StatusIs(GetParam().error->code(), + HasSubstr(GetParam().error->message()))); + } else { + EXPECT_THAT(actual_uuid, IsOkAndHolds(expected_uuid)); + } +} + +INSTANTIATE_TEST_SUITE_P( + MakeUuid, MakeUuidTest, + testing::Values( + // Error Paths + MakeUuidTestParam{"Empty", "", 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "UUID must contain 32 hexadecimal digits"}}, + MakeUuidTestParam{"EmptyCurlyBraces", "{}", 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "UUID must contain 32 hexadecimal digits"}}, + MakeUuidTestParam{ + "MissingClosingCurlyBrace", "{0b6ed04ca16dfc4652817f9978c13738", + 0x0, 0x0, + Status{ + StatusCode::kInvalidArgument, + "UUID missing closing '}': {0b6ed04ca16dfc4652817f9978c13738"}}, + MakeUuidTestParam{ + "MissingOpeningCurlyBrace", "0b6ed04ca16dfc4652817f9978c13738}", + 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "Extra characters found after parsing UUID: }"}}, + MakeUuidTestParam{"StartsWithInvalidHyphen", + "-0b6ed04ca16dfc4652817f9978c13738", 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "UUID cannot begin with '-':"}}, + MakeUuidTestParam{"ContainsInvalidCharacter", + "0b6ed04ca16dfc4652817f9978c1373g", 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "UUID contains invalid character (g):"}}, + MakeUuidTestParam{"ContainsConsecutiveHyphens", + "0b--6ed04ca16dfc4652817f9978c13738", 0x0, 0x0, + Status{StatusCode::kInvalidArgument, + "UUID cannot contain consecutive hyphens: " + "0b--6ed04ca16dfc4652817f9978c13738"}}, + MakeUuidTestParam{"InsufficientDigits", + "00-00-00-00-00-00-00-00-00-00-00-00-00-00-00", 0x0, + 0x0, + Status{StatusCode::kInvalidArgument, + "UUID must contain 32 hexadecimal digits:"}}, + // Success Paths + MakeUuidTestParam{ + "Zero", "00000000000000000000000000000000", 0x0, 0x0, {}}, + MakeUuidTestParam{"CurlyBraced", + "{0b6ed04ca16dfc4652817f9978c13738}", + 0x0b6ed04ca16dfc46, + 0x52817f9978c13738, + {}}, + MakeUuidTestParam{ + "CurlyBracedFullyHyphenated", + "{0-b-6-e-d-0-4-c-a-1-6-d-f-c-4-6-5-2-8-1-7-f-9-9-7-8-c-1-3-7-3-8}", + 0x0b6ed04ca16dfc46, + 0x52817f9978c13738, + {}}, + MakeUuidTestParam{"CurlyBracedConventionallyHyphenated", + "{0b6ed04c-a16d-fc46-5281-7f9978c13738}", + 0x0b6ed04ca16dfc46, + 0x52817f9978c13738, + {}}, + MakeUuidTestParam{"MixedCase16BitHyphenSeparated", + "7Bf8-A7b8-1917-1919-2625-F208-c582-4254", + 0x7Bf8A7b819171919, + 0x2625F208c5824254, + {}}, + MakeUuidTestParam{"AllUpperCase", + "{DECAFBAD-DEAD-FADE-CAFE-FEEDFACEBEEF}", + 0xdecafbaddeadfade, + 0xcafefeedfacebeef, + {}}), + [](testing::TestParamInfo const& info) { + return info.param.name; + }); + +TEST(UuidTest, RoundTripStringRepresentation) { + Uuid expected_uuid{absl::MakeUint128(0x7bf8a7b819171919, 0x2625f208c5824254)}; + auto str = static_cast(expected_uuid); + auto actual_uuid = MakeUuid(str); + EXPECT_THAT(actual_uuid, IsOkAndHolds(expected_uuid)); +} + +} // namespace +GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END +} // namespace spanner +} // namespace cloud +} // namespace google