diff --git a/sdk/tables/azure-data-tables/CHANGELOG.md b/sdk/tables/azure-data-tables/CHANGELOG.md index 31ede29df4..be0d5eb5c3 100644 --- a/sdk/tables/azure-data-tables/CHANGELOG.md +++ b/sdk/tables/azure-data-tables/CHANGELOG.md @@ -8,6 +8,12 @@ ### Bugs Fixed +- Fixed multipart transaction framing, the `InsertReplace` request method and body, and `UpdateMerge` ETag conditions. +- Fixed `UpdateEntity` and `MergeEntity` to send `If-Match: *` when the entity's ETag is empty, rather than performing an upsert. +- Preserved native JSON Boolean, Int32, and Double property types when responses omit OData type annotations. +- Preserved query options and key selectors across pages and supported partition-only continuation tokens. +- Escaped single quotes in OData entity keys before URL encoding for point, query, and transaction requests. + ### Other Changes ## 1.0.0-beta.7 (2026-11-04) diff --git a/sdk/tables/azure-data-tables/inc/azure/data/tables/models.hpp b/sdk/tables/azure-data-tables/inc/azure/data/tables/models.hpp index f6d9bc6c47..ff5ba5aff7 100644 --- a/sdk/tables/azure-data-tables/inc/azure/data/tables/models.hpp +++ b/sdk/tables/azure-data-tables/inc/azure/data/tables/models.hpp @@ -453,6 +453,9 @@ namespace Azure { namespace Data { namespace Tables { std::string Value; /** * Property type. + * When deserializing, an explicit OData type annotation takes precedence; otherwise, native + * JSON booleans, integers, and floating-point numbers infer Edm.Boolean, Edm.Int32, and + * Edm.Double respectively. Unannotated strings leave the type unset. */ Azure::Nullable Type; }; diff --git a/sdk/tables/azure-data-tables/inc/azure/data/tables/table_client.hpp b/sdk/tables/azure-data-tables/inc/azure/data/tables/table_client.hpp index ffb704bdb5..f8489744fb 100644 --- a/sdk/tables/azure-data-tables/inc/azure/data/tables/table_client.hpp +++ b/sdk/tables/azure-data-tables/inc/azure/data/tables/table_client.hpp @@ -107,6 +107,9 @@ namespace Azure { namespace Data { namespace Tables { /** * @brief Update entity in a table. * + * @note An empty ETag updates the existing entity unconditionally. Use UpdateOrInsertEntity + * to insert the entity if it does not exist. + * * @param tableEntity The TableEntity to set. * @param context for canceling long running operations. * @return Update entity result. @@ -118,6 +121,9 @@ namespace Azure { namespace Data { namespace Tables { /** * @brief Merge entity in a table. * + * @note An empty ETag merges the existing entity unconditionally. Use MergeOrInsertEntity + * to insert the entity if it does not exist. + * * @param tableEntity The TableEntity to merge. * @param context for canceling long running operations. * @return Merge entity result. @@ -221,6 +227,10 @@ namespace Azure { namespace Data { namespace Tables { std::string PrepAddEntity(std::string const& changesetId, Models::TableEntity entity) const; std::string PrepDeleteEntity(std::string const& changesetId, Models::TableEntity entity) const; std::string PrepMergeEntity(std::string const& changesetId, Models::TableEntity entity) const; + std::string PrepMergeEntity( + std::string const& changesetId, + Models::TableEntity entity, + bool isUpsert) const; std::string PrepUpdateEntity(std::string const& changesetId, Models::TableEntity entity) const; std::string PrepInsertEntity(std::string const& changesetId, Models::TableEntity entity) const; std::shared_ptr m_pipeline; diff --git a/sdk/tables/azure-data-tables/src/serializers.cpp b/sdk/tables/azure-data-tables/src/serializers.cpp index ca49f08ff8..a0cc16397f 100644 --- a/sdk/tables/azure-data-tables/src/serializers.cpp +++ b/sdk/tables/azure-data-tables/src/serializers.cpp @@ -555,6 +555,18 @@ namespace Azure { namespace Data { namespace Tables { namespace _detail { value, static_cast(type)); erasable.push_back(typeFieldName); } + else if (json.at(name).is_boolean()) + { + tableEntity.Properties[name] = TableEntityProperty(value, TableEntityDataType::EdmBoolean); + } + else if (json.at(name).is_number_integer()) + { + tableEntity.Properties[name] = TableEntityProperty(value, TableEntityDataType::EdmInt32); + } + else if (json.at(name).is_number_float()) + { + tableEntity.Properties[name] = TableEntityProperty(value, TableEntityDataType::EdmDouble); + } else { tableEntity.Properties[name] = TableEntityProperty(value); diff --git a/sdk/tables/azure-data-tables/src/table_clients.cpp b/sdk/tables/azure-data-tables/src/table_clients.cpp index 562c68215c..be72795332 100644 --- a/sdk/tables/azure-data-tables/src/table_clients.cpp +++ b/sdk/tables/azure-data-tables/src/table_clients.cpp @@ -10,6 +10,7 @@ #include "private/serializers.hpp" #include "private/tables_constants.hpp" +#include #include #include @@ -18,6 +19,28 @@ using namespace Azure::Data::Tables::_detail::Policies; using namespace Azure::Data::Tables::_detail::Xml; using namespace Azure::Data::Tables::_detail; +namespace { +// OData string literals are single-quoted, with embedded apostrophes doubled. +// Escape the contents before URL encoding; callers add the surrounding quotes. +// https://learn.microsoft.com/rest/api/storageservices/querying-tables-and-entities#single-quote- +std::string EscapeODataString(std::string const& value) +{ + std::string escaped; + escaped.reserve( + value.size() + + static_cast(std::count(value.begin(), value.end(), '\''))); + for (auto const c : value) + { + if (c == '\'') + { + escaped += '\''; + } + escaped += c; + } + return escaped; +} +} // namespace + namespace Azure { namespace Data { namespace Tables { namespace _detail { std::string GetDefaultScopeForAudience(const std::string& audience = "") { @@ -470,9 +493,11 @@ Azure::Response TableClient::UpdateEntityImpl( Core::Context const& context) const { auto url = m_url; - url.AppendPath(Azure::Core::Url::Encode( - m_tableName + PartitionKeyFragment + tableEntity.GetPartitionKey().Value + RowKeyFragment - + tableEntity.GetRowKey().Value + ClosingFragment)); + url.AppendPath( + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + + EscapeODataString(tableEntity.GetPartitionKey().Value) + RowKeyFragment + + EscapeODataString(tableEntity.GetRowKey().Value) + ClosingFragment)); std::string jsonBody = Serializers::UpdateEntity(tableEntity); @@ -486,9 +511,10 @@ Azure::Response TableClient::UpdateEntityImpl( request.SetHeader(AcceptHeader, AcceptFullMeta); request.SetHeader(PreferHeader, PreferNoContent); - if (!isUpsert && !tableEntity.GetETag().Value.empty()) + if (!isUpsert) { - request.SetHeader(IfMatch, tableEntity.GetETag().Value); + auto const etag = tableEntity.GetETag().Value; + request.SetHeader(IfMatch, etag.empty() ? "*" : etag); } auto rawResponse = m_pipeline->Send(request, context); @@ -510,9 +536,11 @@ Azure::Response TableClient::MergeEntityImpl( Core::Context const& context) const { auto url = m_url; - url.AppendPath(Azure::Core::Url::Encode( - m_tableName + PartitionKeyFragment + tableEntity.GetPartitionKey().Value + RowKeyFragment - + tableEntity.GetRowKey().Value + ClosingFragment)); + url.AppendPath( + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + + EscapeODataString(tableEntity.GetPartitionKey().Value) + RowKeyFragment + + EscapeODataString(tableEntity.GetRowKey().Value) + ClosingFragment)); std::string jsonBody = Serializers::MergeEntity(tableEntity); @@ -526,9 +554,10 @@ Azure::Response TableClient::MergeEntityImpl( request.SetHeader(AcceptHeader, AcceptFullMeta); request.SetHeader(PreferHeader, PreferNoContent); - if (!isUpsert && !tableEntity.GetETag().Value.empty()) + if (!isUpsert) { - request.SetHeader(IfMatch, tableEntity.GetETag().Value); + auto const etag = tableEntity.GetETag().Value; + request.SetHeader(IfMatch, etag.empty() ? "*" : etag); } auto rawResponse = m_pipeline->Send(request, context); @@ -549,9 +578,11 @@ Azure::Response TableClient::DeleteEntity( Core::Context const& context) const { auto url = m_url; - url.AppendPath(Azure::Core::Url::Encode( - m_tableName + PartitionKeyFragment + tableEntity.GetPartitionKey().Value + RowKeyFragment - + tableEntity.GetRowKey().Value + ClosingFragment)); + url.AppendPath( + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + + EscapeODataString(tableEntity.GetPartitionKey().Value) + RowKeyFragment + + EscapeODataString(tableEntity.GetRowKey().Value) + ClosingFragment)); Core::Http::Request request(Core::Http::HttpMethod::Delete, url); @@ -616,9 +647,10 @@ Azure::Response TableClient::GetEntity( Core::Context const& context) const { auto url = m_url; - url.AppendPath(Azure::Core::Url::Encode( - m_tableName + PartitionKeyFragment + partitionKey + RowKeyFragment + rowKey - + ClosingFragment)); + url.AppendPath( + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + EscapeODataString(partitionKey) + RowKeyFragment + + EscapeODataString(rowKey) + ClosingFragment)); Core::Http::Request request(Core::Http::HttpMethod::Get, url); request.SetHeader(AcceptHeader, AcceptFullMeta); @@ -647,27 +679,27 @@ Models::QueryEntitiesPagedResponse TableClient::QueryEntities( Core::Context const& context) const { auto url = m_url; - if (!options.NextPartitionKey.empty() && !options.NextRowKey.empty()) + std::string appendPath = Azure::Core::Url::Encode(m_tableName) + "("; + if (!options.PartitionKey.empty()) { - url.AppendPath(Azure::Core::Url::Encode(m_tableName)); - url.AppendQueryParameter( - "NextPartitionKey", Azure::Core::Url::Encode(options.NextPartitionKey)); - url.AppendQueryParameter("NextRowKey", Azure::Core::Url::Encode(options.NextRowKey)); + appendPath += "PartitionKey='" + + Azure::Core::Url::Encode(EscapeODataString(options.PartitionKey)) + "'"; } - else + if (!options.RowKey.empty()) { - std::string appendPath = Azure::Core::Url::Encode(m_tableName) + "("; - if (!options.PartitionKey.empty()) - { - appendPath += "PartitionKey='" + Azure::Core::Url::Encode(options.PartitionKey) + "'"; - } - if (!options.RowKey.empty()) + appendPath += ",RowKey='" + Azure::Core::Url::Encode(EscapeODataString(options.RowKey)) + "'"; + } + appendPath += ")"; + url.AppendPath(appendPath); + + if (!options.NextPartitionKey.empty()) + { + url.AppendQueryParameter( + "NextPartitionKey", Azure::Core::Url::Encode(options.NextPartitionKey)); + if (!options.NextRowKey.empty()) { - appendPath += ",RowKey='" + Azure::Core::Url::Encode(options.RowKey) + "'"; + url.AppendQueryParameter("NextRowKey", Azure::Core::Url::Encode(options.NextRowKey)); } - appendPath += ")"; - - url.AppendPath(appendPath); } if (options.Filter.HasValue()) @@ -690,6 +722,7 @@ Models::QueryEntitiesPagedResponse TableClient::QueryEntities( } Models::QueryEntitiesPagedResponse response(std::make_shared(*this)); + response.m_operationOptions = options; { const auto& responseBody = rawResponse->GetBody(); @@ -802,7 +835,7 @@ std::string TableClient::PreparePayload( std::vector const& steps) const { std::string accumulator - = "--" + batchId + "\nContent-Type: multipart/mixed; boundary=" + changesetId + "\n\n"; + = "--" + batchId + "\r\nContent-Type: multipart/mixed; boundary=" + changesetId + "\r\n\r\n"; for (auto step : steps) { @@ -815,9 +848,11 @@ std::string TableClient::PreparePayload( accumulator += PrepDeleteEntity(changesetId, step.Entity); break; case Models::TransactionActionType::InsertMerge: - case Models::TransactionActionType::UpdateMerge: accumulator += PrepMergeEntity(changesetId, step.Entity); break; + case Models::TransactionActionType::UpdateMerge: + accumulator += PrepMergeEntity(changesetId, step.Entity, false); + break; case Models::TransactionActionType::InsertReplace: accumulator += PrepInsertEntity(changesetId, step.Entity); break; @@ -825,25 +860,26 @@ std::string TableClient::PreparePayload( accumulator += PrepUpdateEntity(changesetId, step.Entity); break; } + accumulator += "\r\n"; } - accumulator += "\n\n--" + changesetId + "--\n"; - accumulator += "--" + batchId + "\n"; + accumulator += "--" + changesetId + "--\r\n"; + accumulator += "--" + batchId + "--\r\n"; return accumulator; } std::string TableClient::PrepAddEntity(std::string const& changesetId, Models::TableEntity entity) const { - std::string returnValue = "--" + changesetId + "\n"; - returnValue += "Content-Type: application/http\n"; - returnValue += "Content-Transfer-Encoding: binary\n\n"; + std::string returnValue = "--" + changesetId + "\r\n"; + returnValue += "Content-Type: application/http\r\n"; + returnValue += "Content-Transfer-Encoding: binary\r\n\r\n"; auto url = m_url; - url.AppendPath(m_tableName); - returnValue += "POST " + url.GetAbsoluteUrl() + " HTTP/1.1\n"; - returnValue += "Content-Type: application/json\n"; - returnValue += "Accept: application/json;odata=minimalmetadata\n"; - returnValue += "Prefer: return-no-content\n"; - returnValue += "DataServiceVersion: 3.0;\n\n"; + url.AppendPath(Azure::Core::Url::Encode(m_tableName)); + returnValue += "POST " + url.GetAbsoluteUrl() + " HTTP/1.1\r\n"; + returnValue += "Content-Type: application/json\r\n"; + returnValue += "Accept: application/json;odata=minimalmetadata\r\n"; + returnValue += "Prefer: return-no-content\r\n"; + returnValue += "DataServiceVersion: 3.0;\r\n\r\n"; returnValue += Serializers::CreateEntity(entity); return returnValue; } @@ -851,17 +887,18 @@ std::string TableClient::PrepDeleteEntity( std::string const& changesetId, Models::TableEntity entity) const { - std::string returnValue = "--" + changesetId + "\n"; - returnValue += "Content-Type: application/http\n"; - returnValue += "Content-Transfer-Encoding: binary\n\n"; + std::string returnValue = "--" + changesetId + "\r\n"; + returnValue += "Content-Type: application/http\r\n"; + returnValue += "Content-Transfer-Encoding: binary\r\n\r\n"; auto url = m_url; url.AppendPath( - m_tableName + PartitionKeyFragment + entity.GetPartitionKey().Value + RowKeyFragment - + entity.GetRowKey().Value + ClosingFragment); - returnValue += "DELETE " + url.GetAbsoluteUrl() + " HTTP/1.1\n"; - returnValue += "Accept: application/json;odata=minimalmetadata\n"; - returnValue += "Prefer: return-no-content\n"; - returnValue += "DataServiceVersion: 3.0;\n"; + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + EscapeODataString(entity.GetPartitionKey().Value) + + RowKeyFragment + EscapeODataString(entity.GetRowKey().Value) + ClosingFragment)); + returnValue += "DELETE " + url.GetAbsoluteUrl() + " HTTP/1.1\r\n"; + returnValue += "Accept: application/json;odata=minimalmetadata\r\n"; + returnValue += "Prefer: return-no-content\r\n"; + returnValue += "DataServiceVersion: 3.0;\r\n"; if (!entity.GetETag().Value.empty()) { returnValue += "If-Match: " + entity.GetETag().Value; @@ -870,24 +907,39 @@ std::string TableClient::PrepDeleteEntity( { returnValue += "If-Match: *"; } - returnValue += "\n"; + returnValue += "\r\n\r\n"; return returnValue; } std::string TableClient::PrepMergeEntity(std::string const& changesetId, Models::TableEntity entity) const { - std::string returnValue = "--" + changesetId + "\n"; - returnValue += "Content-Type: application/http\n"; - returnValue += "Content-Transfer-Encoding: binary\n\n"; + return PrepMergeEntity(changesetId, std::move(entity), true); +} + +std::string TableClient::PrepMergeEntity( + std::string const& changesetId, + Models::TableEntity entity, + bool isUpsert) const +{ + std::string returnValue = "--" + changesetId + "\r\n"; + returnValue += "Content-Type: application/http\r\n"; + returnValue += "Content-Transfer-Encoding: binary\r\n\r\n"; auto url = m_url; url.AppendPath( - m_tableName + PartitionKeyFragment + entity.GetPartitionKey().Value + RowKeyFragment - + entity.GetRowKey().Value + ClosingFragment); - returnValue += "MERGE " + url.GetAbsoluteUrl() + " HTTP/1.1\n"; - returnValue += "Content-Type: application/json\n"; - returnValue += "Accept: application/json;odata=minimalmetadata\n"; - returnValue += "DataServiceVersion: 3.0;\n\n"; + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + EscapeODataString(entity.GetPartitionKey().Value) + + RowKeyFragment + EscapeODataString(entity.GetRowKey().Value) + ClosingFragment)); + returnValue += "MERGE " + url.GetAbsoluteUrl() + " HTTP/1.1\r\n"; + returnValue += "Content-Type: application/json\r\n"; + returnValue += "Accept: application/json;odata=minimalmetadata\r\n"; + returnValue += "DataServiceVersion: 3.0;\r\n"; + if (!isUpsert) + { + auto const etag = entity.GetETag().Value; + returnValue += "If-Match: " + (etag.empty() ? "*" : etag) + "\r\n"; + } + returnValue += "\r\n"; returnValue += Serializers::MergeEntity(entity); return returnValue; @@ -897,18 +949,19 @@ std::string TableClient::PrepUpdateEntity( std::string const& changesetId, Models::TableEntity entity) const { - std::string returnValue = "--" + changesetId + "\n"; - returnValue += "Content-Type: application/http\n"; - returnValue += "Content-Transfer-Encoding: binary\n\n"; + std::string returnValue = "--" + changesetId + "\r\n"; + returnValue += "Content-Type: application/http\r\n"; + returnValue += "Content-Transfer-Encoding: binary\r\n\r\n"; auto url = m_url; url.AppendPath( - m_tableName + PartitionKeyFragment + entity.GetPartitionKey().Value + RowKeyFragment - + entity.GetRowKey().Value + ClosingFragment); - returnValue += "PUT " + url.GetAbsoluteUrl() + " HTTP/1.1\n"; - returnValue += "Content-Type: application/json\n"; - returnValue += "Accept: application/json;odata=minimalmetadata\n"; - returnValue += "Prefer: return-no-content\n"; - returnValue += "DataServiceVersion: 3.0;\n"; + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + EscapeODataString(entity.GetPartitionKey().Value) + + RowKeyFragment + EscapeODataString(entity.GetRowKey().Value) + ClosingFragment)); + returnValue += "PUT " + url.GetAbsoluteUrl() + " HTTP/1.1\r\n"; + returnValue += "Content-Type: application/json\r\n"; + returnValue += "Accept: application/json;odata=minimalmetadata\r\n"; + returnValue += "Prefer: return-no-content\r\n"; + returnValue += "DataServiceVersion: 3.0;\r\n"; if (!entity.GetETag().Value.empty()) { returnValue += "If-Match: " + entity.GetETag().Value; @@ -917,7 +970,7 @@ std::string TableClient::PrepUpdateEntity( { returnValue += "If-Match: *"; } - returnValue += "\n\n"; + returnValue += "\r\n\r\n"; returnValue += Serializers::UpdateEntity(entity); return returnValue; } @@ -927,27 +980,20 @@ std::string TableClient::PrepInsertEntity( Models::TableEntity entity) const { std::string payload = Serializers::UpdateEntity(entity); - std::string returnValue = "--" + changesetId + "\n"; - returnValue += "Content-Type: application/http\n"; - returnValue += "Content-Transfer-Encoding: binary\n\n"; + std::string returnValue = "--" + changesetId + "\r\n"; + returnValue += "Content-Type: application/http\r\n"; + returnValue += "Content-Transfer-Encoding: binary\r\n\r\n"; auto url = m_url; url.AppendPath( - m_tableName + PartitionKeyFragment + entity.GetPartitionKey().Value + RowKeyFragment - + entity.GetRowKey().Value + ClosingFragment); - returnValue += "PATCH " + url.GetAbsoluteUrl() + " HTTP/1.1\n"; - returnValue += "Content-Type: application/json\n"; - returnValue += "Content-Length: " + std::to_string(payload.length()); - returnValue += "Accept: application/json;odata=minimalmetadata\n"; - returnValue += "Prefer: return-no-content\n"; - returnValue += "DataServiceVersion: 3.0;\n"; - if (!entity.GetETag().Value.empty()) - { - returnValue += "If-Match: " + entity.GetETag().Value; - } - else - { - returnValue += "If-Match: *"; - } - returnValue += "\n\n"; + Azure::Core::Url::Encode( + m_tableName + PartitionKeyFragment + EscapeODataString(entity.GetPartitionKey().Value) + + RowKeyFragment + EscapeODataString(entity.GetRowKey().Value) + ClosingFragment)); + returnValue += "PUT " + url.GetAbsoluteUrl() + " HTTP/1.1\r\n"; + returnValue += "Content-Type: application/json\r\n"; + returnValue += "Content-Length: " + std::to_string(payload.length()) + "\r\n"; + returnValue += "Accept: application/json;odata=minimalmetadata\r\n"; + returnValue += "Prefer: return-no-content\r\n"; + returnValue += "DataServiceVersion: 3.0;\r\n\r\n"; + returnValue += payload; return returnValue; } diff --git a/sdk/tables/azure-data-tables/test/ut/CMakeLists.txt b/sdk/tables/azure-data-tables/test/ut/CMakeLists.txt index 5da7427bbe..7d013f730d 100644 --- a/sdk/tables/azure-data-tables/test/ut/CMakeLists.txt +++ b/sdk/tables/azure-data-tables/test/ut/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable ( serializers_test.cpp table_client_test.cpp table_client_test.hpp + table_client_unit_test.cpp transactions_test.hpp transactions_test.cpp # Include shared test source code diff --git a/sdk/tables/azure-data-tables/test/ut/serializers_test.cpp b/sdk/tables/azure-data-tables/test/ut/serializers_test.cpp index 25a185e92e..a459e46ce0 100644 --- a/sdk/tables/azure-data-tables/test/ut/serializers_test.cpp +++ b/sdk/tables/azure-data-tables/test/ut/serializers_test.cpp @@ -3,6 +3,7 @@ #include "serializers_test.hpp" #include +#include #include #include #include @@ -161,6 +162,97 @@ namespace Azure { namespace Data { namespace Test { EXPECT_FALSE(entity.Properties.empty()); } + TEST_F(SerializersTest, DeserializeEntityInfersNativePrimitiveTypes) + { + auto const payload = R"({ + "Enabled": true, + "Disabled": false, + "Count": 42, + "Minimum": -2147483648, + "Maximum": 2147483647, + "Ratio": 1.2345678901234567, + "WholeDouble": 42.0, + "SmallDouble": 5e-324, + "BooleanString": "true", + "IntegerString": "42", + "DoubleString": "1.2345678901234567" + })"_json; + auto const entity = Serializers::DeserializeEntity(payload); + std::map const types{ + {"Enabled", TableEntityDataType::EdmBoolean}, + {"Disabled", TableEntityDataType::EdmBoolean}, + {"Count", TableEntityDataType::EdmInt32}, + {"Minimum", TableEntityDataType::EdmInt32}, + {"Maximum", TableEntityDataType::EdmInt32}, + {"Ratio", TableEntityDataType::EdmDouble}, + {"WholeDouble", TableEntityDataType::EdmDouble}, + {"SmallDouble", TableEntityDataType::EdmDouble}}; + for (auto const& expected : types) + { + SCOPED_TRACE(expected.first); + auto const& property = entity.Properties.at(expected.first); + ASSERT_TRUE(property.Type.HasValue()); + EXPECT_EQ(property.Type.Value(), expected.second); + EXPECT_EQ(property.Value, payload.at(expected.first).dump()); + } + for (auto const& name : {"BooleanString", "IntegerString", "DoubleString"}) + { + EXPECT_FALSE(entity.Properties.at(name).Type.HasValue()); + EXPECT_EQ(entity.Properties.at(name).Value, payload.at(name).get()); + } + EXPECT_EQ(entity.Properties.at("Enabled").Value, entity.Properties.at("BooleanString").Value); + EXPECT_EQ(entity.Properties.at("Count").Value, entity.Properties.at("IntegerString").Value); + EXPECT_EQ(entity.Properties.at("Ratio").Value, entity.Properties.at("DoubleString").Value); + + auto const roundTrip = Serializers::DeserializeEntity( + Core::Json::_internal::json::parse(Serializers::CreateEntity(entity))); + for (auto const& expected : types) + { + ASSERT_TRUE(roundTrip.Properties.at(expected.first).Type.HasValue()); + EXPECT_EQ(roundTrip.Properties.at(expected.first).Type.Value(), expected.second); + EXPECT_EQ( + roundTrip.Properties.at(expected.first).Value, + entity.Properties.at(expected.first).Value); + } + } + + // cspell:ignore AQIDBA + TEST_F(SerializersTest, DeserializeEntityPreservesExplicitTypes) + { + auto const payload = R"({ + "IntegralDouble": 42, + "IntegralDouble@odata.type": "Edm.Double", + "LargeInteger": "9223372036854775807", + "LargeInteger@odata.type": "Edm.Int64", + "SmallInteger": "-9223372036854775808", + "SmallInteger@odata.type": "Edm.Int64", + "Binary": "AQIDBA==", + "Binary@odata.type": "Edm.Binary", + "Date": "2026-01-02T03:04:05.1234567Z", + "Date@odata.type": "Edm.DateTime", + "Id": "12345678-1234-1234-1234-123456789abc", + "Id@odata.type": "Edm.Guid", + "NotANumber": "NaN", + "NotANumber@odata.type": "Edm.Double", + "PositiveInfinity": "Infinity", + "PositiveInfinity@odata.type": "Edm.Double", + "NegativeInfinity": "-Infinity", + "NegativeInfinity@odata.type": "Edm.Double" + })"_json; + auto const entity = Serializers::DeserializeEntity(payload); + EXPECT_EQ(entity.Properties.size(), 9U); + for (auto const& property : entity.Properties) + { + SCOPED_TRACE(property.first); + ASSERT_TRUE(property.second.Type.HasValue()); + EXPECT_EQ( + property.second.Type.Value().ToString(), + payload.at(property.first + "@odata.type").get()); + auto const& value = payload.at(property.first); + EXPECT_EQ(property.second.Value, value.is_string() ? value.get() : value.dump()); + } + } + TEST_F(SerializersTest, CreateEntity) { TableEntity entity; diff --git a/sdk/tables/azure-data-tables/test/ut/serializers_test.hpp b/sdk/tables/azure-data-tables/test/ut/serializers_test.hpp index 290e29ad8f..8a82af4e15 100644 --- a/sdk/tables/azure-data-tables/test/ut/serializers_test.hpp +++ b/sdk/tables/azure-data-tables/test/ut/serializers_test.hpp @@ -3,12 +3,12 @@ // Licensed under the MIT License. #include "../src/private/serializers.hpp" -#include "test/ut/test_base.hpp" #include +#include + namespace Azure { namespace Data { namespace Test { - class SerializersTest : public Azure::Storage::Test::StorageTest { - }; + class SerializersTest : public ::testing::Test {}; }}} // namespace Azure::Data::Test diff --git a/sdk/tables/azure-data-tables/test/ut/table_client_unit_test.cpp b/sdk/tables/azure-data-tables/test/ut/table_client_unit_test.cpp new file mode 100644 index 0000000000..55dafc9df3 --- /dev/null +++ b/sdk/tables/azure-data-tables/test/ut/table_client_unit_test.cpp @@ -0,0 +1,617 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "azure/data/tables/table_client.hpp" +#include "test/ut/test_base.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace Azure::Data::Tables; +using namespace Azure::Data::Tables::Models; +using Azure::Core::Http::HttpStatusCode; +using Azure::Core::Http::RawResponse; +using Azure::Core::Json::_internal::json; + +namespace Azure { namespace Data { namespace Test { + namespace { + struct RecordedRequest + { + std::string Method; + Core::Url Url; + Core::CaseInsensitiveMap Headers; + std::string Body; + }; + + struct BatchRequest + { + std::string RequestLine; + Core::CaseInsensitiveMap Headers; + std::string Body; + }; + + std::string ETagTestName(std::string const& etag) + { + return etag.empty() ? "EmptyETag" : etag == "*" ? "WildcardETag" : "ConditionalETag"; + } + + std::string BatchTestName( + testing::TestParamInfo> const& info) + { + std::map const names{ + {TransactionActionType::Add, "Add"}, + {TransactionActionType::UpdateMerge, "UpdateMerge"}, + {TransactionActionType::UpdateReplace, "UpdateReplace"}, + {TransactionActionType::Delete, "Delete"}, + {TransactionActionType::InsertMerge, "InsertMerge"}, + {TransactionActionType::InsertReplace, "InsertReplace"}}; + return names.at(std::get<0>(info.param)) + ETagTestName(std::get<1>(info.param)); + } + + TableEntity MakeEntity( + std::string const& partitionKey = "partition", + std::string const& rowKey = "row") + { + TableEntity entity; + entity.SetPartitionKey(partitionKey); + entity.SetRowKey(rowKey); + entity.Properties["Name"] = TableEntityProperty("caf\xc3\xa9 \xe6\xb0\xb4"); + return entity; + } + + void ParseBatchRequest(RecordedRequest const& request, std::vector& parts) + { + EXPECT_EQ(request.Method, "POST"); + EXPECT_EQ(request.Url.GetPath(), "$batch"); + ASSERT_EQ(request.Headers.count("Content-Length"), 1U); + EXPECT_EQ(request.Headers.at("Content-Length"), std::to_string(request.Body.size())); + std::string const contentTypePrefix = "multipart/mixed; boundary="; + ASSERT_EQ(request.Headers.count("Content-Type"), 1U); + auto const& contentType = request.Headers.at("Content-Type"); + ASSERT_EQ(contentType.substr(0, contentTypePrefix.size()), contentTypePrefix); + auto const batch = contentType.substr(contentTypePrefix.size()); + auto const& body = request.Body; + for (size_t i = 0; i < body.size(); ++i) + { + if (body[i] == '\n') + { + ASSERT_GT(i, 0U); + ASSERT_EQ(body[i - 1], '\r'); + } + if (body[i] == '\r') + { + ASSERT_LT(i + 1, body.size()); + ASSERT_EQ(body[i + 1], '\n'); + } + } + + auto const prefix = "--" + batch + "\r\nContent-Type: " + contentTypePrefix; + ASSERT_EQ(body.substr(0, prefix.size()), prefix); + auto const changesetEnd = body.find("\r\n\r\n", prefix.size()); + ASSERT_NE(changesetEnd, std::string::npos); + auto const changeset = body.substr(prefix.size(), changesetEnd - prefix.size()); + auto const partPrefix = "--" + changeset + + "\r\nContent-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n\r\n"; + size_t position = changesetEnd + 4; + while (body.compare(position, partPrefix.size(), partPrefix) == 0) + { + position += partPrefix.size(); + auto const requestLineEnd = body.find("\r\n", position); + ASSERT_NE(requestLineEnd, std::string::npos); + BatchRequest part; + part.RequestLine = body.substr(position, requestLineEnd - position); + position = requestLineEnd + 2; + while (body.compare(position, 2, "\r\n") != 0) + { + auto const headerEnd = body.find("\r\n", position); + ASSERT_NE(headerEnd, std::string::npos); + auto const separator = body.find(": ", position); + ASSERT_LT(separator, headerEnd); + ASSERT_TRUE(part.Headers + .emplace( + body.substr(position, separator - position), + body.substr(separator + 2, headerEnd - separator - 2)) + .second); + position = headerEnd + 2; + } + position += 2; + auto const nextBoundary = body.find("\r\n--" + changeset, position); + ASSERT_NE(nextBoundary, std::string::npos); + part.Body = body.substr(position, nextBoundary - position); + parts.emplace_back(std::move(part)); + position = nextBoundary + 2; + } + EXPECT_EQ(body.substr(position), "--" + changeset + "--\r\n--" + batch + "--\r\n"); + } + } // namespace + + class TablesRequestTest : public ::testing::Test { + protected: + std::vector m_requests; + std::vector> m_responses; + + void QueueResponse( + HttpStatusCode status, + std::string const& body = {}, + Core::CaseInsensitiveMap const& headers = {}) + { + auto response = std::make_unique(1, 1, status, ""); + response->SetBody(std::vector(body.begin(), body.end())); + response->SetHeader("Content-Length", std::to_string(body.size())); + for (auto const& header : headers) + { + response->SetHeader(header.first, header.second); + } + m_responses.emplace_back(std::move(response)); + } + + TableClient CreateClient(TableClientOptions options = {}) + { + options.Retry.MaxRetries = 0; + options.PerRetryPolicies.emplace_back( + std::make_unique( + [this](Core::Http::Request& request, Core::Context const& context) { + std::vector body; + if (request.GetBodyStream() != nullptr) + { + body = request.GetBodyStream()->ReadToEnd(context); + } + m_requests.push_back( + {request.GetMethod().ToString(), + request.GetUrl(), + request.GetHeaders(), + std::string(body.begin(), body.end())}); + return std::move(m_responses.at(m_requests.size() - 1)); + })); + return TableClient("https://example.com", "table", options); + } + }; + + class TablesWriteTest : public TablesRequestTest, + public ::testing::WithParamInterface { + protected: + TableEntity EntityWithETag() + { + auto entity = MakeEntity(); + entity.SetETag(GetParam()); + QueueResponse(HttpStatusCode::NoContent, "", {{"ETag", "W/\"response-etag\""}}); + return entity; + } + + void CheckWrite(std::string const& method, bool hasBody, std::string const& ifMatch) + { + ASSERT_EQ(m_requests.size(), 1U); + auto const& request = m_requests[0]; + EXPECT_EQ(request.Method, method); + if (ifMatch.empty()) + { + EXPECT_EQ(request.Headers.count("If-Match"), 0U); + } + else + { + ASSERT_EQ(request.Headers.count("If-Match"), 1U); + EXPECT_EQ(request.Headers.at("If-Match"), ifMatch); + } + if (hasBody) + { + EXPECT_EQ(request.Headers.at("Content-Type"), "application/json"); + EXPECT_EQ(request.Headers.at("Content-Length"), std::to_string(request.Body.size())); + auto const body = json::parse(request.Body); + EXPECT_EQ(body.at("PartitionKey"), "partition"); + EXPECT_EQ(body.at("RowKey"), "row"); + EXPECT_EQ(body.at("Name"), "caf\xc3\xa9 \xe6\xb0\xb4"); + EXPECT_EQ(body.at("odata.etag"), GetParam()); + } + else + { + EXPECT_TRUE(request.Body.empty()); + } + } + }; + + TEST_P(TablesWriteTest, AddDoesNotSendIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().AddEntity(entity); + CheckWrite("POST", true, ""); + EXPECT_EQ(response.Value.ETag, "W/\"response-etag\""); + } + + TEST_P(TablesWriteTest, UpdateSendsIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().UpdateEntity(entity); + CheckWrite("PUT", true, GetParam().empty() ? "*" : GetParam()); + EXPECT_EQ(response.Value.ETag, "W/\"response-etag\""); + } + + TEST_P(TablesWriteTest, MergeSendsIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().MergeEntity(entity); + CheckWrite("PATCH", true, GetParam().empty() ? "*" : GetParam()); + EXPECT_EQ(response.Value.ETag, "W/\"response-etag\""); + } + + TEST_P(TablesWriteTest, DeleteSendsIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().DeleteEntity(entity); + CheckWrite("DELETE", false, GetParam().empty() ? "*" : GetParam()); + EXPECT_EQ(response.RawResponse->GetStatusCode(), HttpStatusCode::NoContent); + } + + TEST_P(TablesWriteTest, InsertReplaceDoesNotSendIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().UpdateOrInsertEntity(entity); + CheckWrite("PUT", true, ""); + EXPECT_EQ(response.Value.ETag, "W/\"response-etag\""); + } + + TEST_P(TablesWriteTest, InsertMergeDoesNotSendIfMatch) + { + auto entity = EntityWithETag(); + auto response = CreateClient().MergeOrInsertEntity(entity); + CheckWrite("PATCH", true, ""); + EXPECT_EQ(response.Value.ETag, "W/\"response-etag\""); + } + + INSTANTIATE_TEST_SUITE_P( + ETags, + TablesWriteTest, + ::testing::Values("", "*", "W/\"datetime'2026-01-02T03%3A04%3A05.1234567Z'\""), + [](testing::TestParamInfo const& info) { return ETagTestName(info.param); }); + + class TablesBatchTest + : public TablesRequestTest, + public ::testing::WithParamInterface> {}; + + TEST_P(TablesBatchTest, ActionSemantics) + { + auto const action = std::get<0>(GetParam()); + auto const& etag = std::get<1>(GetParam()); + auto entity = MakeEntity(); + entity.SetETag(etag); + QueueResponse(HttpStatusCode::Accepted); + auto response = CreateClient().SubmitTransaction({{action, entity}}); + EXPECT_EQ(response.RawResponse->GetStatusCode(), HttpStatusCode::Accepted); + ASSERT_EQ(m_requests.size(), 1U); + std::vector parts; + ParseBatchRequest(m_requests[0], parts); + ASSERT_EQ(parts.size(), 1U); + auto const& part = parts[0]; + bool const isUpdate = action == TransactionActionType::UpdateMerge + || action == TransactionActionType::UpdateReplace + || action == TransactionActionType::Delete; + if (isUpdate) + { + ASSERT_EQ(part.Headers.count("If-Match"), 1U); + EXPECT_EQ(part.Headers.at("If-Match"), etag.empty() ? "*" : etag); + } + else + { + EXPECT_EQ(part.Headers.count("If-Match"), 0U); + } + std::string method; + switch (action) + { + case TransactionActionType::Add: + method = "POST"; + break; + case TransactionActionType::Delete: + method = "DELETE"; + break; + case TransactionActionType::InsertMerge: + case TransactionActionType::UpdateMerge: + method = "MERGE"; + break; + case TransactionActionType::InsertReplace: + case TransactionActionType::UpdateReplace: + method = "PUT"; + break; + } + std::string const path = action == TransactionActionType::Add + ? "table" + : "table(PartitionKey='partition',RowKey='row')"; + EXPECT_EQ( + Core::Url::Decode(part.RequestLine), method + " https://example.com/" + path + " HTTP/1.1"); + if (action == TransactionActionType::Delete) + { + EXPECT_TRUE(part.Body.empty()); + } + else + { + EXPECT_EQ(part.Headers.at("Content-Type"), "application/json"); + auto const body = json::parse(part.Body); + EXPECT_EQ(body.at("PartitionKey"), "partition"); + EXPECT_EQ(body.at("RowKey"), "row"); + EXPECT_EQ(body.at("Name"), "caf\xc3\xa9 \xe6\xb0\xb4"); + EXPECT_EQ(body.at("odata.etag"), etag); + if (part.Headers.count("Content-Length") != 0) + { + EXPECT_EQ(part.Headers.at("Content-Length"), std::to_string(part.Body.size())); + } + } + } + + INSTANTIATE_TEST_SUITE_P( + ActionsAndETags, + TablesBatchTest, + ::testing::Combine( + ::testing::Values( + TransactionActionType::Add, + TransactionActionType::UpdateMerge, + TransactionActionType::UpdateReplace, + TransactionActionType::Delete, + TransactionActionType::InsertMerge, + TransactionActionType::InsertReplace), + ::testing::Values("", "*", "W/\"datetime'2026-01-02T03%3A04%3A05.1234567Z'\"")), + BatchTestName); + + TEST_F(TablesRequestTest, MultipleDeletesTerminateEachEmbeddedRequest) + { + QueueResponse(HttpStatusCode::Accepted); + CreateClient().SubmitTransaction( + {{TransactionActionType::Delete, MakeEntity("partition", "0")}, + {TransactionActionType::Delete, MakeEntity("partition", "1")}}); + ASSERT_EQ(m_requests.size(), 1U); + std::vector parts; + ParseBatchRequest(m_requests[0], parts); + ASSERT_EQ(parts.size(), 2U); + for (size_t i = 0; i < parts.size(); ++i) + { + EXPECT_EQ( + Core::Url::Decode(parts[i].RequestLine), + "DELETE https://example.com/table(PartitionKey='partition',RowKey='" + std::to_string(i) + + "') HTTP/1.1"); + EXPECT_EQ(parts[i].Headers.at("If-Match"), "*"); + EXPECT_TRUE(parts[i].Body.empty()); + } + } + + TEST_F(TablesRequestTest, MixedBatchPreservesOrderAndResponse) + { + std::string const responseBody + = "--batchresponse\r\nContent-Type: multipart/mixed; boundary=changesetresponse\r\n\r\n" + "--changesetresponse\r\nContent-Type: application/http\r\n\r\n" + "HTTP/1.1 204 No Content\r\nETag: W/\"first\"\r\n\r\n" + "--changesetresponse\r\nContent-Type: application/http\r\n\r\n" + "HTTP/1.1 204 No Content\r\n\r\n" + "--changesetresponse\r\nContent-Type: application/http\r\n\r\n" + "HTTP/1.1 204 No Content\r\nETag: W/\"last\"\r\n\r\n" + "--changesetresponse--\r\n--batchresponse--\r\n"; + QueueResponse(HttpStatusCode::Accepted, responseBody); + auto response = CreateClient().SubmitTransaction( + {{TransactionActionType::Add, MakeEntity("partition", "0")}, + {TransactionActionType::Delete, MakeEntity("partition", "1")}, + {TransactionActionType::InsertReplace, MakeEntity("partition", "2")}}); + ASSERT_EQ(m_requests.size(), 1U); + std::vector parts; + ParseBatchRequest(m_requests[0], parts); + ASSERT_EQ(parts.size(), 3U); + EXPECT_EQ(parts[0].RequestLine, "POST https://example.com/table HTTP/1.1"); + EXPECT_EQ(json::parse(parts[0].Body).at("RowKey"), "0"); + EXPECT_EQ( + Core::Url::Decode(parts[1].RequestLine), + "DELETE https://example.com/table(PartitionKey='partition',RowKey='1') HTTP/1.1"); + EXPECT_TRUE(parts[1].Body.empty()); + EXPECT_EQ( + Core::Url::Decode(parts[2].RequestLine), + "PUT https://example.com/table(PartitionKey='partition',RowKey='2') HTTP/1.1"); + EXPECT_EQ(json::parse(parts[2].Body).at("RowKey"), "2"); + EXPECT_EQ(response.Value.StatusCode, "204"); + EXPECT_FALSE(response.Value.Error.HasValue()); + EXPECT_EQ( + std::string(response.RawResponse->GetBody().begin(), response.RawResponse->GetBody().end()), + responseBody); + } + + TEST_F(TablesRequestTest, BatchPreservesServiceErrors) + { + std::string const responseBody + = "--batchresponse\r\nContent-Type: multipart/mixed; boundary=changesetresponse\r\n\r\n" + "--changesetresponse\r\nContent-Type: application/http\r\n\r\n" + "HTTP/1.1 412 Precondition Failed\r\nContent-Type: application/json\r\n\r\n" + "{\"odata.error\":{\"code\":\"UpdateConditionNotSatisfied\"," + "\"message\":{\"lang\":\"en-US\",\"value\":\"The condition was not satisfied.\"}}}\r\n" + "--changesetresponse--\r\n--batchresponse--\r\n"; + QueueResponse(HttpStatusCode::Accepted, responseBody); + auto response + = CreateClient().SubmitTransaction({{TransactionActionType::UpdateMerge, MakeEntity()}}); + EXPECT_EQ(response.Value.StatusCode, "412"); + ASSERT_TRUE(response.Value.Error.HasValue()); + EXPECT_EQ(response.Value.Error.Value().Code, "UpdateConditionNotSatisfied"); + EXPECT_EQ(response.Value.Error.Value().Message, "The condition was not satisfied."); + EXPECT_EQ( + std::string(response.RawResponse->GetBody().begin(), response.RawResponse->GetBody().end()), + responseBody); + } + + TEST_F(TablesRequestTest, PointOperationsEscapeODataKeys) + { + struct KeyCase + { + std::string Partition; + std::string Row; + std::string EscapedPartition; + std::string EscapedRow; + }; + std::vector const cases{ + {"", "", "", ""}, + {"o'clock", "a''b", "o''clock", "a''''b"}, + {"'", "'", "''", "''"}, + {"part \xc3\xa9%+'", "row \xe6\xb0\xb4'", "part \xc3\xa9%+''", "row \xe6\xb0\xb4''"}}; + auto client = CreateClient(); + for (auto const& testCase : cases) + { + SCOPED_TRACE(testCase.Partition + ":" + testCase.Row); + auto const start = m_requests.size(); + auto const entity = MakeEntity(testCase.Partition, testCase.Row); + QueueResponse(HttpStatusCode::Ok, "{}"); + for (int i = 0; i < 5; ++i) + { + QueueResponse(HttpStatusCode::NoContent, "", {{"ETag", "W/\"response\""}}); + } + client.GetEntity(testCase.Partition, testCase.Row); + client.UpdateEntity(entity); + client.MergeEntity(entity); + client.DeleteEntity(entity); + client.UpdateOrInsertEntity(entity); + client.MergeOrInsertEntity(entity); + ASSERT_EQ(m_requests.size(), start + 6); + auto const path = Core::Url::Encode( + "table(PartitionKey='" + testCase.EscapedPartition + "',RowKey='" + testCase.EscapedRow + + "')"); + for (auto i = start; i < m_requests.size(); ++i) + { + EXPECT_EQ(m_requests[i].Url.GetPath(), path); + } + } + } + + TEST_F(TablesRequestTest, BatchOperationsEscapeODataKeys) + { + std::vector steps; + std::vector const actions{ + TransactionActionType::UpdateMerge, + TransactionActionType::UpdateReplace, + TransactionActionType::Delete, + TransactionActionType::InsertMerge, + TransactionActionType::InsertReplace}; + for (size_t i = 0; i < actions.size(); ++i) + { + steps.push_back( + {actions[i], MakeEntity("part' \xc3\xa9", "row' \xe6\xb0\xb4" + std::to_string(i))}); + } + QueueResponse(HttpStatusCode::Accepted); + CreateClient().SubmitTransaction(steps); + ASSERT_EQ(m_requests.size(), 1U); + std::vector parts; + ParseBatchRequest(m_requests[0], parts); + ASSERT_EQ(parts.size(), actions.size()); + for (size_t i = 0; i < parts.size(); ++i) + { + auto const path = Core::Url::Encode( + "table(PartitionKey='part'' \xc3\xa9',RowKey='row'' \xe6\xb0\xb4" + std::to_string(i) + + "')"); + auto const methodEnd = parts[i].RequestLine.find(' '); + ASSERT_NE(methodEnd, std::string::npos); + EXPECT_EQ( + parts[i].RequestLine.substr(methodEnd + 1), "https://example.com/" + path + " HTTP/1.1"); + } + } + + TEST_F(TablesRequestTest, QueryPreservesOptionsAcrossPages) + { + for (bool const hasKeySelector : {false, true}) + { + SCOPED_TRACE(hasKeySelector); + auto const start = m_requests.size(); + QueueResponse( + HttpStatusCode::Ok, + R"({"value":[{"PartitionKey":"partition","RowKey":"0"}]})", + {{"x-ms-continuation-NextPartitionKey", "partition+token="}, + {"x-ms-continuation-NextRowKey", "row+token="}}); + QueueResponse( + HttpStatusCode::Ok, R"({"value":[]})", {{"x-ms-continuation-NextPartitionKey", "next"}}); + QueueResponse(HttpStatusCode::Ok, R"({"value":[{"PartitionKey":"partition","RowKey":"2"}]})"); + TableClientOptions clientOptions; + clientOptions.PerRetryPolicies.emplace_back( + std::make_unique([](Core::Http::Request& request) { + request.GetUrl().AppendQueryParameter("$top", "2"); + })); + auto client = CreateClient(clientOptions); + QueryEntitiesOptions options; + options.Filter = "Name eq 'caf\xc3\xa9'"; + options.SelectColumns = "PartitionKey,RowKey,Name"; + if (hasKeySelector) + { + options.PartitionKey = "part' \xc3\xa9"; + options.RowKey = "row' \xe6\xb0\xb4"; + } + auto page = client.QueryEntities(options); + ASSERT_TRUE(page.HasPage()); + ASSERT_EQ(page.TableEntities.size(), 1U); + EXPECT_EQ(page.TableEntities[0].GetRowKey().Value, "0"); + page.MoveToNextPage(); + ASSERT_TRUE(page.HasPage()); + EXPECT_TRUE(page.TableEntities.empty()); + page.MoveToNextPage(); + ASSERT_TRUE(page.HasPage()); + ASSERT_EQ(page.TableEntities.size(), 1U); + EXPECT_EQ(page.TableEntities[0].GetRowKey().Value, "2"); + page.MoveToNextPage(); + EXPECT_FALSE(page.HasPage()); + ASSERT_EQ(m_requests.size(), start + 3); + for (size_t i = start; i < m_requests.size(); ++i) + { + auto const& request = m_requests[i]; + EXPECT_EQ(request.Method, "GET"); + EXPECT_EQ( + Core::Url::Decode(request.Url.GetPath()), + hasKeySelector ? "table(PartitionKey='part'' \xc3\xa9',RowKey='row'' \xe6\xb0\xb4')" + : "table()"); + auto const& query = request.Url.GetQueryParameters(); + ASSERT_EQ(query.count("$filter"), 1U); + ASSERT_EQ(query.count("$select"), 1U); + EXPECT_EQ(query.at("$filter"), Core::Url::Encode(options.Filter.Value())); + EXPECT_EQ(query.at("$select"), Core::Url::Encode(options.SelectColumns)); + EXPECT_EQ(query.at("$top"), "2"); + } + auto const& first = m_requests[start].Url.GetQueryParameters(); + EXPECT_EQ(first.count("NextPartitionKey"), 0U); + EXPECT_EQ(first.count("NextRowKey"), 0U); + auto const& second = m_requests[start + 1].Url.GetQueryParameters(); + ASSERT_EQ(second.count("NextPartitionKey"), 1U); + ASSERT_EQ(second.count("NextRowKey"), 1U); + EXPECT_EQ(second.at("NextPartitionKey"), "partition%2Btoken%3D"); + EXPECT_EQ(second.at("NextRowKey"), "row%2Btoken%3D"); + auto const& third = m_requests[start + 2].Url.GetQueryParameters(); + ASSERT_EQ(third.count("NextPartitionKey"), 1U); + EXPECT_EQ(third.at("NextPartitionKey"), "next"); + EXPECT_EQ(third.count("NextRowKey"), 0U); + } + } + + TEST_F(TablesRequestTest, QueryResumesWithPartitionOnlyContinuation) + { + for (bool const emptyRowHeader : {false, true}) + { + SCOPED_TRACE(emptyRowHeader); + Core::CaseInsensitiveMap headers{{"x-ms-continuation-NextPartitionKey", "partition+token="}}; + if (emptyRowHeader) + { + headers.emplace("x-ms-continuation-NextRowKey", ""); + } + QueueResponse(HttpStatusCode::Ok, R"({"value":[]})", headers); + QueueResponse(HttpStatusCode::Ok, R"({"value":[]})"); + auto client = CreateClient(); + auto page = client.QueryEntities(); + ASSERT_TRUE(page.HasPage()); + EXPECT_TRUE(page.TableEntities.empty()); + page.MoveToNextPage(); + auto const& query = m_requests.back().Url.GetQueryParameters(); + ASSERT_EQ(query.count("NextPartitionKey"), 1U); + EXPECT_EQ(query.at("NextPartitionKey"), "partition%2Btoken%3D"); + EXPECT_EQ(query.count("NextRowKey"), 0U); + page.MoveToNextPage(); + EXPECT_FALSE(page.HasPage()); + } + QueueResponse(HttpStatusCode::Ok, R"({"value":[]})"); + QueryEntitiesOptions options; + options.NextPartitionKey = "manual+token="; + CreateClient().QueryEntities(options); + auto const& query = m_requests.back().Url.GetQueryParameters(); + ASSERT_EQ(query.count("NextPartitionKey"), 1U); + EXPECT_EQ(query.at("NextPartitionKey"), "manual%2Btoken%3D"); + EXPECT_EQ(query.count("NextRowKey"), 0U); + } +}}} // namespace Azure::Data::Test diff --git a/sdk/tables/azure-data-tables/test/ut/transactions_test.cpp b/sdk/tables/azure-data-tables/test/ut/transactions_test.cpp index e787dbe419..27ef0d4742 100644 --- a/sdk/tables/azure-data-tables/test/ut/transactions_test.cpp +++ b/sdk/tables/azure-data-tables/test/ut/transactions_test.cpp @@ -23,8 +23,8 @@ namespace Azure { namespace Data { namespace Test { EXPECT_EQ( ser, - "--batch\nContent-Type: multipart/mixed; " - "boundary=changeset\n\n\n\n--changeset--\n--batch\n"); + "--batch\r\nContent-Type: multipart/mixed; " + "boundary=changeset\r\n\r\n--changeset--\r\n--batch--\r\n"); } TEST_F(TransactionsBodyTest, TransactionBodyAddOp) @@ -124,32 +124,32 @@ namespace Azure { namespace Data { namespace Test { break; case Models::TransactionActionType::Delete: EXPECT_EQ( - lines[4], + Core::Url::Decode(lines[4]), "DELETE " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + rowKey + "') HTTP/1.1"); break; case Models::TransactionActionType::UpdateMerge: EXPECT_EQ( - lines[4], + Core::Url::Decode(lines[4]), "MERGE " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + rowKey + "') HTTP/1.1"); break; case Models::TransactionActionType::UpdateReplace: EXPECT_EQ( - lines[4], + Core::Url::Decode(lines[4]), "PUT " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + rowKey + "') HTTP/1.1"); break; case Models::TransactionActionType::InsertMerge: EXPECT_EQ( - lines[4], + Core::Url::Decode(lines[4]), "MERGE " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + rowKey + "') HTTP/1.1"); break; case Models::TransactionActionType::InsertReplace: EXPECT_EQ( - lines[4], - "PATCH " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + Core::Url::Decode(lines[4]), + "PUT " + url + "/" + tableName + "(PartitionKey='" + partitionKey + "',RowKey='" + rowKey + "') HTTP/1.1"); break; } @@ -159,31 +159,24 @@ namespace Azure { namespace Data { namespace Test { std::string const& body, Models::TransactionActionType action) { - (void)action; std::stringstream ss(body); std::string line; - std::getline(ss, line, '\n'); - - // line1 - EXPECT_EQ(line.substr(0, 8), "--batch_"); - EXPECT_EQ(line.size(), 8); - batch = line.substr(2, line.length() - 1); - // line2 - std::getline(ss, line, '\n'); - EXPECT_EQ(line, "Content-Type: multipart/mixed; boundary=changeset_1"); - changeset = line.substr(40, line.length() - 1); - - // line3 - std::getline(ss, line, '\n'); - EXPECT_EQ(line, ""); - std::vector contentLines; - std::getline(ss, line, '\n'); - while (line != "--" + batch) + std::vector lines; + while (std::getline(ss, line, '\n')) { - contentLines.push_back(line); - std::getline(ss, line, '\n'); + ASSERT_FALSE(line.empty()); + ASSERT_EQ(line.back(), '\r'); + line.pop_back(); + lines.emplace_back(std::move(line)); } + ASSERT_GE(lines.size(), 9U); + EXPECT_EQ(lines[0], "--batch_"); + batch = lines[0].substr(2); + EXPECT_EQ(lines[1], "Content-Type: multipart/mixed; boundary=changeset_1"); + changeset = "changeset_1"; + EXPECT_EQ(lines[2], ""); + std::vector contentLines(lines.begin() + 3, lines.end() - 1); CheckContentLines(contentLines, action); - EXPECT_EQ(line, "--" + batch); + EXPECT_EQ(lines.back(), "--" + batch + "--"); } }}} // namespace Azure::Data::Test diff --git a/sdk/tables/azure-data-tables/test/ut/transactions_test.hpp b/sdk/tables/azure-data-tables/test/ut/transactions_test.hpp index 78173b832c..b0b160401a 100644 --- a/sdk/tables/azure-data-tables/test/ut/transactions_test.hpp +++ b/sdk/tables/azure-data-tables/test/ut/transactions_test.hpp @@ -3,11 +3,12 @@ // Licensed under the MIT License. #include "azure/data/tables/table_client.hpp" -#include "test/ut/test_base.hpp" + +#include namespace Azure { namespace Data { namespace Test { - class TransactionsBodyTest : public Azure::Storage::Test::StorageTest { + class TransactionsBodyTest : public ::testing::Test { protected: const std::string url = "someUrl";