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);