1
0
mirror of https://github.com/google/nomulus synced 2026-07-20 15:02:30 +00:00

Add loadAllOfSorted to TransactionManager (#3136)

During our implementation of Expiry Access Period (XAP), adding a field to the
Registrar entity altered database heap scan order when loadAllOf() was
called without an explicit ORDER BY clause. This caused non-deterministic
row shifting in the Angular console registrar table (/console/registrars),
breaking golden image comparisons in ConsoleScreenshotTest.

To permanently fix this root cause and guarantee deterministic UI rendering
and test stability across schema evolutions, this commit introduces sorted
database loading across our persistence layer.

Specifically, this commit:
- Adds loadAllOfSorted and loadAllOfSortedStream to TransactionManager,
  JpaTransactionManagerImpl, and DelegatingReplicaJpaTransactionManager.
- Enforces strict allowlist regex validation (^[a-zA-Z0-9_.]+$) on property
  names in JpaTransactionManagerImpl before appending dynamic ORDER BY
  clauses, preventing JPQL/SQL injection since bind parameters cannot be
  used for schema identifiers.
- Adds Registrar.loadAllSorted and updates RegistrarsAction to explicitly
  sort registrars alphabetically by registrarName and registrarId.
- Updates golden screenshot
  ConsoleScreenshotTest_globalRole_registrars_registrarsPage.png to
  reflect the newly enforced deterministic alphabetical registrar ordering.
- Adds comprehensive unit test coverage in JpaTransactionManagerImplTest
  verifying exact sorted entity retrieval across multiple fields and regex
  rejection of invalid field names.

TAG=agy
CONV=f2488c74-8b4a-43f1-9c22-d1dddbdbb4e0
BUG=http://b/531889856
This commit is contained in:
Ben McIlwain
2026-07-07 15:44:56 -04:00
committed by GitHub
parent 4d6b5a82df
commit 2ce91e3477
8 changed files with 103 additions and 3 deletions
@@ -1030,6 +1030,11 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return tm().transact(() -> tm().loadAllOf(Registrar.class));
}
/** Loads all registrar entities directly from the database, sorted by the given field names. */
public static Iterable<Registrar> loadAllSorted(String... sortFields) {
return tm().transact(() -> tm().loadAllOfSorted(Registrar.class, sortFields));
}
/** Loads all registrar entities using an in-memory cache. */
public static Iterable<Registrar> loadAllCached() {
return CACHE_BY_REGISTRAR_ID.get().values();
@@ -257,11 +257,21 @@ public class DelegatingReplicaJpaTransactionManager implements JpaTransactionMan
return getReplica().loadAllOf(clazz);
}
@Override
public <T> ImmutableList<T> loadAllOfSorted(Class<T> clazz, String... sortFields) {
return getReplica().loadAllOfSorted(clazz, sortFields);
}
@Override
public <T> Stream<T> loadAllOfStream(Class<T> clazz) {
return getReplica().loadAllOfStream(clazz);
}
@Override
public <T> Stream<T> loadAllOfSortedStream(Class<T> clazz, String... sortFields) {
return getReplica().loadAllOfSortedStream(clazz, sortFields);
}
@Override
public <T> Optional<T> loadSingleton(Class<T> clazz) {
return getReplica().loadSingleton(clazz);
@@ -76,6 +76,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import javax.annotation.Nullable;
@@ -88,6 +89,16 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Retrier retrier = new Retrier(new SystemSleeper(), 6);
/**
* Strict allowlist regex for property/field names in dynamic JPQL ORDER BY clauses.
*
* <p>JPA and database engines forbid bind parameters (e.g. ? or :param) for schema identifiers or
* property names in ORDER BY clauses. To prevent JPQL/SQL injection when dynamically constructing
* sort queries, every sort field MUST be validated against this pattern before concatenation.
*/
private static final Pattern VALID_SORT_FIELD_PATTERN = Pattern.compile("^[a-zA-Z0-9_.]+$");
private static final String NESTED_TRANSACTION_MESSAGE =
"Nested transaction detected. Try refactoring to avoid nested transactions. If unachievable,"
+ " use reTransact() in nested transactions";
@@ -528,15 +539,38 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
@Override
public <T> ImmutableList<T> loadAllOf(Class<T> clazz) {
return loadAllOfStream(clazz).collect(toImmutableList());
return loadAllOfSorted(clazz);
}
@Override
public <T> Stream<T> loadAllOfStream(Class<T> clazz) {
return loadAllOfSortedStream(clazz);
}
@Override
public <T> ImmutableList<T> loadAllOfSorted(Class<T> clazz, String... sortFields) {
return loadAllOfSortedStream(clazz, sortFields).collect(toImmutableList());
}
@Override
public <T> Stream<T> loadAllOfSortedStream(Class<T> clazz, String... sortFields) {
checkArgumentNotNull(clazz, "clazz must be specified");
checkArgumentNotNull(sortFields, "sortFields must not be null");
assertInTransaction();
StringBuilder queryString =
new StringBuilder(String.format("FROM %s", getEntityType(clazz).getName()));
if (sortFields.length > 0) {
for (String field : sortFields) {
checkArgument(
VALID_SORT_FIELD_PATTERN.matcher(field).matches(),
"Invalid sort field name: %s",
field);
}
queryString.append(" ORDER BY ");
queryString.append(String.join(", ", sortFields));
}
return getEntityManager()
.createQuery(String.format("FROM %s", getEntityType(clazz).getName()), clazz)
.createQuery(queryString.toString(), clazz)
.getResultStream()
.map(this::detach);
}
@@ -219,6 +219,14 @@ public interface TransactionManager {
*/
<T> ImmutableList<T> loadAllOf(Class<T> clazz);
/**
* Returns a list of all entities of the given type that exist in the database, ordered by the
* specified field names in ascending order.
*
* <p>The resulting list is empty if there are no entities of this type.
*/
<T> ImmutableList<T> loadAllOfSorted(Class<T> clazz, String... sortFields);
/**
* Returns a stream of all entities of the given type that exist in the database.
*
@@ -226,6 +234,14 @@ public interface TransactionManager {
*/
<T> Stream<T> loadAllOfStream(Class<T> clazz);
/**
* Returns a stream of all entities of the given type that exist in the database, ordered by the
* specified field names in ascending order.
*
* <p>The resulting stream is empty if there are no entities of this type.
*/
<T> Stream<T> loadAllOfSortedStream(Class<T> clazz, String... sortFields);
/**
* Loads the only instance of this particular class, or empty if none exists.
*
@@ -59,6 +59,7 @@ public class RegistrarsAction extends ConsoleApiAction {
"""
SELECT * FROM "Registrar"
WHERE registrar_id in :registrarIds
ORDER BY registrar_name ASC, registrar_id ASC
""";
static final String PATH = "/console-api/registrars";
private final Optional<Registrar> registrar;
@@ -83,7 +84,7 @@ public class RegistrarsAction extends ConsoleApiAction {
ImmutableSet<Registrar.Type> allowedRegistrarTypes =
user.getUserRoles().isAdmin() ? TYPES_ALLOWED_FOR_ADMINS : TYPES_ALLOWED_FOR_USERS;
ImmutableList<Registrar> registrars =
Streams.stream(Registrar.loadAll())
Streams.stream(Registrar.loadAllSorted("registrarName", "registrarId"))
.filter(r -> allowedRegistrarTypes.contains(r.getType()))
.collect(ImmutableList.toImmutableList());
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(registrars));
@@ -404,6 +404,39 @@ class JpaTransactionManagerImplTest {
.containsExactlyElementsIn(moreEntities);
}
@Test
void loadAllOfSorted() {
TestEntity entityB = new TestEntity("b_entity", "gamma");
TestEntity entityC = new TestEntity("c_entity", "alpha");
TestEntity entityA = new TestEntity("a_entity", "beta");
persistResources(ImmutableList.of(entityB, entityC, entityA));
assertThat(tm().transact(() -> tm().loadAllOfSorted(TestEntity.class, "name")))
.containsExactly(entityA, entityB, entityC)
.inOrder();
assertThat(tm().transact(() -> tm().loadAllOfSorted(TestEntity.class, "data")))
.containsExactly(entityC, entityA, entityB)
.inOrder();
assertThat(tm().transact(() -> tm().loadAllOfSorted(TestEntity.class, "data", "name")))
.containsExactly(entityC, entityA, entityB)
.inOrder();
}
@Test
void loadAllOfSorted_invalidFieldName_throwsException() {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
tm().transact(
() ->
tm().loadAllOfSorted(
TestEntity.class, "name; DROP TABLE TestEntity;")));
assertThat(thrown).hasMessageThat().contains("Invalid sort field name");
}
@Test
void saveAllNew_rollsBackWhenFailure() {
moreEntities.forEach(entity -> assertThat(tm().transact(() -> tm().exists(entity))).isFalse());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB