Compare commits

..
Author SHA1 Message Date
Rob Bygrave 292b6d2c72 Alternative Fix for @Column on timestamp defined as timestamp(255)
As per https://github.com/ebean-orm/ebean/discussions/3720

Fix:
Detect the default value of `@Column.length()` which will be
255 when the JPA API is used rather than the Ebean supplied
one.
2026-04-10 00:45:51 +12:00
Rob Bygrave a99ef3ebf2 Bump ebean-datasource dependency to 10.4, resets metrics on initialisation (#3739) 2026-04-09 22:23:55 +12:00
Andrey GlushkovandRob Bygrave 19afa845d1 FEATURE: inTuples() expressions support for natural key cache lookup (#3732)
* `inTuples()` support for natural key cache lookup

* Simplify NaturalKeyEntryBasic.addInPairs()

---------

Co-authored-by: Rob Bygrave <robin.bygrave@gmail.com>
2026-04-09 19:46:33 +12:00
Rob Bygrave 239900cd3b Bump ebean-agent to 16.4.0 with support for Java 26 (#3738) 2026-04-09 19:15:36 +12:00
robin.bygrave 73ac39971c Update test TestErrorBindLog for DB2 2026-04-08 22:55:41 +12:00
robin.bygrave 3a876252ec Update test TestQueryJoinOnFormula, restrict platforms for specific test
Restrict the new test_findCount_formulaJoin_subqueryWithOrderBy_issue3686
to H2 and Postgres for now. Not supported on Oracle and SQL Server.
2026-04-08 22:33:06 +12:00
robin.bygrave 231b52ba88 Update test TestErrorBindLog to improve failure message 2026-04-08 22:27:46 +12:00
robin.bygrave 808019cf3d Modify tests, move setRegister(false) setDefaultServer(false) before loadFromProperties() 2026-04-08 22:15:11 +12:00
Rob Bygrave 364520455f Add some logging for test BeanPersistControllerTest 2026-04-08 08:58:38 +12:00
Rob Bygrave e92621a489 Try old ebean agent, for strange CI build issue (#3737) 2026-04-08 08:51:33 +12:00
Rob Bygrave ccd1b7b8ec Bump ebean-test-containers to 7.18 (#3736) 2026-04-08 08:31:02 +12:00
thomas-lcdp d144273307 ebean-core#3686: use parenthesis-aware ORDER BY removal in buildRowCountQuery to avoid breaking nested subqueries (#3729) 2026-03-27 23:18:15 +13:00
Rob Bygrave 41c5ebdcd7 Bump ebean-agent with ASM 9.9.1 and Java 26 support (#3731) 2026-03-27 23:15:00 +13:00
Rob Bygrave 9e711efecb Merge pull request #3730 from ebean-orm/feature/add-docs-for-graalvm-support
Add GraalVM native image support documentation
2026-03-27 22:06:00 +13:00
robin.bygrave ef7fd76f14 Add GraalVM native image support documentation 2026-03-27 22:05:21 +13:00
robin.bygrave b7e3ddbedd Add GraalVM native image badge to README.md 2026-03-27 21:55:29 +13:00
16 changed files with 461 additions and 48 deletions
+1
View File
@@ -3,6 +3,7 @@
[![Maven Central : ebean](https://maven-badges.herokuapp.com/maven-central/io.ebean/ebean/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.ebean/ebean)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/ebean-orm/ebean/blob/master/LICENSE)
[![Multi-JDK Build](https://github.com/ebean-orm/ebean/actions/workflows/multi-jdk-build.yml/badge.svg)](https://github.com/ebean-orm/ebean/actions/workflows/multi-jdk-build.yml)
[![GraalVM Native Image](https://img.shields.io/badge/GraalVM-Native%20Image%20Ready-darkgreen?logo=graalvm)](https://www.graalvm.org/)
##### Build with database platforms
[![H2Database](https://github.com/ebean-orm/ebean/actions/workflows/h2database.yml/badge.svg)](https://github.com/ebean-orm/ebean/actions/workflows/h2database.yml)
@@ -41,7 +41,7 @@ final class NaturalKeyEntryBasic implements NaturalKeyEntry {
* Create when query uses an IN PAIRS clause.
*/
NaturalKeyEntryBasic(BeanNaturalKey naturalKey, List<NaturalKeyEq> eqList,
String inMapProperty0, String inMapProperty1, Pairs.Entry pair) {
String inMapProperty0, String inMapProperty1, Pairs.Entry pair) {
load(eqList);
map.put(inMapProperty0, pair.getA());
map.put(inMapProperty1, pair.getB());
@@ -49,6 +49,14 @@ final class NaturalKeyEntryBasic implements NaturalKeyEntry {
this.key = calculateKey(naturalKey);
}
NaturalKeyEntryBasic(BeanNaturalKey naturalKey, List<NaturalKeyEq> eqList,
Map<String, Object> properties, Object[] naturalKeyValue) {
load(eqList);
map.putAll(properties);
this.inValue = naturalKeyValue;
this.key = calculateKey(naturalKey);
}
private void load(List<NaturalKeyEq> eqList) {
if (eqList != null) {
for (NaturalKeyEq eq : eqList) {
@@ -3,10 +3,7 @@ package io.ebeaninternal.api;
import io.ebean.Pairs;
import io.ebeaninternal.server.deploy.BeanNaturalKey;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.*;
/**
* Collects the data for processing the natural key cache processing.
@@ -19,8 +16,9 @@ public final class NaturalKeyQueryData<T> {
*/
private boolean hasIn;
// IN Pairs clause - only one allowed
private String inProperty0, inProperty1;
private List<Pairs.Entry> inPairs;
private String[] properties;
private List<Object[]> inTuples;
// IN clause - only one allowed
private List<Object> inValues;
private String inProperty;
@@ -47,14 +45,39 @@ public final class NaturalKeyQueryData<T> {
}
if (matchProperty(property0) && matchProperty(property1)) {
this.hasIn = true;
this.inProperty0 = property0;
this.inProperty1 = property1;
this.properties = new String[]{property0, property1};
this.inPairs = new ArrayList<>(inPairs); // will be modified
return this.inPairs;
}
return null;
}
/**
* Match for In Tuples expression. We only allow one IN clause.
*/
public List<Object[]> matchInTuples(String[] properties, List<Object[]> inTuples) {
if (hasIn) {
// only 1 IN allowed (to project naturalIds)
return null;
}
boolean matchAll = true;
for (String property : properties) {
if (!matchProperty(property)) {
matchAll = false;
break;
}
}
if (matchAll) {
this.hasIn = true;
this.properties = Arrays.copyOf(properties, properties.length);
this.inTuples = new ArrayList<>(inTuples);
return this.inTuples;
}
return null;
}
/**
* Match for IN expression. We only allow one IN clause.
*/
@@ -100,6 +123,8 @@ public final class NaturalKeyQueryData<T> {
addInValues();
} else if (inPairs != null) {
addInPairs();
} else if (inTuples != null) {
addInTuples();
} else {
addEqualsKey();
}
@@ -110,7 +135,17 @@ public final class NaturalKeyQueryData<T> {
// a findList() with an IN Map clause so we project
// for every IN value a natural key combination
for (Pairs.Entry entry : inPairs) {
set.add(new NaturalKeyEntryBasic(naturalKey, eqList, inProperty0, inProperty1, entry));
set.add(new NaturalKeyEntryBasic(naturalKey, eqList, properties[0], properties[1], entry));
}
}
private void addInTuples() {
for (Object[] inTuple : inTuples) {
Map<String, Object> map = new HashMap<>();
for (int i = 0; i < inTuple.length; i++) {
map.put(properties[i], inTuple[i]);
}
set.add(new NaturalKeyEntryBasic(naturalKey, eqList, map, inTuple));
}
}
@@ -152,11 +187,8 @@ public final class NaturalKeyQueryData<T> {
if (inProperty != null) {
exprProps.add(inProperty);
}
if (inProperty0 != null) {
exprProps.add(inProperty0);
}
if (inProperty1 != null) {
exprProps.add(inProperty1);
if (properties != null) {
exprProps.addAll(Arrays.asList(properties));
}
if (eqList != null) {
for (NaturalKeyEq eq : eqList) {
@@ -173,6 +205,7 @@ public final class NaturalKeyQueryData<T> {
int defined = (inValues == null) ? 0 : 1;
defined += (inPairs == null) ? 0 : 2;
defined += (eqList == null) ? 0 : eqList.size();
defined += (inTuples == null) ? 0 : properties.length;
return defined == naturalKey.length();
}
@@ -206,6 +239,9 @@ public final class NaturalKeyQueryData<T> {
} else if (inPairs != null) {
//noinspection SuspiciousMethodCalls
inPairs.remove(inValue);
} else if (inTuples != null) {
//noinspection SuspiciousMethodCalls
inTuples.remove(inValue);
}
}
}
@@ -3,6 +3,7 @@ package io.ebeaninternal.server.deploy;
import io.ebean.config.BeanNotRegisteredException;
import io.ebeaninternal.server.deploy.meta.DeployBeanPropertyAssocOne;
import io.ebeaninternal.server.deploy.parse.AnnotationParser;
import jakarta.persistence.Column;
import java.util.Map;
@@ -12,6 +13,8 @@ import java.util.Map;
*/
final class BeanEmbeddedMetaFactory {
private static final int COLUMN_LENGTH_UNSET = AnnotationParser.COLUMN_LENGTH_UNSET;
/**
* Create BeanProperties for embedded beans using the deployment specific DB column name and table alias.
*/
@@ -58,7 +61,7 @@ final class BeanEmbeddedMetaFactory {
}
private static int dbLength(Column override, BeanProperty source) {
return (override != null && (override.length() != 0)) ? override.length() : source.dbLength();
return (override != null && (override.length() != COLUMN_LENGTH_UNSET)) ? override.length() : source.dbLength();
}
private static int dbScale(Column override, BeanProperty source) {
@@ -5,10 +5,14 @@ import io.ebeaninternal.server.deploy.meta.DeployBeanDescriptor;
import io.ebeaninternal.server.deploy.meta.DeployBeanProperty;
import io.ebeaninternal.server.deploy.meta.DeployBeanPropertyAssoc;
import io.ebeaninternal.server.deploy.meta.DeployBeanPropertyAssocOne;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -19,6 +23,11 @@ import java.util.UUID;
*/
public abstract class AnnotationParser extends AnnotationBase {
/**
* The default/unset value for {@code @Column.length()}. Ebean API uses 0 and JPA uses 255.
*/
public static final int COLUMN_LENGTH_UNSET = _ColumnProbeHolder.class.getAnnotation(_ColumnLengthProbe.class).value().length();
final DeployBeanInfo<?> info;
final DeployBeanDescriptor<?> descriptor;
final Class<?> beanType;
@@ -98,7 +107,7 @@ public abstract class AnnotationParser extends AnnotationBase {
prop.setUnique(columnAnn.unique());
if (columnAnn.precision() > 0) {
prop.setDbLength(columnAnn.precision());
} else if (columnAnn.length() != 0) {
} else if (columnAnn.length() != COLUMN_LENGTH_UNSET) {
// set default 255 on DbTypeMap
prop.setDbLength(columnAnn.length());
}
@@ -132,4 +141,21 @@ public abstract class AnnotationParser extends AnnotationBase {
protected String processFormula(String source) {
return source == null ? null : source.replace("${dbTableName}", descriptor.getBaseTable());
}
/**
* Probe annotation used solely to read the default value of {@code @Column.length()}.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface _ColumnLengthProbe {
Column value() default @Column;
}
/**
* Holder so we can read a bare {@code @Column} instance.
*/
@_ColumnLengthProbe
private static final class _ColumnProbeHolder {
}
}
@@ -12,7 +12,7 @@ final class InTuplesExpression extends AbstractExpression {
private final boolean not;
private final String[] properties;
private final List<Object[]> entries;
private List<Object[]> entries;
InTuplesExpression(InTuples pairs, boolean not) {
super("");
@@ -25,7 +25,15 @@ final class InTuplesExpression extends AbstractExpression {
@Override
public boolean naturalKey(NaturalKeyQueryData<?> data) {
return false;
if (not) {
return false;
}
List<Object[]> copy = data.matchInTuples(properties, entries);
if (copy == null) {
return false;
}
entries = copy;
return true;
}
@Override
@@ -280,7 +280,7 @@ final class CQueryBuilder {
sql = wrapSelectCount(sql);
} else if (wrap || query.isRawSql()) {
// remove order by - mssql does not accept order by in subqueries
int pos = sql.lastIndexOf(" order by ");
int pos = lastTopLevelOrderBy(sql);
if (pos != -1) {
sql = sql.substring(0, pos);
}
@@ -300,6 +300,27 @@ final class CQueryBuilder {
return request.descriptor().includesAggregation(query.detail());
}
/**
* Find the last " order by " that is not inside parentheses (i.e. not inside a subquery).
* Returns the position or -1 if not found.
*/
static int lastTopLevelOrderBy(String sql) {
String target = " order by ";
int depth = 0;
int lastFound = -1;
for (int i = 0; i < sql.length(); i++) {
char c = sql.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
} else if (depth == 0 && c == ' ' && sql.regionMatches(true, i, target, 0, target.length())) {
lastFound = i;
}
}
return lastFound;
}
private String wrapSelectCount(String sql) {
sql = "select count(*) from ( " + sql + ")";
if (selectCountWithAlias) {
@@ -0,0 +1,118 @@
package io.ebeaninternal.server.query;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CQueryBuilderTest {
/**
* Simulates what buildRowCountQuery does: strip the top-level order by, then wrap with count.
* This is the logic that must use lastTopLevelOrderBy instead of lastIndexOf.
*/
private static String simulateCountWrap(String sql) {
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
if (pos != -1) {
sql = sql.substring(0, pos);
}
return "select count(*) from ( " + sql + ") as c";
}
private static int countChar(String s, char c) {
int count = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c) count++;
}
return count;
}
@Test
void lastTopLevelOrderBy_simple() {
String sql = "select t0.id from ad t0 order by t0.id";
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
assertThat(pos).isEqualTo(sql.indexOf(" order by "));
}
@Test
void lastTopLevelOrderBy_noOrderBy() {
String sql = "select t0.id from ad t0";
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
assertThat(pos).isEqualTo(-1);
}
@Test
void lastTopLevelOrderBy_insideSubquery() {
// order by is only inside a subquery - should not be found at top level
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 order by t0.rebate)";
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
assertThat(pos).isEqualTo(-1);
}
@Test
void lastTopLevelOrderBy_bothLevels() {
// order by inside subquery AND at top level - should find only the top-level one
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 order by t0.rebate) order by t0.id";
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
assertThat(sql.substring(pos)).isEqualTo(" order by t0.id");
}
@Test
void lastTopLevelOrderBy_nestedSubqueries() {
// deeply nested order by should not be found
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 where t0.x in (select id from foo order by bar))";
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
assertThat(pos).isEqualTo(-1);
}
/**
* Reproduces the exact scenario from https://github.com/ebean-orm/ebean/issues/3686
*
* With lastIndexOf(" order by "), the inner subquery's order by is matched,
* stripping its closing parenthesis and producing unbalanced SQL.
*/
@Test
void countWrap_formulaJoinWithSubqueryOrderBy_issue3686() {
// This is the SQL that buildRowCountQuery would produce before wrapping,
// matching the bug report: @Formula with JOIN + IN subquery with distinctOn + orderBy
String innerSql = "select t0.id from ad t0"
+ " LEFT JOIN price_range ON price_range.ad_id = t0.id"
+ " where t0.id in (select distinct on (t0.rebate) t0.id from ad t0 order by t0.rebate)";
String countSql = simulateCountWrap(innerSql);
// The subquery's closing ) must be preserved
assertThat(countSql).contains("order by t0.rebate)");
// Parentheses must be balanced
assertThat(countChar(countSql, '(')).isEqualTo(countChar(countSql, ')'));
// Should end with ") as c" - the outer count wrapper's closing paren
assertThat(countSql).endsWith(") as c");
}
@Test
void countWrap_topLevelOrderByIsStripped() {
// When there IS a top-level order by, it should be stripped
String innerSql = "select t0.id from ad t0"
+ " LEFT JOIN price_range ON price_range.ad_id = t0.id"
+ " where t0.id in (select distinct on (t0.rebate) t0.id from ad t0 order by t0.rebate)"
+ " order by price_range.discounted_price";
String countSql = simulateCountWrap(innerSql);
// Top-level order by should be removed
assertThat(countSql).doesNotContain("discounted_price");
// But inner subquery order by must remain intact
assertThat(countSql).contains("order by t0.rebate)");
// Parentheses must be balanced
assertThat(countChar(countSql, '(')).isEqualTo(countChar(countSql, ')'));
}
@Test
void countWrap_simpleOrderByIsStripped() {
// Simple case: top-level order by with no subquery
String innerSql = "select t0.id from ad t0 order by t0.id";
String countSql = simulateCountWrap(innerSql);
assertThat(countSql).isEqualTo("select count(*) from ( select t0.id from ad t0) as c");
}
}
@@ -1,6 +1,8 @@
package org.integration;
import io.ebean.DB;
import io.ebean.InTuples;
import io.ebean.Pairs;
import io.ebean.cache.ServerCache;
import io.ebean.cache.ServerCacheStatistics;
import org.domain.*;
@@ -177,6 +179,76 @@ class IntegrationTest {
.findOne();
}
@Test
void naturalKey_inPairs() throws InterruptedException {
DB.save(new OtherOne("ip_A", "ip_1", "ip_A1"));
DB.save(new OtherOne("ip_A", "ip_2", "ip_A2"));
DB.save(new OtherOne("ip_B", "ip_1", "ip_B1"));
ServerCache nkeyCache = DB.cacheManager().naturalKeyCache(OtherOne.class);
nkeyCache.clear();
Pairs pairs = new Pairs("one", "two")
.add("ip_A", "ip_1")
.add("ip_A", "ip_2")
.add("ip_B", "ip_1");
// first fetch — miss, populates natural key + bean cache
List<OtherOne> list0 = DB.find(OtherOne.class)
.where()
.inPairs(pairs)
.setUseCache(true)
.findList();
assertThat(list0).hasSize(3);
nkeyCache.statistics(true); // reset stats
Thread.sleep(5);
// second fetch — all three should hit the natural key cache
List<OtherOne> list1 = DB.find(OtherOne.class)
.where()
.inPairs(pairs)
.setUseCache(true)
.findList();
assertThat(list1).hasSize(3);
assertThat(nkeyCache.statistics(true).getHitCount()).isEqualTo(3);
}
@Test
void naturalKey_inTuples() throws InterruptedException {
DB.save(new OtherOne("it_A", "it_1", "it_A1"));
DB.save(new OtherOne("it_A", "it_2", "it_A2"));
DB.save(new OtherOne("it_B", "it_1", "it_B1"));
ServerCache nkeyCache = DB.cacheManager().naturalKeyCache(OtherOne.class);
nkeyCache.clear();
InTuples tuples = InTuples.of("one", "two")
.add("it_A", "it_1")
.add("it_A", "it_2")
.add("it_B", "it_1");
// first fetch — miss, populates natural key + bean cache
List<OtherOne> list0 = DB.find(OtherOne.class)
.where()
.inTuples(tuples)
.setUseCache(true)
.findList();
assertThat(list0).hasSize(3);
nkeyCache.statistics(true); // reset stats
Thread.sleep(5);
// second fetch — all three should hit the natural key cache
List<OtherOne> list1 = DB.find(OtherOne.class)
.where()
.inTuples(tuples)
.setUseCache(true)
.findList();
assertThat(list1).hasSize(3);
assertThat(nkeyCache.statistics(true).getHitCount()).isEqualTo(3);
}
@Test
void test() throws InterruptedException {
@@ -30,13 +30,13 @@ public class BeanFindControllerTest extends BaseTestCase {
var config = new DatabaseConfig();
config.setName("h2otherfind");
config.setRegister(false);
config.setDefaultServer(false);
config.loadFromProperties();
config.setDdlGenerate(true);
config.setDdlRun(true);
config.setDdlExtra(false);
config.setRegister(false);
config.setDefaultServer(false);
config.add(new ModUuidGenerator());
config.addClass(EBasic.class);
config.addClass(ECustomId.class);
@@ -173,13 +173,13 @@ public class BeanFindControllerTest extends BaseTestCase {
DatabaseConfig config = new DatabaseConfig();
config.setName("h2otherfind");
config.setRegister(false);
config.setDefaultServer(false);
config.loadFromProperties();
config.setDdlGenerate(true);
config.setDdlRun(true);
config.setDdlExtra(false);
config.setRegister(false);
config.setDefaultServer(false);
config.add(new ModUuidGenerator());
config.addClass(FindControllerMain.class);
config.addClass(SoftRefA.class);
@@ -10,6 +10,8 @@ import io.ebean.event.BeanDeleteIdRequest;
import io.ebean.event.BeanPersistAdapter;
import io.ebean.event.BeanPersistRequest;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tests.model.basic.EBasicVer;
import org.tests.model.basic.UTDetail;
import org.tests.model.basic.UTMaster;
@@ -22,13 +24,15 @@ import static org.assertj.core.api.Assertions.assertThat;
public class BeanPersistControllerTest {
private static final Logger log = LoggerFactory.getLogger(BeanPersistControllerTest.class);
private final PersistAdapter continuePersistingAdapter = new PersistAdapter(true);
private final PersistAdapter stopPersistingAdapter = new PersistAdapter(false);
@Test
public void issued1() {
Database db = getDatabase(continuePersistingAdapter);
Database db = createDatabase(continuePersistingAdapter);
UTMaster bean0 = new UTMaster("m0");
bean0.setJournal(new UTMaster.Journal());
@@ -47,11 +51,12 @@ public class BeanPersistControllerTest {
assertThat(journal.getEntries()).hasSize(2);
db.shutdown();
log.info("done issued1");
}
@Test
public void issue_1341() {
Database db = getDatabase(continuePersistingAdapter);
Database db = createDatabase(continuePersistingAdapter);
UTMaster bean0 = new UTMaster("one0");
UTDetail detail0 = new UTDetail("detail0", 12, 23D);
@@ -77,12 +82,13 @@ public class BeanPersistControllerTest {
}
db.shutdown();
log.info("done issue_1341");
}
@Test
public void testInsertUpdateDelete_given_continuePersistingAdapter() {
Database db = getDatabase(continuePersistingAdapter);
Database db = createDatabase(continuePersistingAdapter);
EBasicVer bean = new EBasicVer("testController");
@@ -102,12 +108,13 @@ public class BeanPersistControllerTest {
assertThat(continuePersistingAdapter.methodsCalled).containsExactly("preDelete", "postDelete");
db.shutdown();
log.info("done testInsertUpdateDelete_given_continuePersistingAdapter");
}
@Test
public void testInsertUpdateDelete_given_stopPersistingAdapter() {
Database db = getDatabase(stopPersistingAdapter);
Database db = createDatabase(stopPersistingAdapter);
EBasicVer bean = new EBasicVer("testController");
@@ -138,18 +145,19 @@ public class BeanPersistControllerTest {
stopPersistingAdapter.methodsCalled.clear();
db.shutdown();
log.info("done testInsertUpdateDelete_given_stopPersistingAdapter");
}
private Database getDatabase(PersistAdapter persistAdapter) {
private Database createDatabase(PersistAdapter persistAdapter) {
DatabaseBuilder config = new DatabaseConfig();
config.setName("h2ebasicver");
config.setRegister(false);
config.setDefaultServer(false);
config.loadFromProperties();
config.setDdlGenerate(true);
config.setDdlRun(true);
config.setDdlExtra(false);
config.setRegister(false);
config.setDefaultServer(false);
config.addClass(EBasicVer.class);
config.addClass(UTMaster.class);
config.addClass(UTDetail.class);
@@ -52,13 +52,13 @@ public class BeanPostLoadTest extends BaseTestCase {
DatabaseBuilder config = new DatabaseConfig();
config.setName("h2ebasicver");
config.setRegister(false);
config.setDefaultServer(false);
config.loadFromProperties();
config.setDdlGenerate(true);
config.setDdlRun(true);
config.setDdlExtra(false);
config.setRegister(false);
config.setDefaultServer(false);
config.addClass(EBasicVer.class);
config.add(postLoad);
@@ -7,23 +7,20 @@ import org.tests.model.basic.Order;
import jakarta.persistence.PersistenceException;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.assertj.core.api.Assertions.assertThat;
public class TestErrorBindLog extends BaseTestCase {
class TestErrorBindLog extends BaseTestCase {
@Test
public void test() {
void test() {
try {
DB.find(Order.class).where().gt("id", "JUNK").findList();
} catch (PersistenceException e) {
String msg = e.getMessage();
if (isHana()) {
assertTrue(msg.contains("Error with property[1] dt[12]data[JUNK]"));
}
else {
assertTrue(msg.contains("Bind values:"));
if (isHana() || isDb2()) {
assertThat(msg).contains("Error with property");
} else {
assertThat(msg).contains("Bind values:");
}
}
}
@@ -4,6 +4,7 @@ import io.ebean.DB;
import io.ebean.Query;
import io.ebean.test.LoggedSql;
import io.ebean.xtest.BaseTestCase;
import io.ebean.xtest.ForPlatform;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.tests.model.basic.Order;
@@ -16,6 +17,8 @@ import org.tests.model.family.ParentPerson;
import java.sql.Date;
import java.util.List;
import static io.ebean.annotation.Platform.H2;
import static io.ebean.annotation.Platform.POSTGRES;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -333,6 +336,52 @@ public class TestQueryJoinOnFormula extends BaseTestCase {
assertThat(loggedSql.get(0)).contains("where coalesce(f2.child_age, 0) = ?");
}
/**
* Test for https://github.com/ebean-orm/ebean/issues/3686
* findCount with Formula join + subquery containing ORDER BY
* should produce valid SQL with properly balanced parentheses.
*
* Before the fix, lastIndexOf(" order by ") matched the ORDER BY inside the
* IN subquery, stripping its closing parenthesis and producing invalid SQL.
*/
@ForPlatform({H2, POSTGRES})
@Test
public void test_findCount_formulaJoin_subqueryWithOrderBy_issue3686() {
LoggedSql.start();
// Subquery with orderBy — the order by inside the subquery triggers the bug.
Query<Order> subQuery = DB.find(Order.class)
.select("id")
.orderBy("id");
// Outer query: formula join (totalItems) via where clause + findCount
int count = DB.find(Order.class)
.where()
.in("id", subQuery)
.eq("totalItems", 3)
.findCount();
assertThat(count).isEqualTo(2);
List<String> sql = LoggedSql.stop();
assertEquals(1, sql.size());
String countSql = sql.get(0);
// The count query must wrap with select count(*) from ( ... )
assertThat(countSql).contains("select count(*) from (");
// The subquery's ORDER BY and closing ) must be preserved
assertThat(countSql).contains("order by t0.id)");
// Parentheses must be balanced in the generated SQL
int open = 0, close = 0;
for (char c : countSql.toCharArray()) {
if (c == '(') open++;
if (c == ')') close++;
}
assertThat(open).as("parentheses must be balanced in: " + countSql).isEqualTo(close);
}
@Test
public void test_softRef() {
+66
View File
@@ -0,0 +1,66 @@
# GraalVM Native Image
Ebean ORM supports GraalVM native image compilation. No additional dependencies or
native-image configuration files are required beyond what Ebean already provides.
## How it works
Ebean uses **bytecode enhancement at compile time** (via the ebean-maven-plugin or
ebean-gradle-plugin). The enhanced entity classes are compiled directly into the native
image. Because the enhancement happens ahead-of-time, Ebean does not rely on runtime
reflection for normal ORM operations — queries, inserts, updates, deletes, and
associations all work without additional configuration.
Entity classes do require reflection registration for native image, but this is handled
**automatically by the querybean-generator** annotation processor at compile time.
Projects using the querybean-generator (which is the standard and expected setup) have
nothing extra to do for `@Entity` classes.
## Setup
Ensure the ebean enhancement plugin runs during your build, as it normally would for
a standard JVM application. No extra steps are required for native image.
**Maven:**
```xml
<plugin>
<groupId>io.ebean</groupId>
<artifactId>ebean-maven-plugin</artifactId>
<version>${ebean.version}</version>
<extensions>true</extensions>
</plugin>
```
**Gradle:**
```kotlin
plugins {
id("io.ebean") version "${ebeanVersion}"
}
```
## DtoQuery and reflection
[`DtoQuery`](https://ebean.io/docs/query/dto) maps SQL results onto plain classes using
their public constructors and setter methods. This mapping uses reflection at runtime, so
DTO classes must be explicitly registered for reflection in your native-image configuration.
Create `src/main/resources/META-INF/native-image/<group-id>/<artifact-id>/reflect-config.json`
and add an entry for each DTO class:
```json
[
{
"name": "com.example.CustomerDto",
"allDeclaredConstructors": true,
"allPublicMethods": true
},
{
"name": "com.example.OrderSummaryDto",
"allDeclaredConstructors": true,
"allPublicMethods": true
}
]
```
> **Note:** `@Entity` classes are registered automatically by the querybean-generator —
> no manual entries are needed for them.
+5 -5
View File
@@ -4,7 +4,7 @@
<parent>
<groupId>org.avaje</groupId>
<artifactId>java11-oss</artifactId>
<version>5.1</version>
<version>5.2</version>
</parent>
<groupId>io.ebean</groupId>
@@ -48,10 +48,10 @@
<ebean-ddl-runner.version>2.3</ebean-ddl-runner.version>
<ebean-migration-auto.version>1.2</ebean-migration-auto.version>
<ebean-migration.version>14.3.0</ebean-migration.version>
<ebean-test-containers.version>7.17</ebean-test-containers.version>
<ebean-datasource.version>10.3</ebean-datasource.version>
<ebean-agent.version>16.3.0</ebean-agent.version>
<ebean-maven-plugin.version>16.3.0</ebean-maven-plugin.version>
<ebean-test-containers.version>7.18</ebean-test-containers.version>
<ebean-datasource.version>10.4</ebean-datasource.version>
<ebean-agent.version>16.4.0</ebean-agent.version>
<ebean-maven-plugin.version>16.4.0</ebean-maven-plugin.version>
<surefire.useModulePath>false</surefire.useModulePath>
</properties>