Detect string-based property references in Spring Data code (Sort.by(...), Criteria.where(...), etc.) and provide automated refactoring to the new type-safe property path API introduced in Spring Data 2026.0.0-M1.
The key challenge: determining the Domain Type so that string property names can be mapped to method references (e.g. "firstName" → Person::getFirstName).
| Before (string-based) | After (type-safe) |
|---|---|
Sort.by("firstName", "lastName") |
Sort.by(Person::getFirstName, Person::getLastName) |
Sort.by(Direction.ASC, "lastName") |
Sort.by(Person::getLastName).ascending() (or equivalent) |
Sort.by(Sort.Order.desc("total")) |
Sort.by(Person::getTotal).descending() (or equivalent) |
where("firstName").is(...) |
where(Person::getFirstName).is(...) |
Criteria.where("address.country").is(...) |
where(PropertyPath.of(Person::getAddress).then(Address::getCountry)).is(...) |
Pattern:
repository.findByLastname(name, Sort.by("firstname"));
repository.totalOrdersPerCustomer(Sort.by(Sort.Order.desc("total")));
repository.findPagedProjectedBy(PageRequest.of(0, 1, Sort.by(Direction.ASC, "lastname")));How to determine Domain Type:
- Resolve the type of the receiver (
repository) — it implements aRepository<T, ID>interface (e.g.CrudRepository<Customer, String>). - Extract the first type parameter
Tfrom theRepository<T, ID>superinterface — that is the domain type. - Alternatively, resolve the called method's declaring interface and find the domain type from its
Repository<T, ID>bound.
JDT AST Strategy:
- Locate the
MethodInvocationnode containing theSort.by(...)orPageRequest.of(...)expression as an argument. - Resolve the receiver's type binding → walk the type hierarchy to find
Repository<T, ID>→ extractT. - The
Ttype binding gives us access to its declared methods/fields for property name validation and getter resolution.
Observed examples from sample projects:
repository.findByLastname(dave.getLastname(), Sort.by("firstname"))— receiver isAdvancedRepository extends CustomerRepository extends CrudRepository<Customer, String>→ Domain Type = Customerrepository.totalOrdersPerCustomer(Sort.by(Sort.Order.desc("total")))— receiver isOrderRepository extends CrudRepository<Order, String>→ Domain Type = Order (this is an@Aggregation-annotated method;"total"is a computed field that won't match a domain property)customers.findPagedProjectedBy(PageRequest.of(0, 1, Sort.by(Direction.ASC, "lastname")))— receiver isCustomerRepository extends CrudRepository<Customer, String>→ Domain Type = Customerrepository.findTop2By(Sort.by(ASC, "lastname"))— receiver isSimpleUserRepository extends ListCrudRepository<User, Long>→ Domain Type = User
Caveat for @Aggregation-annotated methods: The repository method may be annotated with @Aggregation, and the Sort parameter appends to the aggregation pipeline. The domain type resolution is identical (from Repository<T, ID>), but the sort field may reference computed/projected names (like "total") that do NOT correspond to properties on the domain type. These should be flagged as not refactorable (no matching getter on the domain type).
Confidence: HIGH — the repository type parameter is the single authoritative source.
Scenario 2: Fluent Template API — operations.query(X.class).matching(where(...)) / operations.update(X.class).matching(...)
Pattern:
// Query
mongoOps.query(SWCharacter.class)
.matching(query(where("name").is("luke")))
.one();
// Update
template.update(Process.class)
.matching(Query.query(Criteria.where("id").is(process.id())))
.apply(Update.update("state", State.DONE))
.first();How to determine Domain Type:
- Walk UP the method invocation chain to find the root
.query(X.class)or.update(X.class)call. - The
X.classclass literal argument is the domain type.
JDT AST Strategy:
- From the
where("...")call site, traverse the parentMethodInvocationchain (fluent API chaining). - Look for a call to
query(Class)orupdate(Class)on aMongoOperations/ReactiveMongoOperations/FluentMongoOperationsreceiver. - Extract the
Class<X>literal argument —Xis the domain type. - Note: if
.as(Y.class)is present, the query mapping still uses the originating domain typeXfor property resolution, notY.
Observed examples from sample projects:
mongoOps.query(SWCharacter.class).matching(query(where("name").is("luke")))→ Domain Type = SWCharactermongoOps.query(Jedi.class).matching(query(where("firstname").is("anakin")))→ Domain Type = JedimongoOps.update(Jedi.class).matching(query(where("lastname").is("windu")))→ Domain Type = Jeditemplate.update(Process.class).matching(Query.query(Criteria.where("id").is(...)))→ Domain Type = Processoperations.update(Manager.class).matching(where("id").is(...))→ Domain Type = Manageroperations.query(Manager.class).matching(where("id").is(...))→ Domain Type = Manager
Confidence: HIGH — the .class literal is explicitly in scope.
Pattern:
template.find(Query.query(Criteria.where("lastname").is("White")), Person.class);
operations.findOne(new Query(), Customer.class, "customer");
operations.find(query(byExample(example)), Person.class);
template.findAll(query(where("name").is("Marry")), PetOwner.class); // JDBCHow to determine Domain Type:
- The second parameter to
find()/findOne()/findAll()is theClass<T>domain type.
JDT AST Strategy:
- Locate
MethodInvocationwhere method name isfind/findOne/findAllon a template/operations type. - The
Criteria.where("...")orwhere("...")call is nested inside aQuerythat is the first argument. - The second argument is
X.class→ domain type =X.
Observed examples from sample projects:
template.find(Query.query(Criteria.where("lastname").is("White")), Person.class)→ Domain Type = Personoperations.find(bq, Store.class)→ Domain Type = Storeoperations.find(query(criteria), BlogPost.class)→ Domain Type = BlogPosttemplate.findAll(query(where("name").is("Marry")), PetOwner.class)(JDBC) → Domain Type = PetOwner
Confidence: HIGH — the Class parameter is explicit.
Scenario 4: Aggregation Framework — newAggregation(X.class, ...) with embedded match(where(...)) / sort(...) / project(...)
Pattern:
operations.aggregate(newAggregation(Order.class,
match(where("id").is(order.getId())),
unwind("items"),
project("id", "customerId", "items")
.andExpression("...").as("lineTotal"),
sort(Direction.DESC, "totalPageCount")
), Invoice.class);How to determine Domain Type:
- If
newAggregation(X.class, ...)form is used —Xis the domain type (first parameter is the input type for field mapping). - If
newAggregation(...)form is used (no Class parameter) — domain type must come from theoperations.aggregate(aggregation, "collectionName", OutputType.class)call, but this is the output type, not necessarily the input domain type. This case is ambiguous and harder to resolve.
JDT AST Strategy:
- Walk to the
newAggregation(...)call. Check if the first argument is aClass<X>literal → domain type =X. - If no class literal, look for the enclosing
operations.aggregate(aggregation, ...)call — the third parameter is the output type mapping (may differ from input domain type).
Observed examples from sample projects:
newAggregation(Order.class, match(where("id").is(...)), ...)→ Domain Type = Order (explicit)newAggregation(sort(Direction.ASC, "volumeInfo.title"), project(...))→ No explicit domain type; theoperations.aggregate(aggregation, "books", BookTitle.class)only gives output type. Domain type is ambiguous.
Confidence: HIGH when Class parameter present, LOW when absent.
Pattern:
operations.update(person,
UpdateOptions.builder()
.ifCondition(Criteria.where("name").is("Walter White"))
.build());How to determine Domain Type:
- The first argument to
operations.update(entity, options)is the entity instance. Its declared/runtime type is the domain type.
JDT AST Strategy:
- From
Criteria.where("name"), walk up to find the enclosingoperations.update(entity, ...)call. - Resolve the type binding of the first argument — that's the domain type.
Confidence: MEDIUM — requires resolving the type of a variable (not a .class literal), but usually straightforward.
Scenario 6: JPA Specification / Criteria API — root.get("property") / cb.equal(root.get("property"), ...)
Pattern:
public static Specification<Customer> accountExpiresBefore(LocalDate date) {
return (root, query, cb) -> {
var accounts = query.from(Account.class);
var expiryDate = accounts.<Date>get("expiryDate");
var customerIsAccountOwner = cb.equal(accounts.<Customer>get("customer"), root);
return cb.and(customerIsAccountOwner, accountExpiryDateBefore);
};
}How to determine Domain Type:
- The
Specification<Customer>return type carries the domain type. - For
root.get("property")—rootis of typeRoot<T>whereTis the domain type. Resolve its type parameter. - For
accounts.get("property")—accountscomes fromquery.from(Account.class), so the domain type for that path isAccount.
JDT AST Strategy:
- Find calls to
.get(String)onRoot<T>,Path<T>,From<?,T>types. - Resolve the type parameter
Tfrom the receiver's type binding to determine the domain type.
Note: This is JPA Criteria API, which already has its own metamodel generator (_ classes). The new Spring Data type-safe paths are a separate mechanism, but users may want both approaches. This scenario may be out of scope for the initial Spring Data type-safe property refactoring since the JPA Criteria API has its own type-safe metamodel approach.
Confidence: MEDIUM — type parameter resolution on generic types is reliable, but the refactoring target is different from Sort.by()/Criteria.where().
Scan for AST nodes matching:
Sort.by(String...)—MethodInvocationonSort, method nameby, withStringliteral argumentsSort.by(Direction, String...)— same as above but first arg isDirectionSort.Order.asc(String)/Sort.Order.desc(String)—MethodInvocationonSort.OrderCriteria.where(String)/where(String)(static import) —MethodInvocationreturningCriteriaUpdate.update(String, Object)— first argument is a property name stringPageRequest.of(int, int, Sort)— transitively containsSort.by(String...)(already caught by rule 1)
For each detected site, walk up the AST to find the enclosing context and extract the domain type:
| Context | Domain Type Source |
|---|---|
| Argument to a Repository method call | Repository's T from Repository<T, ID> type hierarchy |
Inside .query(X.class).matching(...) chain |
X from the query(X.class) call |
Inside .update(X.class).matching(...) chain |
X from the update(X.class) call |
Argument to template.find(query, X.class) |
X from the second parameter |
Inside newAggregation(X.class, ...) |
X from the first parameter |
Argument to operations.update(entity, options) |
Type of entity |
root.get("prop") in JPA Specification |
T from Root<T> type parameter |
- Resolve the domain type's properties via JDT type bindings.
- For a simple property name like
"firstName":- Look for a getter
getFirstName()orisFirstName()on the domain type. - If found → refactoring is possible → suggest
DomainType::getFirstName.
- Look for a getter
- For a dotted path like
"address.country":- Split on
., resolve each segment's type transitively. "address"→Person::getAddressreturnsAddress."country"→Address::getCountry.- Suggest
PropertyPath.of(Person::getAddress).then(Address::getCountry).
- Split on
- If property name doesn't match any domain type property → skip (likely a computed/projected field).
- Simple property:
Sort.by("firstName")→Sort.by(Person::getFirstName) - Multiple properties:
Sort.by("firstName", "lastName")→Sort.by(Person::getFirstName, Person::getLastName) - With direction:
Sort.by(Direction.ASC, "lastName")→ TBD based on new API shape - Nested path:
where("address.country")→where(PropertyPath.of(Person::getAddress).then(Address::getCountry)) - Criteria chains:
where("firstName").is("Dave")→where(Person::getFirstName).is("Dave")
- Scenario 1 (Repository method call with Sort) — Most common, highest confidence domain type resolution (includes
@Aggregation-annotated repository methods) - Scenario 2 (Fluent Template API
.query(X.class)/.update(X.class)) — Very common in MongoDB, explicit domain type - Scenario 3 (Template
find/findOnewith Class parameter) — Common, explicit domain type - Scenario 4 (Aggregation with explicit Class) — Common in MongoDB, but only when
newAggregation(X.class, ...)form used - Scenario 5 (Cassandra UpdateOptions) — Less common, requires variable type resolution
- Scenario 6 (JPA Specification/Criteria API) — Likely out of scope (JPA has its own metamodel approach)
- Computed/projected field names — Aggregation pipelines often reference fields like
"count","total","lineTotal"that are computed within the pipeline, not domain properties. These cannot be refactored. - Dynamic property names — Property names constructed at runtime (
Sort.by(someVariable)) cannot be refactored. - String constants / static final fields — Property names stored in
static final String PROP = "firstName"require constant resolution. Should be supported if the constant can be resolved at analysis time. - MongoDB field name mapping —
@Field("actual_name")annotations may cause the string used in Criteria to differ from the Java property name. The refactoring should use the Java property name for the method reference, not the MongoDB field name. - Inherited properties — Properties declared on a superclass/superinterface of the domain type should be resolved correctly via the type hierarchy.
newAggregation(...)without Class parameter — Domain type is ambiguous; skip or mark as needing manual intervention.- Multiple Sort properties with direction —
Sort.by(Direction.ASC, "a", "b")applies direction to all properties. Need to verify new API supports this or if each needs its own direction.
| Module | String-based APIs Found | Scenarios |
|---|---|---|
| MongoDB | Criteria.where(String), Sort.by(String...), Update.update(String, Object), sort(Direction, String), project(String...), group(String) |
1, 2, 3, 4 |
| JPA | Sort.by(String...), PageRequest.of(..., Sort), JPA Criteria root.get(String) |
1, 6 |
| Cassandra | Criteria.where(String) |
5 |
| JDBC | where(String) in template queries |
3 |
| R2DBC | (no string-based Criteria/Sort patterns found in samples) | — |
| Redis | (uses repository methods with Pageable, no direct Criteria/Sort) | 1 |
- What is the exact new API signature for
Sort.by()with direction + type-safe reference? Is itSort.by(Person::getLastName).ascending()orSort.by(Direction.ASC, Person::getLastName)? - Does
Criteria.where(Person::getFirstName)accept a method reference directly, or does it requireTypedPropertyPath? - Should we also refactor
Update.update("property", value)to a type-safe form? Does the new API support that? - Should
project("field1", "field2")andgroup("field")in the aggregation framework also be refactored, or are those only applicable to MongoDB-specific contexts? - What is the minimum Spring Data version required for the type-safe API? Should the refactoring check the project's dependency version first?