You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cayenne.apache.org by aa...@apache.org on 2026/09/20 16:08:25 UTC
(cayenne) branch master updated: CAY-3032 ColumnSelect: an entity column over a to-many relationship filters out root rows with no related objects
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git
The following commit(s) were added to refs/heads/master by this push:
new 7aebdc1a3 CAY-3032 ColumnSelect: an entity column over a to-many relationship filters out root rows with no related objects
7aebdc1a3 is described below
commit 7aebdc1a3fc44ddb015a0befbd7319de35abc68f
Author: Andrus Adamchik <an...@objectstyle.com>
AuthorDate: Sun Sep 20 12:00:26 2026 -0400
CAY-3032 ColumnSelect: an entity column over a to-many relationship filters out root rows with no related objects
---
RELEASE-NOTES.txt | 1 +
UPGRADE.md | 19 ++
.../select/CustomColumnSetExtractor.java | 10 +-
.../access/translator/select/TableTree.java | 9 +
.../access/translator/select/TableTreeNode.java | 9 +
.../apache/cayenne/query/ColumnSelectMetadata.java | 3 +-
.../org/apache/cayenne/query/ColumnSelectIT.java | 6 +-
.../query/ColumnSelect_EntityColumnJoinsIT.java | 256 +++++++++++++++++++++
.../query/ColumnSelect_UntypedColumnsIT.java | 18 +-
9 files changed, 315 insertions(+), 16 deletions(-)
diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index 9d89d9d4b..11a2dce8c 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -53,6 +53,7 @@ CAY-3011 PostgreSQL: use transactions on select with fetch size
CAY-3018 EJBQL: broken joins when selecting a vertically inherited entity
CAY-3030 Cayenne stack must be able to start without touching the DataSource
CAY-3031 Expression parameter names may conflict with function names
+CAY-3032 ColumnSelect: an entity column over a to-many relationship filters out root rows with no related objects
----------------------------------
Release: 5.0-M3
diff --git a/UPGRADE.md b/UPGRADE.md
index a1bd39853..99fc68d72 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -259,6 +259,25 @@ Expression caseWhenExp = caseWhen(
The map is keyed by the String IDs passed in, and IDs with no matching object are absent from it.
+* Per [CAY-3032](https://issues.apache.org/jira/browse/CAY-3032) a `ColumnSelect` with an entity column over a
+ to-many relationship no longer filters out the root rows that have no related objects. It now uses an outer join,
+ same as the to-one entity columns always did, so such rows are returned with a `null` in place of the related
+ object:
+
+ ```java
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .select(context);
+
+ // before: artists with no paintings were missing from the result
+ // after: they are present as ["artist name", null]
+ ```
+
+ If you relied on the old behavior, check the related object for `null`, or exclude such rows explicitly by
+ adding a condition on the relationship path to `where(..)` (e.g. `Artist.PAINTING_ARRAY.exists()`). Entity columns
+ whose path is also used in the `where(..)` clause are not affected, as they share the join with the qualifier.
+ Scalar columns (`Artist.PAINTING_ARRAY.dot(Painting.PAINTING_TITLE)`) and aggregates are not affected either.
+
* The `org.apache.cayenne.query.ParameterizedQuery` interface was removed, together with the `createQuery(Map)`
methods of `SQLTemplate`, `ProcedureQuery` and `ObjectSelect` that implemented it. Applying parameters to a mapped
query is now the job of the query descriptor - override `QueryDescriptor.buildQuery(Map)` if you have a custom
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/CustomColumnSetExtractor.java b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/CustomColumnSetExtractor.java
index c4e93c5dc..e216e8be2 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/CustomColumnSetExtractor.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/CustomColumnSetExtractor.java
@@ -150,10 +150,18 @@ class CustomColumnSetExtractor implements ColumnExtractor {
private void ensureJoin(CayennePath prefix) {
// ensure all joins for given property
if(!prefix.isEmpty()) {
+ // An entity column should not filter out the root rows that have no related objects. So the joins
+ // that are only needed for this column must be outer, unlike those that already exist in the tree
+ // (coming from the qualifier), that are left unchanged.
+ TableTree tableTree = context.getTableTree();
+ int nodeCount = tableTree.getNodeCount();
+
PathTranslationResult result = context.getPathTranslator()
.translatePath(context.getMetadata().getDbEntity(), prefix);
result.getDbRelationship().ifPresent(relationship
- -> context.getTableTree().addJoinTable(result.getFinalPath(), relationship, JoinType.LEFT_OUTER));
+ -> tableTree.addJoinTable(result.getFinalPath(), relationship, JoinType.LEFT_OUTER));
+
+ tableTree.useOuterJoinsSince(nodeCount);
}
}
}
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTree.java b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTree.java
index 385576c48..02624537d 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTree.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTree.java
@@ -133,6 +133,15 @@ class TableTree {
return tableNodes.size() + 1;
}
+ /**
+ * Turns the joins added to the tree after it reached a given node count into outer joins.
+ */
+ void useOuterJoinsSince(int nodeCount) {
+ tableNodes.entrySet().stream()
+ .skip(nodeCount - 1)
+ .forEach(e -> e.setValue(e.getValue().outer()));
+ }
+
/**
* Returns the number of distinct table aliases used across the whole statement, including nested
* (subquery) trees, as tracked during translation. A value of 1 means the statement uses a single
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTreeNode.java b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTreeNode.java
index c2b68563b..a010002d2 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTreeNode.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/translator/select/TableTreeNode.java
@@ -59,6 +59,15 @@ class TableTreeNode {
this.additionalQualifier = additionalQualifier;
}
+ /**
+ * Returns a node that is the same as this one, but is joined with an outer join.
+ */
+ TableTreeNode outer() {
+ return joinType == JoinType.LEFT_OUTER
+ ? this
+ : new TableTreeNode(attributePath, relationship, tableAlias, JoinType.LEFT_OUTER, additionalQualifier);
+ }
+
public CayennePath getAttributePath() {
return attributePath;
}
diff --git a/cayenne/src/main/java/org/apache/cayenne/query/ColumnSelectMetadata.java b/cayenne/src/main/java/org/apache/cayenne/query/ColumnSelectMetadata.java
index fe9957a83..48dffb733 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/ColumnSelectMetadata.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/ColumnSelectMetadata.java
@@ -167,8 +167,9 @@ class ColumnSelectMetadata extends ObjectSelectMetadata {
case EmbeddedAttribute ignored -> embeddableSegment(exp);
// An entity segment means that a row holds a single object of the relationship target entity:
- // * to-one: the one related object, or null if there is none (an outer join)
+ // * to-one: the one related object
// * to-many: a row is repeated for each related object, same as "flat()" does for a typed property
+ // In both cases it is an outer join, so a row with no related objects is preserved and holds a null.
// Unlike a typed to-many property, there is no declared List or Map type here that a single object
// would contradict, so both kinds of relationships are resolved the same way.
case ObjRelationship relationship -> entitySegment(relationship.getTargetEntity(), resolver);
diff --git a/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelectIT.java b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelectIT.java
index 418639d39..2cdede6a4 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelectIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelectIT.java
@@ -672,7 +672,8 @@ public class ColumnSelectIT {
.select(context);
assertNotNull(a);
- assertEquals(21, a.size());
+ // 21 paintings of 5 artists, plus 15 artists with no paintings
+ assertEquals(36, a.size());
int idx = 0;
for(Object[] next : a) {
assertNotNull(next);
@@ -873,7 +874,8 @@ public class ColumnSelectIT {
List<Object[]> result = ObjectSelect.query(Artist.class)
.columns(Artist.SELF, Artist.PAINTING_ARRAY.flat(), Artist.PAINTING_ARRAY.dot(Painting.TO_GALLERY))
.select(context);
- assertEquals(21, result.size());
+ // 21 paintings of 5 artists, plus 15 artists with no paintings
+ assertEquals(36, result.size());
for(Object[] next : result) {
assertTrue(next[0] instanceof Artist);
diff --git a/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_EntityColumnJoinsIT.java b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_EntityColumnJoinsIT.java
new file mode 100644
index 000000000..35585d889
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_EntityColumnJoinsIT.java
@@ -0,0 +1,256 @@
+/*****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.cayenne.query;
+
+import org.apache.cayenne.access.DataContext;
+import org.apache.cayenne.exp.property.BaseProperty;
+import org.apache.cayenne.exp.property.PropertyFactory;
+import org.apache.cayenne.test.jdbc.TableHelper;
+import org.apache.cayenne.testdo.testmap.Artist;
+import org.apache.cayenne.testdo.testmap.Gallery;
+import org.apache.cayenne.testdo.testmap.Painting;
+import org.apache.cayenne.unit.CayenneProjects;
+import org.apache.cayenne.unit.CayenneTestsEnv;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.util.List;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class ColumnSelect_EntityColumnJoinsIT {
+
+ @RegisterExtension
+ static final CayenneTestsEnv env = CayenneTestsEnv.forProject(CayenneProjects.TESTMAP_PROJECT);
+
+ private DataContext context;
+
+ /**
+ * Artists 1 to 4 have two paintings each, artist 5 has none. Paintings 1 to 8 have artists, painting 9 has none.
+ * Odd paintings are in a gallery, even ones are not.
+ */
+ @BeforeEach
+ public void createDataSet() throws Exception {
+ context = env.context();
+
+ TableHelper tArtist = env.table("ARTIST", "ARTIST_ID", "ARTIST_NAME");
+ for (int i = 1; i <= 5; i++) {
+ tArtist.insert(i, "artist" + i);
+ }
+
+ env.table("GALLERY", "GALLERY_ID", "GALLERY_NAME").insert(1, "gallery1");
+
+ TableHelper tPainting = env.table("PAINTING", "PAINTING_ID", "PAINTING_TITLE", "ARTIST_ID", "GALLERY_ID");
+ for (int i = 1; i <= 8; i++) {
+ tPainting.insert(i, "painting" + i, i % 4 + 1, i % 2 == 1 ? 1 : null);
+ }
+ tPainting.insert(9, "painting9", null, 1);
+ }
+
+ private static <T> BaseProperty<T> untyped(String path) {
+ return PropertyFactory.createBase(path, null);
+ }
+
+ // checks that there is a single [name, null] row for a root with no related objects
+ private static void assertRootWithNoRelated(List<Object[]> rows, String rootName) {
+ List<Object[]> matched = rows.stream().filter(r -> rootName.equals(r[0])).toList();
+ assertEquals(1, matched.size(), () -> "Expected a single row for " + rootName);
+ assertNull(matched.get(0)[1]);
+ }
+
+ @Test
+ public void toMany() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .select(context);
+
+ assertEquals(9, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_Untyped() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, untyped("artistName"), untyped("paintingArray"))
+ .select(context);
+
+ assertEquals(9, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_SingleColumn() {
+ List<Painting> paintings = ObjectSelect
+ .columnQuery(Artist.class, Artist.PAINTING_ARRAY.flat())
+ .select(context);
+
+ assertEquals(9, paintings.size());
+ assertEquals(8, paintings.stream().filter(Objects::nonNull).count());
+ }
+
+ @Test
+ public void toMany_Paginated() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .pageSize(3)
+ .select(context);
+
+ assertEquals(9, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_QualifierOnRoot() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .where(Artist.ARTIST_NAME.in("artist1", "artist5"))
+ .select(context);
+
+ assertEquals(3, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_OrderingOnTheSamePath() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .orderBy(Artist.PAINTING_ARRAY.dot(Painting.PAINTING_TITLE).asc())
+ .select(context);
+
+ assertEquals(9, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_QualifierOnTheSamePath() {
+ // the join is shared with the qualifier, that is the one to filter the rows
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .where(Artist.PAINTING_ARRAY.dot(Painting.PAINTING_TITLE).like("painting%"))
+ .select(context);
+
+ assertEquals(8, rows.size());
+ }
+
+ @Test
+ public void toMany_QualifierOnTheSamePath_InnerJoinWins() {
+ // Unlike "like" above, "is null" gives different results for inner and outer joins. No painting has a null
+ // title, so the inner join of the qualifier matches nothing, while an outer join would've matched "artist5".
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .where(Artist.PAINTING_ARRAY.dot(Painting.PAINTING_TITLE).isNull())
+ .select(context);
+
+ assertEquals(0, rows.size());
+ }
+
+ @Test
+ public void toMany_OuterQualifierOnTheSamePath() {
+ // an explicitly outer path in the qualifier is a join of its own, and it does match "artist5"
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .where(Artist.PAINTING_ARRAY.outer().dot(Painting.PAINTING_TITLE).isNull())
+ .select(context);
+
+ assertEquals(1, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ }
+
+ @Test
+ public void toMany_ExistsQualifier() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.flat())
+ .where(Artist.PAINTING_ARRAY.exists())
+ .select(context);
+
+ assertEquals(8, rows.size());
+ }
+
+ @Test
+ public void toManyThenToOne() {
+ // DISTINCT is in effect, so there's a row per artist: artists 2 and 4 have paintings in a gallery
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.dot(Painting.TO_GALLERY))
+ .select(context);
+
+ assertEquals(5, rows.size());
+ assertRootWithNoRelated(rows, "artist5");
+ assertRootWithNoRelated(rows, "artist1");
+ assertEquals(2, rows.stream().filter(r -> r[1] instanceof Gallery).count());
+ }
+
+ @Test
+ public void toOneThenToMany() {
+ // 5 paintings in a gallery, each paired with the 5 paintings of that gallery, plus 4 paintings with no gallery
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Painting.class, Painting.PAINTING_TITLE, untyped("toGallery.paintingArray"))
+ .select(context);
+
+ assertEquals(29, rows.size());
+ assertRootWithNoRelated(rows, "painting2");
+ }
+
+ @Test
+ public void toOne() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Painting.class, Painting.PAINTING_TITLE, Painting.TO_ARTIST)
+ .select(context);
+
+ assertEquals(9, rows.size());
+ assertRootWithNoRelated(rows, "painting9");
+ }
+
+ @Test
+ public void toOne_QualifierOnTheSamePath() {
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Painting.class, Painting.PAINTING_TITLE, Painting.TO_ARTIST)
+ .where(Painting.TO_ARTIST.dot(Artist.ARTIST_NAME).like("artist%"))
+ .select(context);
+
+ assertEquals(8, rows.size());
+ }
+
+ @Test
+ public void toOne_QualifierOnTheSamePath_InnerJoinWins() {
+ // no artist has a null name, so the inner join of the qualifier matches nothing, while an outer join
+ // would've matched "painting9"
+ List<Object[]> rows = ObjectSelect
+ .columnQuery(Painting.class, Painting.PAINTING_TITLE, Painting.TO_ARTIST)
+ .where(Painting.TO_ARTIST.dot(Artist.ARTIST_NAME).isNull())
+ .select(context);
+
+ assertEquals(0, rows.size());
+ }
+
+ @Test
+ public void scalarColumnsAndAggregatesUseInnerJoins() {
+ List<Object[]> scalars = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.dot(Painting.PAINTING_TITLE))
+ .select(context);
+ assertEquals(8, scalars.size());
+
+ List<Object[]> counts = ObjectSelect
+ .columnQuery(Artist.class, Artist.ARTIST_NAME, Artist.PAINTING_ARRAY.count())
+ .select(context);
+ assertEquals(4, counts.size());
+ }
+}
diff --git a/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_UntypedColumnsIT.java b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_UntypedColumnsIT.java
index fed0d330f..de4a98927 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_UntypedColumnsIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ColumnSelect_UntypedColumnsIT.java
@@ -130,26 +130,20 @@ public class ColumnSelect_UntypedColumnsIT {
public void toManyPathWithNoRelatedObjects() throws Exception {
env.table("ARTIST", "ARTIST_ID", "ARTIST_NAME").insert(5, "artist5");
- // inner join: the root row is dropped
- List<Object[]> inner = ObjectSelect.columnQuery(Artist.class, untyped("artistName"), untyped("paintingArray"))
- .where(Artist.ARTIST_NAME.eq("artist5"))
- .select(context);
- assertEquals(0, inner.size());
-
- // outer join: the root row is preserved with a null related object
- List<Object[]> outer = ObjectSelect.columnQuery(Artist.class, untyped("artistName"), untyped("paintingArray+"))
+ // an outer join: the root row is preserved with a null related object
+ List<Object[]> rows = ObjectSelect.columnQuery(Artist.class, untyped("artistName"), untyped("paintingArray"))
.where(Artist.ARTIST_NAME.eq("artist5"))
.select(context);
- assertEquals(1, outer.size());
- assertEquals("artist5", outer.get(0)[0]);
- assertNull(outer.get(0)[1]);
+ assertEquals(1, rows.size());
+ assertEquals("artist5", rows.get(0)[0]);
+ assertNull(rows.get(0)[1]);
}
@Test
public void toOnePathWithNoRelatedObject() throws Exception {
env.table("PAINTING", "PAINTING_ID", "PAINTING_TITLE").insert(9, "painting9");
- // unlike to-many, a to-one column is an outer join even with no "+": the root row is preserved
+ // an outer join: the root row is preserved with a null related object
List<Object[]> rows = ObjectSelect.columnQuery(Painting.class, untyped("paintingTitle"), untyped("toArtist"))
.where(Painting.PAINTING_TITLE.eq("painting9"))
.select(context);