diff --git a/core/operations/management/collection_create.cxx b/core/operations/management/collection_create.cxx index 3ffaa9e8d..d2624bb58 100644 --- a/core/operations/management/collection_create.cxx +++ b/core/operations/management/collection_create.cxx @@ -34,8 +34,12 @@ collection_create_request::encode_to(encoded_request_type& encoded, http_context encoded.path = fmt::format("/pools/default/buckets/{}/scopes/{}/collections", bucket_name, scope_name); encoded.headers["content-type"] = "application/x-www-form-urlencoded"; encoded.body = fmt::format("name={}", utils::string_codec::form_encode(collection_name)); - if (max_expiry > 0) { - encoded.body.append(fmt::format("&maxTTL={}", max_expiry)); + if (max_expiry >= -1) { + if (max_expiry != 0) { + encoded.body.append(fmt::format("&maxTTL={}", max_expiry)); + } + } else { + return couchbase::errc::common::invalid_argument; } if (history.has_value()) { encoded.body.append(fmt::format("&history={}", history.value())); diff --git a/core/operations/management/collection_create.hxx b/core/operations/management/collection_create.hxx index 1f238fbbb..f16e71607 100644 --- a/core/operations/management/collection_create.hxx +++ b/core/operations/management/collection_create.hxx @@ -41,7 +41,7 @@ struct collection_create_request { std::string bucket_name; std::string scope_name; std::string collection_name; - std::uint32_t max_expiry{ 0 }; + std::int32_t max_expiry{ 0 }; std::optional history{}; std::optional client_context_id{}; diff --git a/core/operations/management/collection_update.cxx b/core/operations/management/collection_update.cxx index 25466a706..86553ad85 100644 --- a/core/operations/management/collection_update.cxx +++ b/core/operations/management/collection_update.cxx @@ -35,7 +35,11 @@ collection_update_request::encode_to(encoded_request_type& encoded, http_context encoded.headers["content-type"] = "application/x-www-form-urlencoded"; std::map values{}; if (max_expiry.has_value()) { - values["maxTTL"] = std::to_string(max_expiry.value()); + if (max_expiry.value() >= -1) { + values["maxTTL"] = std::to_string(max_expiry.value()); + } else { + return errc::common::invalid_argument; + } } if (history.has_value()) { values["history"] = history.value() ? "true" : "false"; diff --git a/core/operations/management/collection_update.hxx b/core/operations/management/collection_update.hxx index 347538e22..837d67730 100644 --- a/core/operations/management/collection_update.hxx +++ b/core/operations/management/collection_update.hxx @@ -41,7 +41,7 @@ struct collection_update_request { std::string bucket_name; std::string scope_name; std::string collection_name; - std::optional max_expiry{}; + std::optional max_expiry{}; std::optional history{}; std::optional client_context_id{}; diff --git a/core/topology/collections_manifest.hxx b/core/topology/collections_manifest.hxx index ac599bb92..36e6f172c 100644 --- a/core/topology/collections_manifest.hxx +++ b/core/topology/collections_manifest.hxx @@ -28,7 +28,7 @@ struct collections_manifest { struct collection { std::uint64_t uid; std::string name; - std::uint32_t max_expiry{ 0 }; + std::int32_t max_expiry{ 0 }; std::optional history{}; }; diff --git a/core/topology/collections_manifest_json.hxx b/core/topology/collections_manifest_json.hxx index a963b6d64..70860147d 100644 --- a/core/topology/collections_manifest_json.hxx +++ b/core/topology/collections_manifest_json.hxx @@ -41,7 +41,7 @@ struct traits { collection.uid = std::stoull(c.at("uid").get_string(), nullptr, 16); collection.name = c.at("name").get_string(); if (const auto* max_ttl = c.find("maxTTL"); max_ttl != nullptr) { - collection.max_expiry = max_ttl->template as(); + collection.max_expiry = max_ttl->template as(); } if (const auto* history = c.find("history"); history != nullptr) { collection.history = history->template as>(); diff --git a/couchbase/create_collection_options.hxx b/couchbase/create_collection_options.hxx index e3b340b3d..61a11d292 100644 --- a/couchbase/create_collection_options.hxx +++ b/couchbase/create_collection_options.hxx @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -27,17 +28,44 @@ namespace couchbase { struct create_collection_options : public common_options { public: + /** + * Immutable value object representing consistent options. + * + * @since 1.0.0 + * @internal + */ struct built : public common_options::built { }; + /** + * Validates the options and returns them as an immutable value. + * + * @return consistent options as an immutable value + * + * @exception std::system_error with code errc::common::invalid_argument if the options are not valid + * + * @since 1.0.0 + * @internal + */ [[nodiscard]] auto build() const -> built { return { build_common_options() }; } }; +/** + * The settings to use when creating the collection + */ struct create_collection_settings { - std::uint32_t max_expiry{ 0 }; + /** + * The maximum expiry, in seconds, for documents in this collection. Values greater than or equal to -1 are valid. + * Value of 0 sets max_expiry to the bucket-level setting and value of -1 to set it as no-expiry. + */ + std::int32_t max_expiry{ 0 }; + + /** + * Whether history retention should be enabled. If unset, the bucket-level setting is used. + */ std::optional history{}; }; diff --git a/couchbase/management/collection_spec.hxx b/couchbase/management/collection_spec.hxx index ac406df0c..325f3f677 100644 --- a/couchbase/management/collection_spec.hxx +++ b/couchbase/management/collection_spec.hxx @@ -22,7 +22,7 @@ namespace couchbase::management::bucket struct collection_spec { std::string name; std::string scope_name; - std::uint32_t max_expiry{}; + std::int32_t max_expiry{}; std::optional history{}; }; diff --git a/couchbase/update_collection_options.hxx b/couchbase/update_collection_options.hxx index f31422586..cb4e95779 100644 --- a/couchbase/update_collection_options.hxx +++ b/couchbase/update_collection_options.hxx @@ -17,26 +17,55 @@ #pragma once -#include +#include +#include + +#include #include #include -#include namespace couchbase { struct update_collection_options : public common_options { public: + /** + * Immutable value object representing consistent options. + * + * @since 1.0.0 + * @internal + */ struct built : public common_options::built { }; + /** + * Validates the options and returns them as an immutable value. + * + * @return consistent options as an immutable value + * + * @exception std::system_error with code errc::common::invalid_argument if the options are not valid + * + * @since 1.0.0 + * @internal + */ [[nodiscard]] auto build() const -> built { return { build_common_options() }; } }; +/** + * The settings that should be updated for the collection + */ struct update_collection_settings { - std::optional max_expiry{}; + /** + * The maximum expiry, in seconds, for documents in this collection. Values greater than or equal to -1 are valid. + * Value of 0 sets max_expiry to the bucket-level setting and value of -1 to set it as no-expiry. + */ + std::optional max_expiry{}; + + /** + * Whether history retention should be enabled. + */ std::optional history{}; }; diff --git a/test/test_integration_management.cxx b/test/test_integration_management.cxx index 6762ee079..027f1ca17 100644 --- a/test/test_integration_management.cxx +++ b/test/test_integration_management.cxx @@ -1017,6 +1017,28 @@ get_collection(const couchbase::core::cluster& cluster, return std::nullopt; } +std::error_code +create_collection(const couchbase::core::cluster& cluster, + const std::string& bucket_name, + const std::string& scope_name, + const std::string& collection_name) +{ + couchbase::core::operations::management::collection_create_request req{ bucket_name, scope_name, collection_name }; + auto resp = test::utils::execute(cluster, req); + return resp.ctx.ec; +} + +std::error_code +drop_collection(const couchbase::core::cluster& cluster, + const std::string& bucket_name, + const std::string& scope_name, + const std::string& collection_name) +{ + couchbase::core::operations::management::collection_drop_request req{ bucket_name, scope_name, collection_name }; + auto resp = test::utils::execute(cluster, req); + return resp.ctx.ec; +} + bool scope_exists(const couchbase::core::cluster& cluster, const std::string& bucket_name, const std::string& scope_name) { @@ -1043,7 +1065,7 @@ TEST_CASE("integration: collection management", "[integration]") auto scope_name = test::utils::uniq_id("scope"); auto collection_name = test::utils::uniq_id("collection"); - std::uint32_t max_expiry = 5; + std::int32_t max_expiry = 5; SECTION("core api") { { @@ -1231,6 +1253,292 @@ TEST_CASE("integration: collection management", "[integration]") } } +TEST_CASE("integration: collection management create collection with max expiry", "[integration]") +{ + test::utils::integration_test_guard integration; + test::utils::open_bucket(integration.cluster, integration.ctx.bucket); + + if (!integration.cluster_version().supports_collections()) { + SKIP("cluster does not support collections"); + } + + auto scope_name = "_default"; + auto collection_name = test::utils::uniq_id("collection"); + + couchbase::cluster c(integration.cluster); + auto manager = c.bucket(integration.ctx.bucket).collections(); + + SECTION("default max expiry") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_create_request req{ integration.ctx.bucket, scope_name, collection_name }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + auto ctx = manager.create_collection(scope_name, collection_name).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == 0); + } + + SECTION("positive max expiry") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_create_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + 3600, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + couchbase::create_collection_settings settings{ 3600 }; + auto ctx = manager.create_collection(scope_name, collection_name, settings).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == 3600); + } + + SECTION("setting max expiry to no-expiry") + { + if (integration.cluster_version().supports_collection_set_max_expiry_to_no_expiry()) { + SECTION("core API") + { + couchbase::core::operations::management::collection_create_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -1, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + couchbase::create_collection_settings settings{ -1 }; + auto ctx = manager.create_collection(scope_name, collection_name, settings).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == -1); + } else { + SECTION("core API") + { + couchbase::core::operations::management::collection_create_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -1, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE(resp.ctx.ec == couchbase::errc::common::invalid_argument); + } + + SECTION("public API") + { + couchbase::create_collection_settings settings{ -1 }; + auto ctx = manager.create_collection(scope_name, collection_name, settings).get(); + REQUIRE(ctx.ec() == couchbase::errc::common::invalid_argument); + } + } + } + + SECTION("invalid max expiry") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_create_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -20, + }; + + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE(resp.ctx.ec == couchbase::errc::common::invalid_argument); + } + + SECTION("public API") + { + couchbase::create_collection_settings settings{ -20 }; + auto ctx = manager.create_collection(scope_name, collection_name, settings).get(); + REQUIRE(ctx.ec() == couchbase::errc::common::invalid_argument); + } + } + + // Clean up the collection that was created + { + auto ec = drop_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE((!ec || ec == couchbase::errc::common::collection_not_found)); + } +} + +TEST_CASE("integration: collection management update collection with max expiry", "[integration]") +{ + test::utils::integration_test_guard integration; + test::utils::open_bucket(integration.cluster, integration.ctx.bucket); + + if (!integration.cluster_version().supports_collections()) { + SKIP("cluster does not support collections"); + } + if (!integration.cluster_version().supports_collection_update_max_expiry()) { + SKIP("cluster does not support updating the max expiry of collections"); + } + + auto scope_name = "_default"; + auto collection_name = test::utils::uniq_id("collection"); + + { + auto ec = create_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE_SUCCESS(ec); + } + + couchbase::cluster c(integration.cluster); + auto manager = c.bucket(integration.ctx.bucket).collections(); + + SECTION("zero max expiry (bucket-level default)") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_update_request req{ + integration.ctx.bucket, scope_name, collection_name, 0 + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + couchbase::update_collection_settings settings{ 0 }; + auto ctx = manager.update_collection(scope_name, collection_name, settings).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == 0); + } + + SECTION("positive max expiry") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_update_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + 3600, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + couchbase::update_collection_settings settings{ 3600 }; + auto ctx = manager.update_collection(scope_name, collection_name, settings).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == 3600); + } + + SECTION("setting max expiry to no-expiry") + { + if (integration.cluster_version().supports_collection_set_max_expiry_to_no_expiry()) { + SECTION("core API") + { + couchbase::core::operations::management::collection_update_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -1, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE_SUCCESS(resp.ctx.ec); + } + + SECTION("public API") + { + couchbase::update_collection_settings settings{ -1 }; + auto ctx = manager.update_collection(scope_name, collection_name, settings).get(); + REQUIRE_SUCCESS(ctx.ec()); + } + + auto coll = get_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE(coll); + REQUIRE(coll.value().max_expiry == -1); + } else { + SECTION("core API") + { + couchbase::core::operations::management::collection_update_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -1, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE(resp.ctx.ec == couchbase::errc::common::invalid_argument); + } + + SECTION("public API") + { + couchbase::update_collection_settings settings{ -1 }; + auto ctx = manager.update_collection(scope_name, collection_name, settings).get(); + REQUIRE(ctx.ec() == couchbase::errc::common::invalid_argument); + } + } + } + + SECTION("invalid max expiry") + { + SECTION("core API") + { + couchbase::core::operations::management::collection_update_request req{ + integration.ctx.bucket, + scope_name, + collection_name, + -20, + }; + auto resp = test::utils::execute(integration.cluster, req); + REQUIRE(resp.ctx.ec == couchbase::errc::common::invalid_argument); + } + + SECTION("public API") + { + couchbase::update_collection_settings settings{ -20 }; + auto ctx = manager.update_collection(scope_name, collection_name, settings).get(); + REQUIRE(ctx.ec() == couchbase::errc::common::invalid_argument); + } + } + + { + // Clean up the collection that was created + auto ec = drop_collection(integration.cluster, integration.ctx.bucket, scope_name, collection_name); + REQUIRE((!ec || ec == couchbase::errc::common::collection_not_found)); + } +} + TEST_CASE("integration: collection management bucket dedup", "[integration]") { test::utils::integration_test_guard integration; diff --git a/test/utils/server_version.hxx b/test/utils/server_version.hxx index ebc981d92..41a8f9c78 100644 --- a/test/utils/server_version.hxx +++ b/test/utils/server_version.hxx @@ -226,6 +226,16 @@ struct server_version { return !use_gocaves && (major > 7 || (major == 7 && minor >= 6)); } + [[nodiscard]] bool supports_collection_update_max_expiry() const + { + return !use_gocaves && (major > 7 || (major == 7 && minor >= 6)); + } + + [[nodiscard]] bool supports_collection_set_max_expiry_to_no_expiry() const + { + return !use_gocaves && (major > 7 || (major == 7 && minor >= 6)); + } + [[nodiscard]] bool is_capella() const { return deployment == deployment_type::capella;