diff --git a/core/src/main/java/google/registry/tools/DeleteFeatureFlagCommand.java b/core/src/main/java/google/registry/tools/DeleteFeatureFlagCommand.java new file mode 100644 index 000000000..964678ecf --- /dev/null +++ b/core/src/main/java/google/registry/tools/DeleteFeatureFlagCommand.java @@ -0,0 +1,84 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// 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 +// +// http://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. + +package google.registry.tools; + +import static com.google.common.base.Preconditions.checkArgument; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import google.registry.model.common.FeatureFlag; +import java.util.List; + +/** + * Command to remove a {@link FeatureFlag} from the database entirely. + * + *

This should be used when a flag has been deprecated entirely, and we want to remove it, to + * avoid having old invalid data in the database. + * + *

This command uses the native query format so that it is able to delete values that are no + * longer part of the {@link FeatureFlag} enum. + * + *

This uses {@link ConfirmingCommand} instead of {@link MutatingCommand} because of the + * nonstandard deletion flow required by the fact that the enum constant may already have been + * removed. + */ +@Parameters(separators = " =", commandDescription = "Delete a FeatureFlag from the database") +public class DeleteFeatureFlagCommand extends ConfirmingCommand { + + @Parameter(description = "Feature flag to delete", required = true) + private List mainParameters; + + @Override + protected boolean checkExecutionState() { + checkArgument( + mainParameters != null && !mainParameters.isEmpty() && !mainParameters.getFirst().isBlank(), + "Must provide a non-blank feature flag as the main parameter"); + boolean exists = + tm().transact( + () -> + (long) + tm().getEntityManager() + .createNativeQuery( + "SELECT COUNT(*) FROM \"FeatureFlag\" WHERE feature_name =" + + " :featureName", + long.class) + .setParameter("featureName", mainParameters.getFirst()) + .getSingleResult() + > 0); + if (!exists) { + System.out.printf("No flag found with name '%s'", mainParameters.getFirst()); + } + return exists; + } + + @Override + protected String prompt() throws Exception { + return String.format("Delete feature flag named '%s'?", mainParameters.getFirst()); + } + + @Override + protected String execute() throws Exception { + String featureName = mainParameters.getFirst(); + tm().transact( + () -> + tm().getEntityManager() + .createNativeQuery( + "DELETE FROM \"FeatureFlag\" WHERE feature_name = :featureName") + .setParameter("featureName", featureName) + .executeUpdate()); + return String.format("Deleted feature flag with name '%s'", featureName); + } +} diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java index 869e9a927..d5b08e8b4 100644 --- a/core/src/main/java/google/registry/tools/RegistryTool.java +++ b/core/src/main/java/google/registry/tools/RegistryTool.java @@ -54,6 +54,7 @@ public final class RegistryTool { .put("curl", CurlCommand.class) .put("delete_allocation_tokens", DeleteAllocationTokensCommand.class) .put("delete_domain", DeleteDomainCommand.class) + .put("delete_feature_flag", DeleteFeatureFlagCommand.class) .put("delete_host", DeleteHostCommand.class) .put("delete_premium_list", DeletePremiumListCommand.class) .put("delete_reserved_list", DeleteReservedListCommand.class) diff --git a/core/src/test/java/google/registry/tools/DeleteFeatureFlagCommandTest.java b/core/src/test/java/google/registry/tools/DeleteFeatureFlagCommandTest.java new file mode 100644 index 000000000..f07746466 --- /dev/null +++ b/core/src/test/java/google/registry/tools/DeleteFeatureFlagCommandTest.java @@ -0,0 +1,79 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// 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 +// +// http://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. + +package google.registry.tools; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.common.FeatureFlag.FeatureName.TEST_FEATURE; +import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.START_OF_TIME; + +import com.google.common.collect.ImmutableSortedMap; +import google.registry.model.common.FeatureFlag; +import org.junit.jupiter.api.Test; + +/** Tests for {@link DeleteFeatureFlagCommand}. */ +public class DeleteFeatureFlagCommandTest extends CommandTestCase { + + @Test + void testSimpleSuccess() throws Exception { + persistResource( + new FeatureFlag() + .asBuilder() + .setFeatureName(TEST_FEATURE) + .setStatusMap(ImmutableSortedMap.of(START_OF_TIME, ACTIVE)) + .build()); + assertThat(tm().transact(() -> FeatureFlag.isActiveNow(TEST_FEATURE))).isTrue(); + runCommandForced("TEST_FEATURE"); + assertThat(FeatureFlag.getUncached(TEST_FEATURE)).isEmpty(); + } + + @Test + void testSuccess_noLongerPartOfEnum() throws Exception { + tm().transact( + () -> + tm().getEntityManager() + .createNativeQuery( + "INSERT INTO \"FeatureFlag\" VALUES('nonexistent'," + + " '\"1970-01-01T00:00:00.000Z\"=>\"INACTIVE\"')") + .executeUpdate()); + assertThat( + tm().transact( + () -> + tm().query( + "SELECT COUNT(*) FROM FeatureFlag WHERE featureName =" + + " 'nonexistent'", + long.class) + .getSingleResult())) + .isEqualTo(1L); + runCommandForced("nonexistent"); + assertThat( + tm().transact( + () -> + tm().query( + "SELECT COUNT(*) FROM FeatureFlag WHERE featureName =" + + " 'nonexistent'", + long.class) + .getSingleResult())) + .isEqualTo(0L); + } + + @Test + void testFailure_nonExistent() throws Exception { + runCommandForced("nonexistent"); + assertInStdout("No flag found with name 'nonexistent'"); + } +}