Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2017-2026 original authors
*
* 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
*
* https://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 io.micronaut.data.jdbc.h2

import io.micronaut.data.tck.entities.Book
import io.micronaut.data.tck.entities.Author
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification

@MicronautTest
@H2DBProperties
class H2SnakeCaseSpec extends Specification {

@Inject H2BookRepository bookRepository
@Inject H2AuthorRepository authorRepository

void "snake_case find_by_title works"() {
given:
def author = authorRepository.save(new Author(name: 'A'))
def b = bookRepository.save(new Book(author: author, title: 'Snake', totalPages: 100))
expect:
bookRepository.find_by_title(b.title).id == b.id
when:
def book = bookRepository.find_by_author_name(author.name).orElse(null)
then:
book
book.id == b.id
when:
def books = bookRepository.query_all()
then:
books.size() > 0
when:
books = bookRepository.find()
then:
books.size() > 0
cleanup:
bookRepository.delete_all()
authorRepository.deleteAll()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.micronaut.data.tck.entities.Book;

import java.util.List;
import java.util.Optional;

@JdbcRepository(dialect = Dialect.H2)
public abstract class H2BookRepository extends io.micronaut.data.tck.repositories.BookRepository {
Expand All @@ -41,4 +42,14 @@ public H2BookRepository(H2AuthorRepository authorRepository) {

@Query(value = "select count(*) from book b where b.title like :title and b.total_pages > :pages", nativeQuery = true)
abstract int countNativeByTitleWithPagesGreaterThan(String title, int pages);

abstract Book find_by_title(String title);

abstract Optional<Book> find_by_author_name(String authorName);

abstract void delete_all();

abstract List<Book> query_all();

abstract List<Book> find();
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
package io.micronaut.data.processor.visitors.finders;

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.naming.NameUtils;
import io.micronaut.data.processor.visitors.MethodMatchContext;
import org.jspecify.annotations.Nullable;

import java.util.List;
import java.util.regex.Pattern;

/**
* The method matcher that is using {@link MethodNameParser}.
Expand All @@ -40,6 +42,8 @@ public abstract class AbstractMethodMatcher implements MethodMatcher {
protected static final String FOR_UPDATE = "ForUpdate";
protected static final String RETURNING = "Returning";

private static final Pattern SNAKE_CASE = Pattern.compile("^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$");

private final MethodNameParser parser;

public AbstractMethodMatcher(MethodNameParser parser) {
Expand All @@ -50,13 +54,57 @@ public AbstractMethodMatcher(MethodNameParser parser) {
@Nullable
public MethodMatch match(MethodMatchContext matchContext) {
String methodName = matchContext.getMethodElement().getName();
List<MethodNameParser.Match> matches = parser.tryMatch(methodName);
String parseInput = methodName;
if (isSnakeCase(methodName)) {
parseInput = NameUtils.camelCase(methodName);
}
List<MethodNameParser.Match> matches = parser.tryMatch(parseInput);
if (matches.isEmpty()) {
return null;
}
return match(matchContext, matches);
}

private static boolean isSnakeCase(String name) {
return name != null && name.indexOf('_') >= 0 && SNAKE_CASE.matcher(name).matches();
}

/**
* Convert snake_case repository method names to camelCase.
* Only applies when underscores are present. The first token is lower-cased
* and subsequent tokens are capitalized.
*
* Examples:
* - find_by_title -> findByTitle
* - count_distinct_by_name -> countDistinctByName
* - find_first_10_by_name -> findFirst10ByName
*/
@Internal
private static String normalizeSnakeCase(String name) {
if (name == null || name.indexOf('_') < 0) {
return name;
}
StringBuilder sb = new StringBuilder(name.length());
String[] parts = name.split("_");
int outIndex = 0;
for (String part : parts) {
if (part.isEmpty()) {
continue;
}
if (outIndex == 0) {
sb.append(part.toLowerCase());
} else {
char first = part.charAt(0);
sb.append(Character.toUpperCase(first));
if (part.length() > 1) {
sb.append(part.substring(1));
}
}
outIndex++;
}
return sb.toString();
}

/**
* Matched the method.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Copyright 2017-2026 original authors
*
* 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
*
* https://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 io.micronaut.data.processor.visitors

import spock.lang.Unroll
import static io.micronaut.data.processor.visitors.TestUtils.getQuery

class SnakeCaseFindersSpec extends AbstractDataSpec {

void "test simple find_by_title"() {
given:
def repository = buildRepository('test.BookRepository', """
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.GenericRepository;
import io.micronaut.data.tck.entities.Book;

@Repository
interface BookRepository extends GenericRepository<Book, Long> {

Book find_by_title(String title);
}
""")
when:
def method = repository.getRequiredMethod("find_by_title", String)
def query = getQuery(method)
then:
query == 'SELECT book_ FROM io.micronaut.data.tck.entities.Book AS book_ WHERE (book_.title = :p1)'

}

void "test count_distinct_by_name"() {
given:
def repository = buildRepository('test.PersonRepository', """
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.model.entities.Person;
import io.micronaut.data.repository.GenericRepository;

@JdbcRepository(dialect = Dialect.MYSQL)
interface PersonRepository extends GenericRepository<Person, Long> {

long count_distinct_by_name(String name);
}
""")
when:
def method = repository.getRequiredMethod("count_distinct_by_name", String)
then:
method != null
when:
def query = getQuery(method)
then:
query == 'SELECT COUNT(DISTINCT(person_.`id`)) FROM `person` person_ WHERE (person_.`name` = ?)'
}

void "test delete_by_id_returning compiles"() {
given:
def repository = buildRepository('test.BookRepository', """
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import io.micronaut.data.repository.GenericRepository;
import io.micronaut.data.tck.entities.Book;

@JdbcRepository(dialect = Dialect.POSTGRES)
interface BookRepository extends GenericRepository<Book, Long> {

int delete_by_id_returning(Long id);
}
""")
when:
def method = repository.getRequiredMethod("delete_by_id_returning", Long)
def query = getQuery(method)
then:
query == 'DELETE FROM "book" WHERE ("id" = ?) RETURNING "id","author_id","genre_id","title","total_pages","publisher_id","last_updated"'
}

@Unroll
void "test complex snake_case '#name' parses"() {
given:
def repository = buildRepository('test.BookRepository', """
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.GenericRepository;
import io.micronaut.data.tck.entities.Book;
import io.micronaut.data.tck.entities.Author;

@JdbcRepository(dialect = Dialect.H2)
interface BookRepository extends GenericRepository<Book, Long> {

java.util.List<Book> ${name}(String a, String b);
}
""")
expect:
repository.findPossibleMethods(name).findFirst().isPresent()
where:
name << [
'find_all_by_author_name_or_title_like_order_by_total_pages_desc',
'query_all_by_title_or_author_name'
]
}

@Unroll
void "test invalid snake_case #name is rejected"() {
when:
buildRepository('test.BookRepository', """
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.GenericRepository;
import io.micronaut.data.tck.entities.Book;

@Repository
interface BookRepository extends GenericRepository<Book, Long> {

Book ${name}(String title);
}
""")
then:
def ex = thrown(RuntimeException)
ex.message.contains('Unable to implement Repository method')
where:
name << ['_find_by_title', 'find__by_title', 'find_by__title', 'find_by_title_']
}

void "test find_first_10_by_name parses"() {
given:
def repository = buildRepository('test.PersonRepository', """
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.CrudRepository;
import io.micronaut.data.model.entities.Person;

@Repository
interface PersonRepository extends CrudRepository<Person, Long> {

java.util.List<Person> find_first_10_by_name(String name);
}
""")
expect:
repository.findPossibleMethods("find_first_10_by_name").findFirst().isPresent()
}
}
17 changes: 17 additions & 0 deletions src/main/docs/guide/shared/querying/criteria.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,20 @@ snippet::example.BookRepository[project-base="doc-examples/hibernate-example", s
The above example uses `Or` to express a greater than condition and a like condition.

You can also negate any of the aforementioned expressions by adding `Not` prior the name of the expression (example `NotTrue` or `NotContain`).

=== Repository Methods

==== Method Name Query Derivation

Micronaut Data can derive queries from repository method names. Traditionally, this uses CamelCase tokens like `findByTitle`, `countDistinctByName`, `findFirst10ByName`, and `findAllByAuthorNameOrTitleLikeOrderByTotalPagesDesc`.

Starting with this version, repository method names written in snake_case are also supported and are normalized to CamelCase during compilation:

- `find_by_title(String title)` -> `findByTitle(String title)`
- `count_distinct_by_name(String name)` -> `countDistinctByName(String name)`
- `find_first_10_by_name(String name)` -> `findFirst10ByName(String name)`
- `find_all_by_author_name_or_title_like_order_by_total_pages_desc(String a, String b)` -> `findAllByAuthorNameOrTitleLikeOrderByTotalPagesDesc(String a, String b)`

Notes:
- This is syntactic sugar; behavior and semantics are identical to the CamelCase equivalent.
- Operators and clauses (By, And, Or, OrderBy, Distinct, First/Top, restriction endings like Equals, Like, Between, …) follow the same rules once normalized.
Loading