diff --git a/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/ConditionGenerationContext.java b/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/ConditionGenerationContext.java index 1e3f5812b4..0fe1f8281b 100644 --- a/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/ConditionGenerationContext.java +++ b/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/ConditionGenerationContext.java @@ -35,6 +35,9 @@ public class ConditionGenerationContext { protected String joinAlias; protected String joinProperty; protected MetaClass joinMetaClass; + protected String collectionPath; + protected String collectionAlias; + protected String collectionFrom; protected List valueProperties; protected List selectedExpressions; protected boolean elementCollection; @@ -122,6 +125,46 @@ public void setJoinMetaClass(@Nullable MetaClass joinMetaClass) { this.joinMetaClass = joinMetaClass; } + /** + * @return path to the to-many collection property whose condition is generated as an 'exists' subquery + * (e.g. {@code e.tags}), or null if the condition is not generated as a subquery + */ + @Nullable + public String getCollectionPath() { + return collectionPath; + } + + public void setCollectionPath(@Nullable String collectionPath) { + this.collectionPath = collectionPath; + } + + /** + * @return identification variable of the 'exists' subquery correlated with the collection property path + * by a 'member of' condition, or null if the condition is not generated as a subquery + */ + @Nullable + public String getCollectionAlias() { + return collectionAlias; + } + + public void setCollectionAlias(@Nullable String collectionAlias) { + this.collectionAlias = collectionAlias; + } + + /** + * @return content of the 'from' clause of the 'exists' subquery generated for a condition on a to-many + * collection property path (e.g. {@code test_Tag cje_0 left join cje_0.category cje_1}), or null if + * the condition is not generated as a subquery + */ + @Nullable + public String getCollectionFrom() { + return collectionFrom; + } + + public void setCollectionFrom(@Nullable String collectionFrom) { + this.collectionFrom = collectionFrom; + } + @Nullable public List getValueProperties() { return valueProperties; diff --git a/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/PropertyConditionGenerator.java b/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/PropertyConditionGenerator.java index 6d1be9f522..1962e23e9e 100644 --- a/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/PropertyConditionGenerator.java +++ b/jmix-data/data/src/main/java/io/jmix/data/impl/jpql/generator/PropertyConditionGenerator.java @@ -87,25 +87,48 @@ public String generateJoin(ConditionGenerationContext context) { StringBuilder joinPropertyBuilder = new StringBuilder(context.entityAlias); MetaClass metaClass = metadata.getClass(context.getEntityName()); + // A join to a to-many collection multiplies rows of the main query, which requires 'select distinct' + // failing on Oracle if the entity contains a LOB attribute. Such a condition is generated as + // a self-contained 'exists' subquery instead: joins starting from the first to-many property go into + // the subquery 'from' clause and the condition itself is wrapped in 'exists' in generateWhere(). + boolean collectionAsSubquery = !useInnerJoinInCondition && isGeneratedAsSubquery(metaClass, propertyName); + StringBuilder subqueryFromBuilder = null; + while (propertyName.contains(".")) { String baseProperty = StringUtils.substringBefore(propertyName, "."); String childProperty = StringUtils.substringAfter(propertyName, "."); MetaProperty metaProperty = metaClass.getProperty(baseProperty); - if ((useInnerJoinInCondition && metaProperty.getRange().getCardinality().isMany()) || + if (collectionAsSubquery && subqueryFromBuilder == null + && (metaProperty.getType() == ASSOCIATION || metaProperty.getType() == COMPOSITION) + && metaProperty.getRange().getCardinality().isMany()) { + MetaClass collectionMetaClass = metaProperty.getRange().asClass(); + String joinAlias = joinAliasPrefix + context.generateNextJoinIndex(); + + context.setJoinAlias(joinAlias); + context.setJoinProperty(childProperty); + context.setJoinMetaClass(collectionMetaClass); + context.setCollectionPath(joinPropertyBuilder + "." + baseProperty); + context.setCollectionAlias(joinAlias); + // the subquery declares an entity variable correlated with the collection path + // by a 'member of' condition, so joins to it can be added inside the subquery + subqueryFromBuilder = new StringBuilder(collectionMetaClass.getName()).append(" ").append(joinAlias); + joinPropertyBuilder = new StringBuilder(joinAlias); + } else if ((useInnerJoinInCondition && metaProperty.getRange().getCardinality().isMany()) || (metaProperty.getType() == ASSOCIATION || metaProperty.getType() == COMPOSITION)) { String joinAlias = joinAliasPrefix + context.generateNextJoinIndex(); context.setJoinAlias(joinAlias); context.setJoinProperty(childProperty); context.setJoinMetaClass(metaProperty.getRange().asClass()); + StringBuilder targetBuilder = subqueryFromBuilder != null ? subqueryFromBuilder : joinBuilder; if (useInnerJoinInCondition) { - joinBuilder.append(" join "); + targetBuilder.append(" join "); } else { - joinBuilder.append(" left join "); + targetBuilder.append(" left join "); } - joinBuilder.append(joinPropertyBuilder + "." + baseProperty + " " + joinAlias); + targetBuilder.append(joinPropertyBuilder + "." + baseProperty + " " + joinAlias); joinPropertyBuilder = new StringBuilder(joinAlias); } else { joinPropertyBuilder.append(".").append(baseProperty); @@ -119,6 +142,10 @@ public String generateJoin(ConditionGenerationContext context) { propertyName = childProperty; } + if (subqueryFromBuilder != null) { + context.setCollectionFrom(subqueryFromBuilder.toString()); + } + // the property may not exist in the metaClass in case of a dynamic attributes MetaProperty metaProperty = metaClass.findProperty(propertyName); if (metaProperty != null && metadataTools.isElementCollection(metaProperty)) { @@ -146,12 +173,26 @@ public String generateWhere(ConditionGenerationContext context) { if (context.getJoinAlias() != null && context.getJoinProperty() != null) { MetaClass joinMetaClass = context.getJoinMetaClass(); + String where; if (joinMetaClass != null) { String property = getProperty(context.getJoinProperty(), joinMetaClass.getName()); - return generateWhere(propertyCondition, context.getJoinAlias(), property, context.isElementCollection()); + where = generateWhere(propertyCondition, context.getJoinAlias(), property, context.isElementCollection()); } else { // case of ElementCollection - return generateWhere(propertyCondition, context.getJoinAlias(), null, context.isElementCollection()); + where = generateWhere(propertyCondition, context.getJoinAlias(), null, context.isElementCollection()); + } + if (context.getCollectionPath() != null) { + where = String.format("exists (select 1 from %s where %s member of %s and %s)", + context.getCollectionFrom(), + context.getCollectionAlias(), + context.getCollectionPath(), + where); + if (isMatchingEmptyCollection(propertyCondition)) { + // with join-based generation an entity with an empty collection produces an all-null + // left-joined row satisfying these operations, so it must match the subquery form too + where = String.format("(%s is empty or %s)", context.getCollectionPath(), where); + } } + return where; } else { String entityAlias = context.getEntityAlias(); String property = getProperty(propertyCondition.getProperty(), context.getEntityName()); @@ -302,6 +343,48 @@ protected String generateWhere(PropertyCondition propertyCondition, String entit } } + /** + * Returns true if the condition on the given property path is generated as a self-contained + * 'exists' subquery instead of a top-level join. This is the case for a path crossing a to-many + * association or composition (e.g. {@code tags.name}): a top-level join would multiply rows of + * the main query. Paths ending with an element collection keep join-based generation, as well as + * paths not resolvable in the static metadata (e.g. containing a dynamic attribute). + */ + protected boolean isGeneratedAsSubquery(MetaClass metaClass, String property) { + if (!property.contains(".")) { + return false; + } + MetaPropertyPath propertyPath = metaClass.getPropertyPath(property); + if (propertyPath == null + || metadataTools.isElementCollection(propertyPath.getMetaProperty())) { + return false; + } + MetaProperty[] metaProperties = propertyPath.getMetaProperties(); + for (int i = 0; i < metaProperties.length - 1; i++) { + MetaProperty metaProperty = metaProperties[i]; + if ((metaProperty.getType() == ASSOCIATION || metaProperty.getType() == COMPOSITION) + && metaProperty.getRange().getCardinality().isMany()) { + return true; + } + } + return false; + } + + /** + * Returns true if the condition must match entities whose to-many collection is empty. With join-based + * generation such entities produce an all-null left-joined row that satisfies these operations. + */ + protected boolean isMatchingEmptyCollection(PropertyCondition propertyCondition) { + String operation = propertyCondition.getOperation(); + if (PropertyCondition.Operation.IS_SET.equals(operation)) { + return !Boolean.TRUE.equals(propertyCondition.getParameterValue()); + } + if (PropertyCondition.Operation.IS_COLLECTION_EMPTY.equals(operation)) { + return Boolean.TRUE.equals(propertyCondition.getParameterValue()); + } + return PropertyCondition.Operation.NOT_MEMBER_OF_COLLECTION.equals(operation); + } + /** * Returns a trailing {@code ESCAPE} clause for {@code LIKE}-based operations so that * {@code _} and {@code %} produced by {@link QueryUtils#escapeForLike(String)} are diff --git a/jmix-data/eclipselink/src/test/groovy/data_manager/DataManagerPropertyConditionTest.groovy b/jmix-data/eclipselink/src/test/groovy/data_manager/DataManagerPropertyConditionTest.groovy index cf345b0396..701421ccfc 100644 --- a/jmix-data/eclipselink/src/test/groovy/data_manager/DataManagerPropertyConditionTest.groovy +++ b/jmix-data/eclipselink/src/test/groovy/data_manager/DataManagerPropertyConditionTest.groovy @@ -671,6 +671,10 @@ class DataManagerPropertyConditionTest extends DataSpec { propertyConditionGenerator.generateWhere(context).contains("cje_0.name =") } + // A condition on a path crossing a to-many association is generated as a self-contained 'exists' + // subquery instead of a top-level join, so the main query does not require 'select distinct' + // which fails on Oracle if the entity contains a LOB attribute (ORA-00932). + def "PropertyCondition generator join to many test"() { when: @@ -678,11 +682,14 @@ class DataManagerPropertyConditionTest extends DataSpec { def context = new ConditionGenerationContext(property) context.entityName = "test_TestAppEntity" context.entityAlias = "e" + def joinClause = propertyConditionGenerator.generateJoin(context) + def whereClause = propertyConditionGenerator.generateWhere(context) then: - propertyConditionGenerator.generateJoin(context).contains("join e.items ") - propertyConditionGenerator.generateWhere(context).contains(context.joinAlias + ".name =") + joinClause == "" + whereClause.startsWith("exists (select 1 from test_TestAppEntityItem cje_0 where cje_0 member of e.items and ") + whereClause.contains("cje_0.name =") } def "PropertyCondition generator join to one and many test"() { @@ -692,11 +699,14 @@ class DataManagerPropertyConditionTest extends DataSpec { def context = new ConditionGenerationContext(property) context.entityName = "test_TestAppEntityItem" context.entityAlias = "e" + def joinClause = propertyConditionGenerator.generateJoin(context) + def whereClause = propertyConditionGenerator.generateWhere(context) - then: + then: "the to-one prefix is joined at the top level, the collection goes into the subquery" - propertyConditionGenerator.generateJoin(context).contains(" left join e.appEntity cje_0 left join cje_0.items cje_1") - propertyConditionGenerator.generateWhere(context).contains(context.joinAlias + ".name =") + joinClause == " left join e.appEntity cje_0" + whereClause.startsWith("exists (select 1 from test_TestAppEntityItem cje_1 where cje_1 member of cje_0.items and ") + whereClause.contains("cje_1.name =") } @@ -707,11 +717,16 @@ class DataManagerPropertyConditionTest extends DataSpec { def context = new ConditionGenerationContext(property) context.entityName = "test_TestAppEntity" context.entityAlias = "e" + def joinClause = propertyConditionGenerator.generateJoin(context) + def whereClause = propertyConditionGenerator.generateWhere(context) - then: + then: "all joins following the first to-many property are inside the subquery" - propertyConditionGenerator.generateJoin(context).count("join ") == 3 - propertyConditionGenerator.generateWhere(context).contains(context.joinAlias + ".name =") + joinClause == "" + whereClause.startsWith("exists (select 1 from test_TestAppEntityItem cje_0" + + " left join cje_0.appEntity cje_1 left join cje_1.items cje_2" + + " where cje_0 member of e.items and ") + whereClause.contains("cje_2.name =") } def "PropertyCondition generator multiple join to one and many test"() { @@ -726,9 +741,11 @@ class DataManagerPropertyConditionTest extends DataSpec { then: - joinClause.count("join ") == 4 - joinClause.count("left join cje_") == 3 - whereClause.contains(context.joinAlias + ".name =") + joinClause == " left join e.appEntity cje_0" + whereClause.startsWith("exists (select 1 from test_TestAppEntityItem cje_1" + + " left join cje_1.appEntity cje_2 left join cje_2.items cje_3" + + " where cje_1 member of cje_0.items and ") + whereClause.contains("cje_3.name =") } def "basic outer join generation test"() { diff --git a/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/CollectionConditionSubqueryTest.groovy b/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/CollectionConditionSubqueryTest.groovy new file mode 100644 index 0000000000..9a88f5e38f --- /dev/null +++ b/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/CollectionConditionSubqueryTest.groovy @@ -0,0 +1,212 @@ +/* + * Copyright 2026 Haulmont. + * + * 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 + * + * http://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 data_manager.conditions + +import io.jmix.core.LoadContext +import io.jmix.core.Metadata +import io.jmix.core.querycondition.LogicalCondition +import io.jmix.core.querycondition.PropertyCondition +import org.springframework.beans.factory.annotation.Autowired +import test_support.entity.conditions.ModuleA +import test_support.entity.conditions.ModuleB +import test_support.entity.conditions.ModuleC + +import static io.jmix.core.querycondition.PropertyCondition.Operation.CONTAINS +import static io.jmix.core.querycondition.PropertyCondition.Operation.EQUAL +import static io.jmix.core.querycondition.PropertyCondition.Operation.LESS +import static io.jmix.core.querycondition.PropertyCondition.Operation.NOT_EQUAL + +/** + * A condition on a property path crossing a to-many association is generated as a self-contained + * 'exists' subquery instead of a top-level join. The main query therefore returns no duplicates + * even without 'select distinct', which fails on Oracle if the entity contains a LOB attribute + * (ORA-00932: inconsistent datatypes). + * + * Test data (see {@link BaseConditionJoinTest}): + * C: c1(maxSpeed=10), c2(20), c3(30); + * B: b1(maxCount=1, recommendedCs=[c1,c2]), b2(2, [c2,c3]), b3(3, [c3]); + * A: a1(compatibleBs=[b1], compatibleCs=[c1]), a2([b2]), a3([b3]), a4_special, a5(compatibleCs=[c1]), a6(compatibleCs=[c3]) + */ +class CollectionConditionSubqueryTest extends BaseConditionJoinTest { + + @Autowired + protected Metadata metadata + + def "filtering by a nested attribute of a m2m collection produces no duplicates without distinct"() { + when: "two elements of b2's collection match the condition" + def list = dataManager.load(ModuleB) + .condition(PropertyCondition.greaterOrEqual("recommendedCs.maxSpeed", 20d)) + .list() + + then: "each entity is returned exactly once" + list.size() == 3 + list*.name.toSet() == ['B1', 'B2', 'B3'] as Set + } + + def "count by a m2m nested attribute condition counts entities, not joined rows"() { + when: + def loadContext = new LoadContext<>(metadata.getClass(ModuleB)) + loadContext.setQuery(new LoadContext.Query("select e from test_ModuleB e") + .setCondition(PropertyCondition.greaterOrEqual("recommendedCs.maxSpeed", 20d))) + + then: + dataManager.getCount(loadContext) == 3 + } + + def "negative operation on a m2m nested attribute produces no duplicates"() { + when: + def list = dataManager.load(ModuleB) + .condition(PropertyCondition.createWithValue("recommendedCs.maxSpeed", NOT_EQUAL, 10d)) + .list() + + then: "an entity having any element not equal to the value matches exactly once" + list.size() == 3 + list*.name.toSet() == ['B1', 'B2', 'B3'] as Set + } + + def "m2m nested condition combined through OR does not lose entities with an empty collection"() { + when: + def list = dataManager.load(ModuleA) + .condition(LogicalCondition.or( + PropertyCondition.createWithValue("compatibleCs.maxSpeed", EQUAL, 30d), + PropertyCondition.createWithValue("name", CONTAINS, "special") + )) + .list() + + then: "the entity without compatibleCs matches by name" + list.size() == 2 + list*.name.toSet() == ['A4_special', 'A6'] as Set + } + + def "condition on a path crossing two m2m collections"() { + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.createWithValue("compatibleBs.recommendedCs.maxSpeed", LESS, 15d)) + .list() + + then: + list.size() == 1 + list[0].name == 'A1' + } + + def "'is set' = false on a m2m nested attribute matches entities with an empty collection"() { + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.isSet("compatibleBs.maxCount", false)) + .list() + + then: "entities without compatibleBs match, as with the left join based generation" + list.size() == 3 + list*.name.toSet() == ['A4_special', 'A5', 'A6'] as Set + } + + def "'is set' = true on a m2m nested attribute matches only entities having a matching element"() { + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.isSet("compatibleBs.maxCount", true)) + .list() + + then: + list.size() == 3 + list*.name.toSet() == ['A1', 'A2', 'A3'] as Set + } + + def "'is collection empty' = true on a collection under a m2m collection matches entities with an empty collection"() { + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.isCollectionEmpty("compatibleBs.recommendedCs", true)) + .list() + + then: "entities without compatibleBs match, as with the left join based generation" + list.size() == 3 + list*.name.toSet() == ['A4_special', 'A5', 'A6'] as Set + } + + def "'is collection empty' = false on a collection under a m2m collection"() { + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.isCollectionEmpty("compatibleBs.recommendedCs", false)) + .list() + + then: + list.size() == 3 + list*.name.toSet() == ['A1', 'A2', 'A3'] as Set + } + + def "'member of' on a collection under a m2m collection"() { + setup: + def c1 = dataManager.load(ModuleC) + .condition(PropertyCondition.equal("name", "C1")) + .one() + + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.memberOfCollection("compatibleBs.recommendedCs", c1)) + .list() + + then: + list.size() == 1 + list[0].name == 'A1' + } + + def "'not member of' on a collection under a m2m collection matches entities with an empty collection"() { + setup: + def c1 = dataManager.load(ModuleC) + .condition(PropertyCondition.equal("name", "C1")) + .one() + + when: + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.notMemberOfCollection("compatibleBs.recommendedCs", c1)) + .list() + + then: "entities without compatibleBs and entities whose every B does not contain the value match" + list.size() == 5 + list*.name.toSet() == ['A2', 'A3', 'A4_special', 'A5', 'A6'] as Set + } + + def "condition on a m2m collection behind a to-one reference"() { + setup: "A1 references B1 whose collection contains C1 and C2" + def a1 = dataManager.load(ModuleA).condition(PropertyCondition.equal("name", "A1")).one() + def b1 = dataManager.load(ModuleB).condition(PropertyCondition.equal("name", "B1")).one() + a1.defaultB = b1 + dataManager.save(a1) + + when: "the subquery is correlated with a collection path starting at a joined to-one reference" + def list = dataManager.load(ModuleA) + .condition(PropertyCondition.createWithValue("defaultB.recommendedCs.maxSpeed", LESS, 15d)) + .list() + + then: "entities without the reference do not match and are not lost with an error" + list.size() == 1 + list[0].name == 'A1' + } + + def "'and' of two conditions on the same m2m collection checks each element independently"() { + when: "no single element of b1's collection satisfies both conditions" + def list = dataManager.load(ModuleB) + .condition(LogicalCondition.and( + PropertyCondition.createWithValue("recommendedCs.maxSpeed", EQUAL, 10d), + PropertyCondition.createWithValue("recommendedCs.maxSpeed", EQUAL, 20d) + )) + .list() + + then: "each condition is a separate subquery matching different elements of the same collection" + list.size() == 1 + list[0].name == 'B1' + } +} diff --git a/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/JoinAliasTest.groovy b/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/JoinAliasTest.groovy index fa2d14e152..8a349f07f8 100644 --- a/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/JoinAliasTest.groovy +++ b/jmix-data/eclipselink/src/test/groovy/data_manager/conditions/JoinAliasTest.groovy @@ -84,12 +84,13 @@ class JoinAliasTest extends BaseConditionJoinTest { def where = logicalConditionGenerator.generateWhere(context) - then: "correct prefix is used" - join == " left join e.compatibleBs c0 left join c0.recommendedCs c1 left join e.compatibleBs c2 left join e.compatibleCs c3" - where.contains("c1.maxSpeed < ") + then: "correct prefix is used, conditions on to-many paths are generated as 'exists' subqueries" + join.isBlank() + where.contains("exists (select 1 from test_ModuleB c0 left join c0.recommendedCs c1" + + " where c0 member of e.compatibleBs and c1.maxSpeed < ") where.contains("e.name like ") - where.contains("c2.maxCount = :maxCount") - where.contains("c3.maxSpeed = ") + where.contains("exists (select 1 from test_ModuleB c2 where c2 member of e.compatibleBs and c2.maxCount = :maxCount)") + where.contains("exists (select 1 from test_ModuleC c3 where c3 member of e.compatibleCs and c3.maxSpeed = ") } private void propagatePropertiesToChildContexts(ConditionGenerationContext generationContext) { diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java index 19972a004c..04df8da3f2 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java @@ -33,6 +33,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; import org.springframework.lang.Nullable; @@ -65,6 +66,13 @@ public class CollectionLoaderImpl implements CollectionLoader { @Autowired protected MeterRegistry meterRegistry; + /** + * With the legacy join-based condition generation, a condition on a path crossing a to-many + * association joins the collection at the top level and requires 'distinct' to avoid duplicates. + */ + @Value("${jmix.eclipselink.use-inner-join-in-condition:false}") + protected boolean useInnerJoinInCondition; + protected DataContext dataContext; protected CollectionContainer container; protected String query; @@ -211,6 +219,12 @@ protected boolean canLeadToDuplicateResultsRecursive(Condition condition) { if (metadataTools.isElementCollection(mpp.getMetaProperty())) { return true; } + if (!useInnerJoinInCondition) { + // a condition on a path crossing a to-many association is generated as a self-contained + // 'exists' subquery which cannot multiply rows, so 'distinct' (failing on Oracle if the + // entity contains a LOB attribute) is not required + return false; + } MetaProperty[] metaProperties = mpp.getMetaProperties(); //length - 1 because no duplicates will be produced if the only x-to-many property is the last one for (int i = 0; i < metaProperties.length - 1; i++) { diff --git a/jmix-flowui/flowui/src/test/groovy/data_components/CollectionLoaderTest.groovy b/jmix-flowui/flowui/src/test/groovy/data_components/CollectionLoaderTest.groovy index b2b2d4c977..64128c22da 100644 --- a/jmix-flowui/flowui/src/test/groovy/data_components/CollectionLoaderTest.groovy +++ b/jmix-flowui/flowui/src/test/groovy/data_components/CollectionLoaderTest.groovy @@ -17,11 +17,14 @@ package data_components import io.jmix.core.DataManager +import io.jmix.core.querycondition.PropertyCondition import io.jmix.flowui.model.CollectionContainer import io.jmix.flowui.model.CollectionLoader import io.jmix.flowui.model.DataComponents import org.springframework.beans.factory.annotation.Autowired import test_support.entity.Foo +import test_support.entity.Zoo +import test_support.entity.element_collection.EcAlpha import test_support.spec.DataContextSpec import java.util.function.Consumer @@ -121,4 +124,46 @@ class CollectionLoaderTest extends DataContextSpec { 1 * preLoadListener.accept({ it.loadContext.query.queryString == 'select e from test_Foo e where e.name = :name' }) } + + def "distinct is not set for a condition crossing a to-many association"() { + CollectionLoader loader = factory.createCollectionLoader() + CollectionContainer container = factory.createCollectionContainer(Zoo) + loader.setContainer(container) + loader.setQuery('select e from test_Zoo e') + + when: "condition on a nested attribute of a m2m collection" + + loader.setCondition(PropertyCondition.contains('animals.name', 'Rex')) + def loadContext = loader.createLoadContext() + + then: "the condition is generated as an 'exists' subquery producing no duplicates, so no 'select distinct' \ +is added (distinct fails on Oracle if the entity contains a LOB attribute)" + + !loadContext.query.distinct + + when: "condition on an own attribute" + + loader.setCondition(PropertyCondition.contains('name', 'Zoo')) + loadContext = loader.createLoadContext() + + then: + + !loadContext.query.distinct + } + + def "distinct is set for a condition on an element collection"() { + CollectionLoader loader = factory.createCollectionLoader() + CollectionContainer container = factory.createCollectionContainer(EcAlpha) + loader.setContainer(container) + loader.setQuery('select e from test_EcAlpha e') + + when: + + loader.setCondition(PropertyCondition.contains('tags', 'x')) + def loadContext = loader.createLoadContext() + + then: "element collections keep join-based condition generation which can multiply rows" + + loadContext.query.distinct + } }