diff --git a/config/presubmits.py b/config/presubmits.py index c1f1ca44a..2863e1cc8 100644 --- a/config/presubmits.py +++ b/config/presubmits.py @@ -207,6 +207,9 @@ PRESUBMITS = { "ForeignKeyIndex.java", "HistoryEntryDao.java", "JpaTransactionManagerImpl.java", + # CriteriaQueryBuilder is a false positive + "CriteriaQueryBuilder.java", + "RdapSearchActionBase.java", }, ): "The first String parameter to EntityManager.create(Native)Query " diff --git a/core/src/main/java/google/registry/model/common/DatabaseTransitionSchedule.java b/core/src/main/java/google/registry/model/common/DatabaseTransitionSchedule.java index 0d2ac29a9..1f536044c 100644 --- a/core/src/main/java/google/registry/model/common/DatabaseTransitionSchedule.java +++ b/core/src/main/java/google/registry/model/common/DatabaseTransitionSchedule.java @@ -62,6 +62,8 @@ public class DatabaseTransitionSchedule extends ImmutableObject implements Datas DOMAIN_LABEL_LISTS, /** The schedule for the migration of the {@link SignedMarkRevocationList} entity. */ SIGNED_MARK_REVOCATION_LIST, + /** The schedule for all asynchronously-replayed entities, ones not dually-written. */ + REPLAYED_ENTITIES, } /** diff --git a/core/src/main/java/google/registry/persistence/transaction/CriteriaQueryBuilder.java b/core/src/main/java/google/registry/persistence/transaction/CriteriaQueryBuilder.java new file mode 100644 index 000000000..1d9e8fd57 --- /dev/null +++ b/core/src/main/java/google/registry/persistence/transaction/CriteriaQueryBuilder.java @@ -0,0 +1,97 @@ +// Copyright 2021 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.persistence.transaction; + +import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; + +import com.google.common.collect.ImmutableList; +import java.util.Collection; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; + +/** + * An extension of {@link CriteriaQuery} that uses a Builder-style pattern when adding "WHERE" + * and/or "ORDER BY" clauses. + * + *

{@link CriteriaQuery}, as is, requires that all clauses must be passed in at once -- if one + * calls "WHERE" multiple times, the later call overwrites the earlier call. + */ +public class CriteriaQueryBuilder { + + /** Functional interface that defines the 'where' operator, e.g. {@link CriteriaBuilder#equal}. */ + public interface WhereClause { + Predicate predicate(Expression expression, U object); + } + + /** Functional interface that defines the order-by operator, e.g. {@link CriteriaBuilder#asc}. */ + public interface OrderByClause { + Order order(Expression expression); + } + + private final CriteriaQuery query; + private final Root root; + private final ImmutableList.Builder predicates = new ImmutableList.Builder<>(); + private final ImmutableList.Builder orders = new ImmutableList.Builder<>(); + + private CriteriaQueryBuilder(CriteriaQuery query, Root root) { + this.query = query; + this.root = root; + } + + /** Adds a WHERE clause to the query, given the specified operation, field, and value. */ + public CriteriaQueryBuilder where(WhereClause whereClause, String fieldName, V value) { + Expression expression = root.get(fieldName); + return where(whereClause.predicate(expression, value)); + } + + /** Adds a WHERE clause to the query specifying that a value must be in the given collection. */ + public CriteriaQueryBuilder whereFieldIsIn(String fieldName, Collection values) { + return where(root.get(fieldName).in(values)); + } + + /** Orders the result by the given operation applied to the given field. */ + public CriteriaQueryBuilder orderBy(OrderByClause orderByClause, String fieldName) { + Expression expression = root.get(fieldName); + return orderBy(orderByClause.order(expression)); + } + + /** Builds and returns the query, applying all WHERE and ORDER BY clauses at once. */ + public CriteriaQuery build() { + Predicate[] predicateArray = predicates.build().toArray(new Predicate[0]); + return query.where(predicateArray).orderBy(orders.build()); + } + + private CriteriaQueryBuilder where(Predicate predicate) { + predicates.add(predicate); + return this; + } + + private CriteriaQueryBuilder orderBy(Order order) { + orders.add(order); + return this; + } + + /** Creates a query builder that will SELECT from the given class. */ + public static CriteriaQueryBuilder create(Class clazz) { + CriteriaQuery query = jpaTm().getEntityManager().getCriteriaBuilder().createQuery(clazz); + Root root = query.from(clazz); + query = query.select(root); + return new CriteriaQueryBuilder<>(query, root); + } +} diff --git a/core/src/main/java/google/registry/rdap/RdapActionBase.java b/core/src/main/java/google/registry/rdap/RdapActionBase.java index e95c0a16a..b00738a10 100644 --- a/core/src/main/java/google/registry/rdap/RdapActionBase.java +++ b/core/src/main/java/google/registry/rdap/RdapActionBase.java @@ -17,6 +17,7 @@ package google.registry.rdap; import static com.google.common.base.Charsets.UTF_8; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; import static google.registry.request.Actions.getPathForAction; import static google.registry.util.DomainNameUtils.canonicalizeDomainName; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; @@ -28,7 +29,10 @@ import com.google.common.net.MediaType; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import google.registry.config.RegistryConfig.Config; +import google.registry.model.DatabaseMigrationUtils; import google.registry.model.EppResource; +import google.registry.model.common.DatabaseTransitionSchedule.PrimaryDatabase; +import google.registry.model.common.DatabaseTransitionSchedule.TransitionId; import google.registry.model.registrar.Registrar; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapObjectClasses.ErrorResponse; @@ -256,4 +260,11 @@ public abstract class RdapActionBase implements Runnable { DateTime getRequestTime() { return rdapJsonFormatter.getRequestTime(); } + + static boolean isDatastore() { + return tm().transact( + () -> + DatabaseMigrationUtils.getPrimaryDatabase(TransitionId.REPLAYED_ENTITIES) + .equals(PrimaryDatabase.DATASTORE)); + } } diff --git a/core/src/main/java/google/registry/rdap/RdapEntitySearchAction.java b/core/src/main/java/google/registry/rdap/RdapEntitySearchAction.java index b464675f8..93419aef7 100644 --- a/core/src/main/java/google/registry/rdap/RdapEntitySearchAction.java +++ b/core/src/main/java/google/registry/rdap/RdapEntitySearchAction.java @@ -15,7 +15,9 @@ package google.registry.rdap; import static com.google.common.collect.ImmutableList.toImmutableList; -import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.persistence.transaction.TransactionManagerUtil.transactIfJpaTm; import static google.registry.rdap.RdapUtils.getRegistrarByIanaIdentifier; import static google.registry.request.Action.Method.GET; import static google.registry.request.Action.Method.HEAD; @@ -29,6 +31,9 @@ import com.google.common.primitives.Longs; import com.googlecode.objectify.cmd.Query; import google.registry.model.contact.ContactResource; import google.registry.model.registrar.Registrar; +import google.registry.persistence.VKey; +import google.registry.persistence.transaction.CriteriaQueryBuilder; +import google.registry.rdap.RdapAuthorization.Role; import google.registry.rdap.RdapJsonFormatter.OutputDataType; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.SearchType; @@ -112,7 +117,6 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { /** Parses the parameters and calls the appropriate search function. */ @Override public EntitySearchResponse getSearchResponse(boolean isHeadRequest) { - // RDAP syntax example: /rdap/entities?fn=Bobby%20Joe*. if (Booleans.countTrue(fnParam.isPresent(), handleParam.isPresent()) != 1) { throw new BadRequestException("You must specify either fn=XXXX or handle=YYYY"); @@ -214,9 +218,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { // Don't allow wildcard suffixes when searching for entities. if (partialStringQuery.getHasWildcard() && (partialStringQuery.getSuffix() != null)) { throw new UnprocessableEntityException( - partialStringQuery.getHasWildcard() - ? "Suffixes not allowed in wildcard entity name searches" - : "Suffixes not allowed when searching for deleted entities"); + "Suffixes not allowed in wildcard entity name searches"); } // For wildcards, make sure the initial string is long enough, except in the special case of // searching for all registrars, where we aren't worried about inefficient searches. @@ -225,9 +227,7 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { && (partialStringQuery.getInitialString().length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)) { throw new UnprocessableEntityException( - partialStringQuery.getHasWildcard() - ? "Initial search string required in wildcard entity name searches" - : "Initial search string required when searching for deleted entities"); + "Initial search string required in wildcard entity name searches"); } // Get the registrar matches. If we have a registrar cursor, weed out registrars up to and // including the one we ended with last time. We can skip registrars if subtype is CONTACTS. @@ -262,18 +262,39 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { || (cursorType == CursorType.REGISTRAR)) { resultSet = RdapResultSet.create(ImmutableList.of()); } else { - Query query = - queryItems( - ContactResource.class, - "searchName", - partialStringQuery, - cursorQueryString, // if we get this far, and there's a cursor, it must be a contact - DeletedItemHandling.EXCLUDE, - rdapResultSetMaxSize + 1); - if (rdapAuthorization.role() != RdapAuthorization.Role.ADMINISTRATOR) { - query = query.filter("currentSponsorClientId in", rdapAuthorization.clientIds()); + if (isDatastore()) { + Query query = + queryItems( + ContactResource.class, + "searchName", + partialStringQuery, + cursorQueryString, // if we get here and there's a cursor, it must be a contact + DeletedItemHandling.EXCLUDE, + rdapResultSetMaxSize + 1); + if (!rdapAuthorization.role().equals(Role.ADMINISTRATOR)) { + query = query.filter("currentSponsorClientId in", rdapAuthorization.clientIds()); + } + resultSet = getMatchingResources(query, false, rdapResultSetMaxSize + 1); + } else { + resultSet = + jpaTm() + .transact( + () -> { + CriteriaQueryBuilder builder = + queryItemsSql( + ContactResource.class, + "searchName", + partialStringQuery, + cursorQueryString, + DeletedItemHandling.EXCLUDE); + if (!rdapAuthorization.role().equals(Role.ADMINISTRATOR)) { + builder = + builder.whereFieldIsIn( + "currentSponsorClientId", rdapAuthorization.clientIds()); + } + return getMatchingResourcesSql(builder, false, rdapResultSetMaxSize + 1); + }); } - resultSet = getMatchingResources(query, false, rdapResultSetMaxSize + 1); } } return makeSearchResults(resultSet, registrars, QueryType.FULL_NAME); @@ -303,15 +324,15 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { if (subtype == Subtype.REGISTRARS) { contactResourceList = ImmutableList.of(); } else { - ContactResource contactResource = - ofy() - .load() - .type(ContactResource.class) - .id(partialStringQuery.getInitialString()) - .now(); + Optional contactResource = + transactIfJpaTm( + () -> + tm().loadByKeyIfPresent( + VKey.create( + ContactResource.class, partialStringQuery.getInitialString()))); contactResourceList = - ((contactResource != null) && shouldBeVisible(contactResource)) - ? ImmutableList.of(contactResource) + (contactResource.isPresent() && shouldBeVisible(contactResource.get())) + ? ImmutableList.of(contactResource.get()) : ImmutableList.of(); } ImmutableList registrarList; @@ -365,16 +386,31 @@ public class RdapEntitySearchAction extends RdapSearchActionBase { if (subtype == Subtype.REGISTRARS) { contactResultSet = RdapResultSet.create(ImmutableList.of()); } else { - contactResultSet = - getMatchingResources( - queryItemsByKey( - ContactResource.class, - partialStringQuery, - cursorQueryString, - getDeletedItemHandling(), - querySizeLimit), - shouldIncludeDeleted(), - querySizeLimit); + if (isDatastore()) { + contactResultSet = + getMatchingResources( + queryItemsByKey( + ContactResource.class, + partialStringQuery, + cursorQueryString, + getDeletedItemHandling(), + querySizeLimit), + shouldIncludeDeleted(), + querySizeLimit); + } else { + contactResultSet = + jpaTm() + .transact( + () -> + getMatchingResourcesSql( + queryItemsByKeySql( + ContactResource.class, + partialStringQuery, + cursorQueryString, + getDeletedItemHandling()), + shouldIncludeDeleted(), + querySizeLimit)); + } } return makeSearchResults(contactResultSet, registrars, QueryType.HANDLE); } diff --git a/core/src/main/java/google/registry/rdap/RdapSearchActionBase.java b/core/src/main/java/google/registry/rdap/RdapSearchActionBase.java index b1d844b60..d8083cbbe 100644 --- a/core/src/main/java/google/registry/rdap/RdapSearchActionBase.java +++ b/core/src/main/java/google/registry/rdap/RdapSearchActionBase.java @@ -16,6 +16,7 @@ package google.registry.rdap; import static com.google.common.base.Charsets.UTF_8; import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.util.DateTimeUtils.END_OF_TIME; import com.google.common.collect.ImmutableList; @@ -24,6 +25,7 @@ import com.googlecode.objectify.Key; import com.googlecode.objectify.cmd.Query; import google.registry.model.EppResource; import google.registry.model.registrar.Registrar; +import google.registry.persistence.transaction.CriteriaQueryBuilder; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.WildcardType; import google.registry.rdap.RdapSearchResults.BaseSearchResponse; @@ -40,8 +42,10 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import javax.inject.Inject; +import javax.persistence.criteria.CriteriaBuilder; /** * Base RDAP (new WHOIS) action for domain, nameserver and entity search requests. @@ -161,14 +165,63 @@ public abstract class RdapSearchActionBase extends RdapActionBase { if (desiredRegistrar.isPresent()) { query = query.filter("currentSponsorClientId", desiredRegistrar.get()); } - if (!checkForVisibility) { - return RdapResultSet.create(query.list()); + List queryResult = query.list(); + if (checkForVisibility) { + return filterResourcesByVisibility(queryResult, querySizeLimit); + } else { + return RdapResultSet.create(queryResult); } + } + + /** + * In Cloud SQL, builds and runs the given query, and checks for permissioning if necessary. + * + * @param builder a query builder that represents the various SELECT FROM, WHERE, ORDER BY and + * (etc) clauses that make up this SQL query + * @param checkForVisibility true if the results should be checked to make sure they are visible; + * normally this should be equal to the shouldIncludeDeleted setting, but in cases where the + * query could not check deletion status (due to Datastore limitations such as the limit of + * one field queried for inequality, for instance), it may need to be set to true even when + * not including deleted records + * @param querySizeLimit the maximum number of items the query is expected to return, usually + * because the limit has been set + * @return an {@link RdapResultSet} object containing the list of resources and an incompleteness + * warning flag, which is set to MIGHT_BE_INCOMPLETE iff any resources were excluded due to + * lack of visibility, and the resulting list of resources is less than the maximum allowable, + * and the number of items returned by the query is greater than or equal to the maximum + * number we might have expected + */ + RdapResultSet getMatchingResourcesSql( + CriteriaQueryBuilder builder, boolean checkForVisibility, int querySizeLimit) { + jpaTm().assertInTransaction(); + Optional desiredRegistrar = getDesiredRegistrar(); + if (desiredRegistrar.isPresent()) { + builder = + builder.where( + jpaTm().getEntityManager().getCriteriaBuilder()::equal, + "currentSponsorClientId", + desiredRegistrar.get()); + } + List queryResult = + jpaTm() + .getEntityManager() + .createQuery(builder.build()) + .setMaxResults(querySizeLimit) + .getResultList(); + if (checkForVisibility) { + return filterResourcesByVisibility(queryResult, querySizeLimit); + } else { + return RdapResultSet.create(queryResult); + } + } + + private RdapResultSet filterResourcesByVisibility( + List queryResult, int querySizeLimit) { // If we are including deleted resources, we need to check that we're authorized for each one. List resources = new ArrayList<>(); int numResourcesQueried = 0; boolean someExcluded = false; - for (T resource : query) { + for (T resource : queryResult) { if (shouldBeVisible(resource)) { resources.add(resource); } else { @@ -268,8 +321,8 @@ public abstract class RdapSearchActionBase extends RdapActionBase { // to be (more) sure we got everything. int getStandardQuerySizeLimit() { return shouldIncludeDeleted() - ? (RESULT_SET_SIZE_SCALING_FACTOR * (rdapResultSetMaxSize + 1)) - : (rdapResultSetMaxSize + 1); + ? (RESULT_SET_SIZE_SCALING_FACTOR * (rdapResultSetMaxSize + 1)) + : (rdapResultSetMaxSize + 1); } /** @@ -311,9 +364,10 @@ public abstract class RdapSearchActionBase extends RdapActionBase { query = query.filter(filterField, partialStringQuery.getInitialString()); } else { // Ignore the suffix; the caller will need to filter on the suffix, if any. - query = query - .filter(filterField + " >=", partialStringQuery.getInitialString()) - .filter(filterField + " <", partialStringQuery.getNextInitialString()); + query = + query + .filter(filterField + " >=", partialStringQuery.getInitialString()) + .filter(filterField + " <", partialStringQuery.getNextInitialString()); } if (cursorString.isPresent()) { query = query.filter(filterField + " >", cursorString.get()); @@ -321,6 +375,59 @@ public abstract class RdapSearchActionBase extends RdapActionBase { return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize); } + /** + * In Cloud SQL, handles prefix searches in cases where, if we need to filter out deleted items, + * there are no pending deletes. + * + *

In such cases, it is sufficient to check whether {@code deletionTime} is equal to {@code + * END_OF_TIME}, because any other value means it has already been deleted. This allows us to use + * an equality query for the deletion time. + * + * @param clazz the type of resource to be queried + * @param filterField the database field of interest + * @param partialStringQuery the details of the search string; if there is no wildcard, an + * equality query is used; if there is a wildcard, a range query is used instead; the initial + * string should not be empty, and any search suffix will be ignored, so the caller must + * filter the results if a suffix is specified + * @param cursorString if a cursor is present, this parameter should specify the cursor string, to + * skip any results up to and including the string; empty() if there is no cursor + * @param deletedItemHandling whether to include or exclude deleted items + * @return a {@link CriteriaQueryBuilder} object representing the query so far + */ + static CriteriaQueryBuilder queryItemsSql( + Class clazz, + String filterField, + RdapSearchPattern partialStringQuery, + Optional cursorString, + DeletedItemHandling deletedItemHandling) { + jpaTm().assertInTransaction(); + if (partialStringQuery.getInitialString().length() + < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { + throw new UnprocessableEntityException( + String.format( + "Initial search string must be at least %d characters", + RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)); + } + CriteriaBuilder criteriaBuilder = jpaTm().getEntityManager().getCriteriaBuilder(); + CriteriaQueryBuilder builder = CriteriaQueryBuilder.create(clazz); + if (partialStringQuery.getHasWildcard()) { + builder = + builder.where( + criteriaBuilder::like, + filterField, + String.format("%s%%", partialStringQuery.getInitialString())); + } else { + // no wildcard means we use a standard equals query + builder = + builder.where(criteriaBuilder::equal, filterField, partialStringQuery.getInitialString()); + } + if (cursorString.isPresent()) { + builder = builder.where(criteriaBuilder::greaterThan, filterField, cursorString.get()); + } + builder = builder.orderBy(criteriaBuilder::asc, filterField); + return setDeletedItemHandlingSql(builder, deletedItemHandling); + } + /** * Handles searches using a simple string rather than an {@link RdapSearchPattern}. * @@ -331,9 +438,9 @@ public abstract class RdapSearchActionBase extends RdapActionBase { * @param filterField the database field of interest * @param queryString the search string * @param cursorField the field which should be compared to the cursor string, or empty() if the - * key should be compared to a key created from the cursor string + * key should be compared to a key created from the cursor string * @param cursorString if a cursor is present, this parameter should specify the cursor string, to - * skip any results up to and including the string; empty() if there is no cursor + * skip any results up to and including the string; empty() if there is no cursor * @param deletedItemHandling whether to include or exclude deleted items * @param resultSetMaxSize the maximum number of results to return * @return the query object @@ -363,6 +470,50 @@ public abstract class RdapSearchActionBase extends RdapActionBase { return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize); } + /** + * In Cloud SQL, handles searches using a simple string rather than an {@link RdapSearchPattern}. + * + *

Since the filter is not an inequality, we can support also checking a cursor string against + * a different field (which involves an inequality on that field). + * + * @param clazz the type of resource to be queried + * @param filterField the database field of interest + * @param queryString the search string + * @param cursorField the field which should be compared to the cursor string, or empty() if the + * key should be compared to a key created from the cursor string + * @param cursorString if a cursor is present, this parameter should specify the cursor string, to + * skip any results up to and including the string; empty() if there is no cursor + * @param deletedItemHandling whether to include or exclude deleted items + * @return a {@link CriteriaQueryBuilder} object representing the query so far + */ + static CriteriaQueryBuilder queryItemsSql( + Class clazz, + String filterField, + String queryString, + Optional cursorField, + Optional cursorString, + DeletedItemHandling deletedItemHandling) { + if (queryString.length() < RdapSearchPattern.MIN_INITIAL_STRING_LENGTH) { + throw new UnprocessableEntityException( + String.format( + "Initial search string must be at least %d characters", + RdapSearchPattern.MIN_INITIAL_STRING_LENGTH)); + } + jpaTm().assertInTransaction(); + CriteriaQueryBuilder builder = CriteriaQueryBuilder.create(clazz); + CriteriaBuilder criteriaBuilder = jpaTm().getEntityManager().getCriteriaBuilder(); + builder = builder.where(criteriaBuilder::equal, filterField, queryString); + if (cursorString.isPresent()) { + if (cursorField.isPresent()) { + builder = + builder.where(criteriaBuilder::greaterThan, cursorField.get(), cursorString.get()); + } else { + builder = builder.where(criteriaBuilder::greaterThan, "repoId", cursorString.get()); + } + } + return setDeletedItemHandlingSql(builder, deletedItemHandling); + } + /** Handles searches where the field to be searched is the key. */ static Query queryItemsByKey( Class clazz, @@ -382,9 +533,10 @@ public abstract class RdapSearchActionBase extends RdapActionBase { query = query.filterKey("=", Key.create(clazz, partialStringQuery.getInitialString())); } else { // Ignore the suffix; the caller will need to filter on the suffix, if any. - query = query - .filterKey(">=", Key.create(clazz, partialStringQuery.getInitialString())) - .filterKey("<", Key.create(clazz, partialStringQuery.getNextInitialString())); + query = + query + .filterKey(">=", Key.create(clazz, partialStringQuery.getInitialString())) + .filterKey("<", Key.create(clazz, partialStringQuery.getNextInitialString())); } if (cursorString.isPresent()) { query = query.filterKey(">", Key.create(clazz, cursorString.get())); @@ -392,6 +544,26 @@ public abstract class RdapSearchActionBase extends RdapActionBase { return setOtherQueryAttributes(query, deletedItemHandling, resultSetMaxSize); } + /** In Cloud SQL, handles searches where the field to be searched is the key. */ + static CriteriaQueryBuilder queryItemsByKeySql( + Class clazz, + RdapSearchPattern partialStringQuery, + Optional cursorString, + DeletedItemHandling deletedItemHandling) { + jpaTm().assertInTransaction(); + return queryItemsSql(clazz, "repoId", partialStringQuery, cursorString, deletedItemHandling); + } + + static CriteriaQueryBuilder setDeletedItemHandlingSql( + CriteriaQueryBuilder builder, DeletedItemHandling deletedItemHandling) { + if (!Objects.equals(deletedItemHandling, DeletedItemHandling.INCLUDE)) { + builder = + builder.where( + jpaTm().getEntityManager().getCriteriaBuilder()::equal, "deletionTime", END_OF_TIME); + } + return builder; + } + static Query setOtherQueryAttributes( Query query, DeletedItemHandling deletedItemHandling, int resultSetMaxSize) { if (deletedItemHandling != DeletedItemHandling.INCLUDE) { diff --git a/core/src/test/java/google/registry/persistence/transaction/CriteriaQueryBuilderTest.java b/core/src/test/java/google/registry/persistence/transaction/CriteriaQueryBuilderTest.java new file mode 100644 index 000000000..eb0da272f --- /dev/null +++ b/core/src/test/java/google/registry/persistence/transaction/CriteriaQueryBuilderTest.java @@ -0,0 +1,202 @@ +// Copyright 2021 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.persistence.transaction; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; + +import com.google.common.collect.ImmutableList; +import google.registry.model.ImmutableObject; +import google.registry.persistence.transaction.JpaTestRules.JpaUnitTestExtension; +import google.registry.testing.FakeClock; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.criteria.CriteriaQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link CriteriaQueryBuilder}. */ +class CriteriaQueryBuilderTest { + + private final FakeClock fakeClock = new FakeClock(); + + private CriteriaQueryBuilderTestEntity entity1 = + new CriteriaQueryBuilderTestEntity("name1", "data"); + private CriteriaQueryBuilderTestEntity entity2 = + new CriteriaQueryBuilderTestEntity("name2", "zztz"); + private CriteriaQueryBuilderTestEntity entity3 = new CriteriaQueryBuilderTestEntity("zzz", "aaa"); + + @RegisterExtension + final JpaUnitTestExtension jpaExtension = + new JpaTestRules.Builder() + .withClock(fakeClock) + .withEntityClass(CriteriaQueryBuilderTestEntity.class) + .buildUnitTestRule(); + + @BeforeEach + void beforeEach() { + jpaTm().transact(() -> jpaTm().putAll(ImmutableList.of(entity1, entity2, entity3))); + } + + @Test + void testSuccess_noWhereClause() { + assertThat( + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createQuery( + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .build()) + .getResultList())) + .containsExactly(entity1, entity2, entity3) + .inOrder(); + } + + @Test + void testSuccess_where_exactlyOne() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::equal, + "data", + "zztz") + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity2); + } + + @Test + void testSuccess_where_like_oneResult() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::like, "data", "a%") + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity3); + } + + @Test + void testSuccess_where_like_twoResults() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::like, "data", "%a%") + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity1, entity3).inOrder(); + } + + @Test + void testSuccess_multipleWheres() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + // first "where" matches 1 and 3 + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::like, "data", "%a%") + // second "where" matches 1 and 2 + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::like, "data", "%t%") + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity1); + } + + @Test + void testSuccess_where_in_oneResult() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .whereFieldIsIn("data", ImmutableList.of("aaa", "bbb")) + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity3).inOrder(); + } + + @Test + void testSuccess_where_in_twoResults() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .whereFieldIsIn("data", ImmutableList.of("aaa", "bbb", "data")) + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity1, entity3).inOrder(); + } + + @Test + void testSuccess_orderBy() { + List result = + jpaTm() + .transact( + () -> { + CriteriaQuery query = + CriteriaQueryBuilder.create(CriteriaQueryBuilderTestEntity.class) + .orderBy(jpaTm().getEntityManager().getCriteriaBuilder()::asc, "data") + .where( + jpaTm().getEntityManager().getCriteriaBuilder()::like, "data", "%a%") + .build(); + return jpaTm().getEntityManager().createQuery(query).getResultList(); + }); + assertThat(result).containsExactly(entity3, entity1).inOrder(); + } + + @Entity(name = "CriteriaQueryBuilderTestEntity") + private static class CriteriaQueryBuilderTestEntity extends ImmutableObject { + @Id private String name; + + @SuppressWarnings("unused") + private String data; + + private CriteriaQueryBuilderTestEntity() {} + + private CriteriaQueryBuilderTestEntity(String name, String data) { + this.name = name; + this.data = data; + } + } +} diff --git a/core/src/test/java/google/registry/rdap/RdapEntitySearchActionTest.java b/core/src/test/java/google/registry/rdap/RdapEntitySearchActionTest.java index e32c2cf90..1f84b67b0 100644 --- a/core/src/test/java/google/registry/rdap/RdapEntitySearchActionTest.java +++ b/core/src/test/java/google/registry/rdap/RdapEntitySearchActionTest.java @@ -43,14 +43,16 @@ import google.registry.model.reporting.HistoryEntry; import google.registry.rdap.RdapMetrics.EndpointType; import google.registry.rdap.RdapMetrics.SearchType; import google.registry.rdap.RdapSearchResults.IncompletenessWarningType; +import google.registry.testing.DualDatabaseTest; import google.registry.testing.FakeResponse; +import google.registry.testing.TestOfyAndSql; import java.net.URLDecoder; import java.util.Optional; import javax.annotation.Nullable; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; /** Unit tests for {@link RdapEntitySearchAction}. */ +@DualDatabaseTest class RdapEntitySearchActionTest extends RdapSearchActionTestCase { RdapEntitySearchActionTest() { @@ -209,9 +211,9 @@ class RdapEntitySearchActionTest extends RdapSearchActionTestCase"); verifyErrorMetrics(0); } - @Test + @TestOfyAndSql void testNameMatchContacts_nonTruncated() { login("2-RegistrarTest"); createManyContactsAndRegistrars(4, 0, registrarTest); rememberWildcardType("Entity *"); + // JsonObject foo = generateActualJsonWithFullName("Entity *"); assertThat(generateActualJsonWithFullName("Entity *")) .isEqualTo(generateExpectedJson("rdap_nontruncated_contacts.json")); assertThat(response.getStatus()).isEqualTo(200); verifyMetrics(4); } - @Test + @TestOfyAndSql void testNameMatchContacts_truncated() { login("2-RegistrarTest"); createManyContactsAndRegistrars(5, 0, registrarTest); @@ -683,7 +686,7 @@ class RdapEntitySearchActionTest extends RdapSearchActionTestCase")); } - @Test + @TestOfyAndSql void testNameMatchMix_truncated() { login("2-RegistrarTest"); createManyContactsAndRegistrars(3, 3, registrarTest); @@ -811,7 +814,7 @@ class RdapEntitySearchActionTest extends RdapSearchActionTestCase", "rdap_registrar.json"); verifyMetrics(0); } - @Test + @TestOfyAndSql void testHandleMatchRegistrar_found_subtypeAll() { action.subtypeParam = Optional.of("all"); runSuccessfulHandleTest("20", "20", "Yes Virginia